mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
1638
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c7fcd147 | ||
|
|
6afc3c7821 | ||
|
|
ef943f22a4 | ||
|
|
e33d06a5cc | ||
|
|
70dff9ef68 | ||
|
|
03ff234f0f | ||
|
|
8363c5f0ef | ||
|
|
e455d17bb7 | ||
|
|
aec0fc7a1e | ||
|
|
24586a4b7c | ||
|
|
2c506df38a | ||
|
|
04c08efb0e | ||
|
|
b4cbb1294a | ||
|
|
137211d55d | ||
|
|
48e8c53776 | ||
|
|
72360cbdf4 | ||
|
|
f581c70ccd | ||
|
|
9c8a308ea9 | ||
|
|
dc9096303c | ||
|
|
7fa1e90ed7 | ||
|
|
6e36c02ade | ||
|
|
b411c5bef8 | ||
|
|
16666c3ee0 | ||
|
|
7ee67fc178 | ||
|
|
ae6e25d1b9 | ||
|
|
839f9eb56c | ||
|
|
490b10e07a | ||
|
|
74aa1323d8 | ||
|
|
2fc5a67dce | ||
|
|
8a170eb2ac | ||
|
|
208ecb826e | ||
|
|
ec1ce79be9 | ||
|
|
faaaf399cb | ||
|
|
d10da0f5fc | ||
|
|
b4cbba1e9a | ||
|
|
ad4595a257 | ||
|
|
d2163cf6a0 | ||
|
|
fe1391903a | ||
|
|
069f537bde | ||
|
|
235a15b9cc | ||
|
|
beec2f523a | ||
|
|
690b79d2b7 | ||
|
|
d3e9803069 | ||
|
|
ce6bfe282f | ||
|
|
60dbb461e7 | ||
|
|
1ca0c658b9 | ||
|
|
b2705b8788 | ||
|
|
cfeb4b6a4a | ||
|
|
bd609c3c54 | ||
|
|
dab4dd4a4e | ||
|
|
5814b89821 | ||
|
|
c3337bdedb | ||
|
|
c11079260d | ||
|
|
7ee09ac790 | ||
|
|
b07c25aa7e | ||
|
|
6eb7e1a81f | ||
|
|
6626b1ce7d | ||
|
|
b2a426bb30 | ||
|
|
d89a6e33f3 | ||
|
|
0684f4719e | ||
|
|
2b272d230c | ||
|
|
7b2f7064b9 | ||
|
|
4c0c89c5ff | ||
|
|
d1b1f58f00 | ||
|
|
793f23a390 | ||
|
|
2efb53aab1 | ||
|
|
80d6bc8da6 | ||
|
|
1299ea80b8 | ||
|
|
bca681b5ea | ||
|
|
e2817eba39 | ||
|
|
9fbff1e8e5 | ||
|
|
0c4fac37b0 | ||
|
|
92d2662078 | ||
|
|
ea1cb67502 | ||
|
|
2c52c265cf | ||
|
|
5df5af7ca8 | ||
|
|
1d1d9bb1c4 | ||
|
|
a6f2c2829e | ||
|
|
2a967e84c5 | ||
|
|
0065d07fe3 | ||
|
|
16f28ec5c7 | ||
|
|
0273558edc | ||
|
|
97bab72eb9 | ||
|
|
70c823ae9f | ||
|
|
229bbf8eb8 | ||
|
|
767f9e7fe3 | ||
|
|
a76b742294 | ||
|
|
9d04681a42 | ||
|
|
775dd8126f | ||
|
|
0f4671afd0 | ||
|
|
daa39fd7ec | ||
|
|
a28e2bf9f6 | ||
|
|
c355fc6888 | ||
|
|
d32d857eb3 | ||
|
|
cf4e85c6ee | ||
|
|
fc73433ffb | ||
|
|
8239a96509 | ||
|
|
49e8d6ecc5 | ||
|
|
558999faa8 | ||
|
|
c9e8aeee5c | ||
|
|
1da7c1da67 | ||
|
|
fb71ee7282 | ||
|
|
96362a2835 | ||
|
|
3c692c4321 | ||
|
|
5b63fd27fe | ||
|
|
5b769adcc5 | ||
|
|
073ddf1421 | ||
|
|
fbdd647168 | ||
|
|
18aed41889 | ||
|
|
f4aa894fe7 | ||
|
|
a7024c8f72 | ||
|
|
820bafa11f | ||
|
|
82a9d0ee9e | ||
|
|
5a3a0a189c | ||
|
|
fd05ea0e9c | ||
|
|
cf6c0ae873 | ||
|
|
f3daaf7b15 | ||
|
|
992fc0fed7 | ||
|
|
68d47ec507 | ||
|
|
b38211469a | ||
|
|
9bd12d70e9 | ||
|
|
c0be963f9c | ||
|
|
74e8d89081 | ||
|
|
8468f8505a | ||
|
|
8be251447a | ||
|
|
008e1d7805 | ||
|
|
310071df1b | ||
|
|
87aa7b3b4c | ||
|
|
187138bec5 | ||
|
|
2776f3c37a | ||
|
|
2b981a5734 | ||
|
|
89c51f0f74 | ||
|
|
e6b642fc19 | ||
|
|
512f62c22b | ||
|
|
db84455c34 | ||
|
|
7c0f77499c | ||
|
|
d30e06b268 | ||
|
|
4ac3d2c868 | ||
|
|
8363b221bb | ||
|
|
9f67d7ab76 | ||
|
|
c2325a8b6e | ||
|
|
06a6a7edf3 | ||
|
|
1a10b38743 | ||
|
|
9c97eeb597 | ||
|
|
5b0f629b6e | ||
|
|
a80b035a0a | ||
|
|
d4b6df4f63 | ||
|
|
7122cbccf8 | ||
|
|
8c20af6474 | ||
|
|
8c03ac15ab | ||
|
|
1163ccffe4 | ||
|
|
b1e79cf86d | ||
|
|
2aee5379b5 | ||
|
|
68ce13ead4 | ||
|
|
5dcaca4d0b | ||
|
|
bb36af08fe | ||
|
|
897c18bd08 | ||
|
|
b69111dda3 | ||
|
|
a025d01294 | ||
|
|
9d061a0f53 | ||
|
|
511b8b7d97 | ||
|
|
30083b96c1 | ||
|
|
d6dce12acc | ||
|
|
5606d5d553 | ||
|
|
1072e5f1de | ||
|
|
c419f3e9b2 | ||
|
|
637ec0f901 | ||
|
|
7ff2092547 | ||
|
|
1272c0e8e4 | ||
|
|
0d6beeba47 | ||
|
|
7987a5d925 | ||
|
|
6671e3493e | ||
|
|
4730faea0c | ||
|
|
dc129b2fad | ||
|
|
9b17fa2b93 | ||
|
|
abfc5ff8d5 | ||
|
|
9aeb23e5da | ||
|
|
d9bce1e39e | ||
|
|
c997fd8ea7 | ||
|
|
75ac8bb491 | ||
|
|
e09e1c5f80 | ||
|
|
71f78c14a3 | ||
|
|
7c0d7faa24 | ||
|
|
0f1fbb8fe5 | ||
|
|
1915250529 | ||
|
|
4d75f8d402 | ||
|
|
cc8f590bf4 | ||
|
|
384a185a2e | ||
|
|
285a0018c9 | ||
|
|
930223019e | ||
|
|
4c252a0763 | ||
|
|
bd6bf480da | ||
|
|
2f4e0c4418 | ||
|
|
4caf585048 | ||
|
|
a25f8c8611 | ||
|
|
6cf3ab7007 | ||
|
|
687c6c15f5 | ||
|
|
59cd193b65 | ||
|
|
7bd408b45e | ||
|
|
00ce7cdca6 | ||
|
|
1c5210c210 | ||
|
|
b5a0d43c96 | ||
|
|
c17c582818 | ||
|
|
d3c618a7ad | ||
|
|
021ee1725b | ||
|
|
ffc79ea0e7 | ||
|
|
0cfa048d42 | ||
|
|
5e633ac2f1 | ||
|
|
5e6b06f009 | ||
|
|
0be4fd77e5 | ||
|
|
03f2616f30 | ||
|
|
244d637fac | ||
|
|
71db8e57f5 | ||
|
|
16092ec967 | ||
|
|
42dc83df80 | ||
|
|
3c9229087b | ||
|
|
15a8215a4f | ||
|
|
499d219933 | ||
|
|
1e4b586fa3 | ||
|
|
e3c18db7b8 | ||
|
|
aa59cd1992 | ||
|
|
c0138c3fe0 | ||
|
|
a186bde2a9 | ||
|
|
e9450db83b | ||
|
|
bdf5e3011f | ||
|
|
48c07f9ab3 | ||
|
|
f6240d0911 | ||
|
|
6e74db1f9e | ||
|
|
bfbd41d64b | ||
|
|
d9e1829fcf | ||
|
|
dfb6037797 | ||
|
|
02e50211f4 | ||
|
|
3a689b379a | ||
|
|
e4c2ccd58f | ||
|
|
417ee87422 | ||
|
|
d7eb647c09 | ||
|
|
08651b5917 | ||
|
|
74bd19c063 | ||
|
|
dfe52df7bb | ||
|
|
40a6142e3e | ||
|
|
b415d16408 | ||
|
|
09c667252c | ||
|
|
9ff090c30b | ||
|
|
9fe1819ea5 | ||
|
|
cc92e851ad | ||
|
|
40f1cf6332 | ||
|
|
870e1f2125 | ||
|
|
70305b7937 | ||
|
|
3512042cf3 | ||
|
|
4a9c241eee | ||
|
|
3883c1932c | ||
|
|
feb0e46aab | ||
|
|
5e0ab3423f | ||
|
|
e4a38e803c | ||
|
|
95cd830c01 | ||
|
|
ddc03eaf30 | ||
|
|
b5d8cde93f | ||
|
|
4e8a833d62 | ||
|
|
3add08b100 | ||
|
|
f2b28968a0 | ||
|
|
599cf3ed22 | ||
|
|
ff28357d7a | ||
|
|
8b4c8ad337 | ||
|
|
07b030d045 | ||
|
|
7f799c8490 | ||
|
|
91a5108b85 | ||
|
|
90c8188952 | ||
|
|
198ea76674 | ||
|
|
5758cd94b6 | ||
|
|
0e8fe54ae3 | ||
|
|
5e1455c3d6 | ||
|
|
eab1a42e07 | ||
|
|
fdfd77df9a | ||
|
|
58f2ad9810 | ||
|
|
3cc99a1ea6 | ||
|
|
15781bb038 | ||
|
|
9ec12ac7d7 | ||
|
|
2559c0478c | ||
|
|
07fcdb0d4e | ||
|
|
40407e52d6 | ||
|
|
2898fdf5d3 | ||
|
|
bb4c82ea5a | ||
|
|
1f2730a64f | ||
|
|
43238feada | ||
|
|
573274875c | ||
|
|
2e27c4f499 | ||
|
|
8029ad3b2d | ||
|
|
eca5cdcd9e | ||
|
|
523293d4d9 | ||
|
|
5d20f8fc84 | ||
|
|
e27ad5fdf9 | ||
|
|
5b1f7d796d | ||
|
|
0874c26208 | ||
|
|
1e7b8f08cf | ||
|
|
7c2793752d | ||
|
|
5cde390c24 | ||
|
|
46ebb13fda | ||
|
|
4775a5fd06 | ||
|
|
7e89e7fb6f | ||
|
|
95ea073b77 | ||
|
|
e135d37878 | ||
|
|
2dedb44372 | ||
|
|
97a6c32838 | ||
|
|
a56fcbd785 | ||
|
|
e16603bbd0 | ||
|
|
dfe132699e | ||
|
|
f793ec33b3 | ||
|
|
ce8bddad8f | ||
|
|
3fe0c85055 | ||
|
|
9da48d5387 | ||
|
|
2bd9bc6138 | ||
|
|
a274fe74e1 | ||
|
|
ea5c35198f | ||
|
|
0f6f3bbb8a | ||
|
|
448eb9223d | ||
|
|
cc0be69019 | ||
|
|
9c627f4c16 | ||
|
|
118154a43a | ||
|
|
b29f4db52c | ||
|
|
5efa257a36 | ||
|
|
09ec6d6ec5 | ||
|
|
8cacdc5112 | ||
|
|
d4394f1190 | ||
|
|
6feb9c2762 | ||
|
|
79c6bd1ac4 | ||
|
|
4e94092730 | ||
|
|
82629a57c6 | ||
|
|
92f607a864 | ||
|
|
245483cb35 | ||
|
|
f04fc68513 | ||
|
|
49eb594e5b | ||
|
|
ed84a476c6 | ||
|
|
eb77ca566c | ||
|
|
262357acef | ||
|
|
9e727f9a13 | ||
|
|
d35e8caa2b | ||
|
|
b9c073e26f | ||
|
|
b160fecac4 | ||
|
|
dab727f90f | ||
|
|
0e36df8ae3 | ||
|
|
f7d3516c24 | ||
|
|
e6665d0803 | ||
|
|
6fd4654c21 | ||
|
|
b5c99fad2a | ||
|
|
f588f7f8d4 | ||
|
|
4fe83cbcbc | ||
|
|
069a050f39 | ||
|
|
577615803b | ||
|
|
680ade5975 | ||
|
|
6914645c19 | ||
|
|
1d0b6b9a62 | ||
|
|
0482f4ae08 | ||
|
|
16ce748ffa | ||
|
|
2c199028ea | ||
|
|
22b16a7ac2 | ||
|
|
f2ccde0dd6 | ||
|
|
dd80d49989 | ||
|
|
e3d5f434a4 | ||
|
|
11b8a0ff97 | ||
|
|
e54b292fce | ||
|
|
41222343e3 | ||
|
|
4676f1c820 | ||
|
|
e49b82842d | ||
|
|
e0a39e5630 | ||
|
|
21b050b220 | ||
|
|
9bed1223fd | ||
|
|
4735f0725d | ||
|
|
1f27617a27 | ||
|
|
ee460d677d | ||
|
|
f2ca465ec6 | ||
|
|
40e6961711 | ||
|
|
1bb51988ac | ||
|
|
2c30fa4a94 | ||
|
|
a22186ca01 | ||
|
|
746b11ff65 | ||
|
|
de51741835 | ||
|
|
a633c42904 | ||
|
|
7522424517 | ||
|
|
57f15d7ba4 | ||
|
|
1af672aa1d | ||
|
|
41f8f493aa | ||
|
|
8be95c140a | ||
|
|
add0088704 | ||
|
|
037f06808e | ||
|
|
01456818a8 | ||
|
|
5c3182fb56 | ||
|
|
f515b2db80 | ||
|
|
eb2c2b5aec | ||
|
|
6f181f357b | ||
|
|
1bcc152368 | ||
|
|
26df9d2caa | ||
|
|
45adb1e88c | ||
|
|
6f5b8ce68d | ||
|
|
16a249d5bf | ||
|
|
a52e4320ec | ||
|
|
e8cabb7196 | ||
|
|
7e82ad89ff | ||
|
|
2219b58000 | ||
|
|
3667b4733a | ||
|
|
490e9c0e5f | ||
|
|
7e87201c24 | ||
|
|
e9653c13eb | ||
|
|
255cc4c325 | ||
|
|
fc05b229f1 | ||
|
|
da4599f219 | ||
|
|
a7620d3cdc | ||
|
|
2e48b728dd | ||
|
|
130dca4261 | ||
|
|
2afd7aab38 | ||
|
|
622d372776 | ||
|
|
790c2834f2 | ||
|
|
71dfb8ca24 | ||
|
|
81e419dad4 | ||
|
|
93271730eb | ||
|
|
5d0985cafc | ||
|
|
6ade75e4cd | ||
|
|
86eefab9f5 | ||
|
|
7701010fee | ||
|
|
b3dc262862 | ||
|
|
f0fb52ab74 | ||
|
|
ed4fe3230d | ||
|
|
8217acef3f | ||
|
|
019737286f | ||
|
|
d90ac998aa | ||
|
|
781b98b1c1 | ||
|
|
4986c9af99 | ||
|
|
4868ed2d63 | ||
|
|
2510d47023 | ||
|
|
0d28a3ac3c | ||
|
|
f4ee2005e3 | ||
|
|
4b85367d4e | ||
|
|
433cc3c712 | ||
|
|
ab5162e581 | ||
|
|
3b8058de81 | ||
|
|
f4916594b8 | ||
|
|
4c01f6a789 | ||
|
|
076fb933dc | ||
|
|
bcabad567e | ||
|
|
414ef0bc8b | ||
|
|
9d807fc6d6 | ||
|
|
c43f752eaf | ||
|
|
6e8290cc08 | ||
|
|
bbd3cc7fdb | ||
|
|
90c742ace6 | ||
|
|
0d9573b949 | ||
|
|
b1f63c9cac | ||
|
|
76099d2a42 | ||
|
|
60eb2389a3 | ||
|
|
86a648e759 | ||
|
|
66c066616b | ||
|
|
30f552847a | ||
|
|
b75e2da480 | ||
|
|
eb09413d8b | ||
|
|
05ae607a6a | ||
|
|
f6eb55163f | ||
|
|
b0595a6502 | ||
|
|
2399fb9d15 | ||
|
|
41b9eb1005 | ||
|
|
2f70db4b7e | ||
|
|
25d98d2ab3 | ||
|
|
f56605600f | ||
|
|
0617552956 | ||
|
|
ff784cc772 | ||
|
|
5b5b2c28f7 | ||
|
|
101886e522 | ||
|
|
276c36b6e9 | ||
|
|
45f0b94971 | ||
|
|
5d9a6cdff6 | ||
|
|
f2833eec33 | ||
|
|
4badc2e9c1 | ||
|
|
05b6bf8c04 | ||
|
|
3f6203f125 | ||
|
|
ef0baaa30f | ||
|
|
aef5d57776 | ||
|
|
d76902cebe | ||
|
|
0fa96521c5 | ||
|
|
61232e3537 | ||
|
|
014b18caf8 | ||
|
|
8a51e9cf4d | ||
|
|
fb3fe09466 | ||
|
|
a106851cdc | ||
|
|
687a384fe4 | ||
|
|
b2daf26e6c | ||
|
|
d0604f892b | ||
|
|
d05a529c52 | ||
|
|
cdfa9860bb | ||
|
|
9877946d68 | ||
|
|
3e7b65264e | ||
|
|
54557a79c5 | ||
|
|
412c291193 | ||
|
|
94692dc418 | ||
|
|
6733aed3fb | ||
|
|
2235294c59 | ||
|
|
5bea930d05 | ||
|
|
385affbcaf | ||
|
|
c5c6759868 | ||
|
|
02c407ef9f | ||
|
|
dd5e3b876e | ||
|
|
acf711fbb0 | ||
|
|
aeb0c3f5f7 | ||
|
|
0a5d0c374b | ||
|
|
e9974c0f21 | ||
|
|
1228baa9a3 | ||
|
|
098aceaf10 | ||
|
|
fd43a0d238 | ||
|
|
dc3205148e | ||
|
|
372c1bffe1 | ||
|
|
b4338a209c | ||
|
|
4cbc30f627 | ||
|
|
44abeace88 | ||
|
|
1c7a32113a | ||
|
|
68a3c11d41 | ||
|
|
c0fd21a665 | ||
|
|
12b52a854e | ||
|
|
ccd5232e70 | ||
|
|
567a27f0c4 | ||
|
|
b019c37d3d | ||
|
|
dec3ad9430 | ||
|
|
9729743a29 | ||
|
|
a4e4621eaa | ||
|
|
4337555ea1 | ||
|
|
9018397b09 | ||
|
|
1c9aae5909 | ||
|
|
6489fa4048 | ||
|
|
22850b7cd0 | ||
|
|
f65c8fb9a0 | ||
|
|
3232da71e2 | ||
|
|
c9fc4f1c8f | ||
|
|
0c56c56f74 | ||
|
|
2928b9b7c5 | ||
|
|
1fc6971278 | ||
|
|
2679851d66 | ||
|
|
ab86cd2b1c | ||
|
|
02fce63e11 | ||
|
|
8981a018b0 | ||
|
|
bbf5df6d01 | ||
|
|
6f68cf74a7 | ||
|
|
07f501a67a | ||
|
|
4caf06aafc | ||
|
|
4901d76ebe | ||
|
|
6b0dd4e9cf | ||
|
|
a2c38ad4e8 | ||
|
|
438ccbdc7f | ||
|
|
d6c5430e0c | ||
|
|
d48b731ea4 | ||
|
|
bccb6527a7 | ||
|
|
00cf0f59ec | ||
|
|
3a823bee01 | ||
|
|
98650b9bfb | ||
|
|
a781af5b6a | ||
|
|
f202a0975a | ||
|
|
ea4280d184 | ||
|
|
c519ee1389 | ||
|
|
c61b9dbe60 | ||
|
|
77f4aa6296 | ||
|
|
86a9084d56 | ||
|
|
43def44136 | ||
|
|
8c1be7210e | ||
|
|
24e290746d | ||
|
|
d52931a326 | ||
|
|
73cc019d2f | ||
|
|
41151d47f0 | ||
|
|
3fbd9a995d | ||
|
|
af299bc08d | ||
|
|
93fa12200f | ||
|
|
35f769ad25 | ||
|
|
732cee4837 | ||
|
|
681e78967f | ||
|
|
d2bd1382ba | ||
|
|
c0ec9a3890 | ||
|
|
4221b96431 | ||
|
|
3ddbe2ddb3 | ||
|
|
7ceb786143 | ||
|
|
174d9d5b07 | ||
|
|
a1118d08dd | ||
|
|
379d2575a0 | ||
|
|
e30dcc6fff | ||
|
|
281a5ed97e | ||
|
|
6004f861fe | ||
|
|
174c798d4b | ||
|
|
14b8b5ac61 | ||
|
|
89dec572bb | ||
|
|
f662880c8d | ||
|
|
90cd2770b3 | ||
|
|
69240e7917 | ||
|
|
e1d9c1e414 | ||
|
|
376eaeba52 | ||
|
|
80983662aa | ||
|
|
9840f22688 | ||
|
|
b9e2acaaa5 | ||
|
|
7ac8cd3781 | ||
|
|
7e2f10e842 | ||
|
|
256407b9fd | ||
|
|
7979dfaa16 | ||
|
|
10a87d47da | ||
|
|
59709280da | ||
|
|
81b656f0ab | ||
|
|
f966c5b965 | ||
|
|
56a26848e6 | ||
|
|
28f21bfb70 | ||
|
|
7b4724845f | ||
|
|
2053423864 | ||
|
|
e7519975ec | ||
|
|
7b7389526b | ||
|
|
a464b61d39 | ||
|
|
88ca4563a8 | ||
|
|
449bd68fab | ||
|
|
59256e77d1 | ||
|
|
f11d91bd6b | ||
|
|
e0278d0331 | ||
|
|
2baa2e50ac | ||
|
|
02d14bd98d | ||
|
|
952ae21958 | ||
|
|
50d7d28054 | ||
|
|
2896ec1c15 | ||
|
|
6b81e6383d | ||
|
|
5ed7163ed8 | ||
|
|
df1edec623 | ||
|
|
a5bf8d92d9 | ||
|
|
af95ca587b | ||
|
|
331fc7d947 | ||
|
|
5b56aa3fea | ||
|
|
56479246ed | ||
|
|
773c98b31a | ||
|
|
4f6abca9d7 | ||
|
|
0bbaef4751 | ||
|
|
3ff80119e6 | ||
|
|
63e30dd0ee | ||
|
|
6e01ba18c5 | ||
|
|
a39911d667 | ||
|
|
4c5ab21c64 | ||
|
|
cca1d4efc0 | ||
|
|
78d4217a2a | ||
|
|
25a5a2257c | ||
|
|
0f8075ad41 | ||
|
|
e089e2df4c | ||
|
|
cd20464952 | ||
|
|
cb23428507 | ||
|
|
f285d7d803 | ||
|
|
117713eda3 | ||
|
|
515093abd2 | ||
|
|
726a3538c2 | ||
|
|
17db53403a | ||
|
|
c650f42276 | ||
|
|
6989fbdd44 | ||
|
|
9f0633abbb | ||
|
|
4e6f7c5402 | ||
|
|
0adb45bba4 | ||
|
|
be071be8f2 | ||
|
|
7cf13a8b3b | ||
|
|
90de1ecbf0 | ||
|
|
405af0e173 | ||
|
|
5e9beb0cf6 | ||
|
|
0eb56b1711 | ||
|
|
42ad7f8704 | ||
|
|
ff35b5c378 | ||
|
|
c120443657 | ||
|
|
96c5f33dbe | ||
|
|
cf9f087cba | ||
|
|
47c5e0fabd | ||
|
|
a9def876e8 | ||
|
|
eafbc32d3d | ||
|
|
bbf65dea57 | ||
|
|
fcd3dbb65b | ||
|
|
f21a3f45bf | ||
|
|
1711271ca8 | ||
|
|
25e1398229 | ||
|
|
2d62ff6689 | ||
|
|
25099c03ba | ||
|
|
c6f10e2195 | ||
|
|
1f5f28c820 | ||
|
|
a79b129b0c | ||
|
|
e841aa5685 | ||
|
|
35fef5e021 | ||
|
|
de8a7ef884 | ||
|
|
f3a62e585f | ||
|
|
2936973a75 | ||
|
|
d50230becc | ||
|
|
600a5d5ce1 | ||
|
|
81fb223d5e | ||
|
|
0e6d33aa82 | ||
|
|
3bf0d48641 | ||
|
|
3fae97ac8b | ||
|
|
42cd099eea | ||
|
|
916c48844a | ||
|
|
ce85149b4c | ||
|
|
0055f28f45 | ||
|
|
1d5040dc0c | ||
|
|
60ee4b2c86 | ||
|
|
7cb191d332 | ||
|
|
c50298d408 | ||
|
|
aeac7eedec | ||
|
|
2ae3f9a600 | ||
|
|
05790966db | ||
|
|
a60a3bcbd7 | ||
|
|
61a241b02d | ||
|
|
e1c29bcde1 | ||
|
|
7f4159b6c6 | ||
|
|
4080c1025d | ||
|
|
64e429c5f6 | ||
|
|
24c07c0ed1 | ||
|
|
61249e5e7e | ||
|
|
a2a3bcd4f8 | ||
|
|
71c1cffcac | ||
|
|
a769c3179a | ||
|
|
6180a0b27b | ||
|
|
4e9df0bc21 | ||
|
|
2c44890100 | ||
|
|
febdbd4fd6 | ||
|
|
d622ab5127 | ||
|
|
a893109a06 | ||
|
|
9f888867fe | ||
|
|
12f833fcc3 | ||
|
|
a16dcd315f | ||
|
|
9cd68fcb36 | ||
|
|
ebafda5407 | ||
|
|
a9e1bdcc65 | ||
|
|
f907455620 | ||
|
|
19336cdd3b | ||
|
|
5d37712db4 | ||
|
|
8996671bea | ||
|
|
7b8945ff15 | ||
|
|
fbdf206e31 | ||
|
|
edfe32c59e | ||
|
|
f78966860e | ||
|
|
2162351710 | ||
|
|
a33aa3b7e5 | ||
|
|
e8570029b0 | ||
|
|
4fd123abc1 | ||
|
|
26269df7d4 | ||
|
|
52d8822048 | ||
|
|
97e25eb885 | ||
|
|
fa1338ddd4 | ||
|
|
4d66dc5de3 | ||
|
|
0baf47c8e7 | ||
|
|
65d1fe53d6 | ||
|
|
16bc651afc | ||
|
|
91c1d2e6c6 | ||
|
|
ff9ba0271f | ||
|
|
87746edbf5 | ||
|
|
6f5547b524 | ||
|
|
c0d33d5852 | ||
|
|
485504d885 | ||
|
|
b093cd136f | ||
|
|
7b34d238ea | ||
|
|
8e22de9d2d | ||
|
|
9464541d61 | ||
|
|
fd741f9b49 | ||
|
|
7934e471a7 | ||
|
|
1090197acc | ||
|
|
c89b773f26 | ||
|
|
c5c73c8c96 | ||
|
|
54411bc327 | ||
|
|
98a15d3366 | ||
|
|
17133dc1f5 | ||
|
|
d7792ca369 | ||
|
|
747c13a715 | ||
|
|
583d484573 | ||
|
|
9b0a06815b | ||
|
|
45ae20051f | ||
|
|
d1baab5e7f | ||
|
|
6c3c094e94 | ||
|
|
1ed52d8fe6 | ||
|
|
2fb33c9648 | ||
|
|
4248e57dcb | ||
|
|
85cdbb1185 | ||
|
|
2e729b9e47 | ||
|
|
427acfe82e | ||
|
|
79c4bf1441 | ||
|
|
fea07e4f55 | ||
|
|
5dbfff9bfd | ||
|
|
37fed2064e | ||
|
|
f95e1d2998 | ||
|
|
5586b1640b | ||
|
|
91cbd7a464 | ||
|
|
472177bf39 | ||
|
|
397dc6a5a6 | ||
|
|
636777d935 | ||
|
|
05ca4eb25e | ||
|
|
ebf8504507 | ||
|
|
ba3d74388d | ||
|
|
8b551deda8 | ||
|
|
2ba33d58ed | ||
|
|
33e05c244c | ||
|
|
4ca5d17cb0 | ||
|
|
4ee508d1b2 | ||
|
|
ad836d3776 | ||
|
|
201a201f05 | ||
|
|
4c64ef9d15 | ||
|
|
e4f0ab1e8f | ||
|
|
efa2b76ab0 | ||
|
|
d80385c947 | ||
|
|
bdf02a5ea2 | ||
|
|
41d76ff757 | ||
|
|
c5841b17a0 | ||
|
|
762212747e | ||
|
|
feb09a6fe0 | ||
|
|
06d9d74b67 | ||
|
|
be4c31c10f | ||
|
|
1556c0afdd | ||
|
|
069f568941 | ||
|
|
4ef930c08c | ||
|
|
3850558d49 | ||
|
|
81444cc447 | ||
|
|
c714d86c5c | ||
|
|
c6354ded70 | ||
|
|
cb29db0e45 | ||
|
|
7298ef2ea7 | ||
|
|
ace8356f68 | ||
|
|
19b5c4d857 | ||
|
|
f6a40baa55 | ||
|
|
0afbb69308 | ||
|
|
9e03c743e9 | ||
|
|
dba2dd246e | ||
|
|
198e58c762 | ||
|
|
ef6861a566 | ||
|
|
60e5ed008d | ||
|
|
3838bc6a70 | ||
|
|
a6033f4171 | ||
|
|
6f85ec9dd6 | ||
|
|
e0b72e568d | ||
|
|
34da073800 | ||
|
|
300db5cbfd | ||
|
|
c49bc07e15 | ||
|
|
c31671d1e6 | ||
|
|
9d18d18603 | ||
|
|
8a47fd8414 | ||
|
|
49d584394d | ||
|
|
e3eb266704 | ||
|
|
3e6806ff0e | ||
|
|
76349fa780 | ||
|
|
d4b0e658c3 | ||
|
|
549ec3d1e7 | ||
|
|
d5f3ac0f4c | ||
|
|
a09a564358 | ||
|
|
f873f4b369 | ||
|
|
44e5847a1d | ||
|
|
eedf5b8f67 | ||
|
|
77fea7e295 | ||
|
|
a1c7173dd2 | ||
|
|
dfc406d025 | ||
|
|
10a857c4ad | ||
|
|
e4aeafaf14 | ||
|
|
a1e7b24298 | ||
|
|
7ff06f2b96 | ||
|
|
36fc2ac1db | ||
|
|
64cc9c2b64 | ||
|
|
c0e631e251 | ||
|
|
8e21290b22 | ||
|
|
a05aef260f | ||
|
|
7547a48fd6 | ||
|
|
33c9542524 | ||
|
|
95396ff1c3 | ||
|
|
59091d74f5 | ||
|
|
6226a45d0b | ||
|
|
84958e3f9d | ||
|
|
a2ea2f2604 | ||
|
|
5d97e8e816 | ||
|
|
f3150a9261 | ||
|
|
4068129124 | ||
|
|
a883e692e4 | ||
|
|
5a73a73c36 | ||
|
|
a1856e6edf | ||
|
|
b2f0000a64 | ||
|
|
8054bd66d2 | ||
|
|
0c63852a2c | ||
|
|
47896247fe | ||
|
|
1687f2f896 | ||
|
|
7e5608e2d8 | ||
|
|
55fcd90ed2 | ||
|
|
7db5821967 | ||
|
|
5215ee8eef | ||
|
|
0b50a6a29e | ||
|
|
b69db08414 | ||
|
|
0cd24d120c | ||
|
|
70cf680edd | ||
|
|
660af756e6 | ||
|
|
08c522429b | ||
|
|
02df956f6c | ||
|
|
263bb16619 | ||
|
|
1f47f6dfc9 | ||
|
|
51657bea44 | ||
|
|
498638c0c9 | ||
|
|
51c450727d | ||
|
|
dbc79779ea | ||
|
|
2c2521a688 | ||
|
|
70e95a56b9 | ||
|
|
fa0b0a6c21 | ||
|
|
8b823a571d | ||
|
|
e90451ead8 | ||
|
|
222a8abdb8 | ||
|
|
eb658f86c0 | ||
|
|
d696b8b52c | ||
|
|
2dbf264d46 | ||
|
|
ffc1a0fccd | ||
|
|
fddd65ddb8 | ||
|
|
94a07b5af4 | ||
|
|
af50cfd47a | ||
|
|
609db31027 | ||
|
|
e3ab71a2e9 | ||
|
|
509fab358a | ||
|
|
6b11fb810a | ||
|
|
80b4a96e85 | ||
|
|
f4679ef6d1 | ||
|
|
eb78cdc322 | ||
|
|
8705377232 | ||
|
|
692075df1d | ||
|
|
ebc0298115 | ||
|
|
d1fa5e67d4 | ||
|
|
7489a76d4a | ||
|
|
201209efb3 | ||
|
|
c6abe29e89 | ||
|
|
c8e54cd563 | ||
|
|
0c9e91b953 | ||
|
|
fb4a5e40f3 | ||
|
|
05b697fd24 | ||
|
|
d7c2e7eed7 | ||
|
|
f1fd8247fb | ||
|
|
92912fa9e5 | ||
|
|
e3753f292b | ||
|
|
b57c419769 | ||
|
|
594158deac | ||
|
|
3d3276b02c | ||
|
|
dc2f86dcf3 | ||
|
|
8a57d9b5f6 | ||
|
|
b9e9a754b8 | ||
|
|
54f56d0ecd | ||
|
|
131e45e10a | ||
|
|
b27256421d | ||
|
|
e405f09f33 | ||
|
|
78a9f73de8 | ||
|
|
b6113a1007 | ||
|
|
c34a13b3e4 | ||
|
|
920b5c1655 | ||
|
|
e341adfb29 | ||
|
|
464d588b5e | ||
|
|
0ddc1899fa | ||
|
|
4e1116ae2d | ||
|
|
3cf9f96233 | ||
|
|
c37fe0f438 | ||
|
|
8831b33c01 | ||
|
|
56124574fb | ||
|
|
9b416583f0 | ||
|
|
b885e37b5b | ||
|
|
7e7e763517 | ||
|
|
811c4f7127 | ||
|
|
f0fb471ea1 | ||
|
|
79c1a2dc99 | ||
|
|
a8ce8e7cc0 | ||
|
|
50687515fa | ||
|
|
6f81216e7c | ||
|
|
3423e0a30c | ||
|
|
8fbdb5dfab | ||
|
|
7625fd82e3 | ||
|
|
5dcc66034b | ||
|
|
a66b1af454 | ||
|
|
3adfcf0a29 | ||
|
|
9a4241e4cd | ||
|
|
8dcb2197d6 | ||
|
|
a168f7db5e | ||
|
|
283aba70df | ||
|
|
62abfa903b | ||
|
|
b7fec4c700 | ||
|
|
ec19d5456e | ||
|
|
e5d82dcfde | ||
|
|
b70feb37f2 | ||
|
|
ebf6536374 | ||
|
|
a68bb9c0a8 | ||
|
|
2075814ed1 | ||
|
|
f07ee22969 | ||
|
|
a70490648f | ||
|
|
e3abf8ba33 | ||
|
|
46309ce5b8 | ||
|
|
f392b258f7 | ||
|
|
1afee2ede5 | ||
|
|
0b2fcdcec4 | ||
|
|
735faf4b2c | ||
|
|
d2775e4db9 | ||
|
|
b3fc278b2c | ||
|
|
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 | ||
|
|
58231cc02c | ||
|
|
ab88955111 | ||
|
|
fc5701f243 | ||
|
|
256b455315 | ||
|
|
2916dcc58c | ||
|
|
b9a2d6b7bd | ||
|
|
224a451bbd | ||
|
|
b222f6dcb1 | ||
|
|
9b0293501f | ||
|
|
2733f2e3c5 | ||
|
|
96bd5fb1c2 | ||
|
|
123148e277 | ||
|
|
d7edae99c5 | ||
|
|
de00a043c3 | ||
|
|
396e2c59c7 | ||
|
|
c3b2c846b7 | ||
|
|
be8bba8ff8 | ||
|
|
c81d2b61a6 | ||
|
|
534dc14221 | ||
|
|
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 | ||
|
|
fcf174376d | ||
|
|
0e68596fbd | ||
|
|
9bcbc1e7f3 | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"image":"mcr.microsoft.com/devcontainers/universal:2",
|
||||
"postCreateCommand": "mkdir factorio && cd factorio && wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && mv factorio factorio_1.1.107 && rm linux64 && wget https://factorio.com/get-download/2.0.47/headless/linux64 && tar -xf linux64 && mv factorio factorio_2.0.47 && rm linux64 && pnpm install",
|
||||
}
|
||||
+1
-2
@@ -13,8 +13,7 @@ factorio
|
||||
factorio.tar.gz
|
||||
|
||||
# We build internal mods during build, mods from local test env should not be applied
|
||||
sharedMods
|
||||
sharedModsDisabled
|
||||
mods
|
||||
|
||||
# These files should never be required during the build
|
||||
.git
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
|
||||
"runtime": {
|
||||
"version": "Lua5.2",
|
||||
"requirePattern": [ "?", "?.lua" ]
|
||||
},
|
||||
"workspace": {
|
||||
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
|
||||
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
|
||||
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
|
||||
"ignoreGlobs": [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"database/**",
|
||||
"docs/**",
|
||||
"external_plugins/**",
|
||||
"factorio/**",
|
||||
"instances/**",
|
||||
"logs/**",
|
||||
"mods/**",
|
||||
"modules/**",
|
||||
"temp/**",
|
||||
"packages/create/templates/**"
|
||||
],
|
||||
"moduleMap": [
|
||||
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
|
||||
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
|
||||
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
|
||||
]
|
||||
},
|
||||
"doc": {
|
||||
"privateName": [ "__*" ]
|
||||
},
|
||||
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
|
||||
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
|
||||
"diagnostics": {
|
||||
"globals": [ "__DebugAdapter", "__Profiler" ],
|
||||
"disable": [
|
||||
"assign-type-mismatch",
|
||||
"param-type-mismatch",
|
||||
"preferred-local-alias",
|
||||
"unnecessary-assert",
|
||||
"unnecessary-if",
|
||||
"unused"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
dist/
|
||||
-356
@@ -1,356 +0,0 @@
|
||||
"use strict";
|
||||
module.exports = {
|
||||
"plugins": [
|
||||
"node",
|
||||
],
|
||||
"env": {
|
||||
"node": true,
|
||||
"commonjs": true,
|
||||
"es6": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 11,
|
||||
},
|
||||
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
"env": {
|
||||
"mocha": "true",
|
||||
},
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
"files": ["packages/web_ui/src/**/*.jsx", "packages/lib/browser.js", "plugins/*/web/**/*.jsx"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"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",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
"rules": {
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
"arrow-spacing": ["error", { "after": true, "before": true }],
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
"comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
},
|
||||
],
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": "error",
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "error",
|
||||
"dot-location": ["error", "property"],
|
||||
"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"],
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": ["error", { "before": false, "after": true }],
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
"indent": ["error", "tab", { "SwitchCase": 1 }],
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"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"],
|
||||
"max-classes-per-file": "off",
|
||||
"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": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
"new-cap": "error",
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "error",
|
||||
"no-confusing-arrow": "error",
|
||||
"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": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
"no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-return-await": "off",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": ["error", { "allow": ["yargs"] }],
|
||||
"no-spaced-func": "error",
|
||||
"no-tabs": ["error", { "allowIndentationTabs": true }],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-use-before-define": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": ["error", { "allowAsStatement": true }],
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
"object-curly-newline": "error",
|
||||
"object-curly-spacing": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
},
|
||||
}],
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-named-capture-group": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "error",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
"spaced-comment": ["error", "always"],
|
||||
"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": "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": "error",
|
||||
"node/handle-callback-err": "error",
|
||||
"node/no-callback-literal": "error",
|
||||
"node/no-deprecated-api": "error",
|
||||
"node/no-exports-assign": "error",
|
||||
"node/no-extraneous-import": "error",
|
||||
"node/no-extraneous-require": "error",
|
||||
"node/no-missing-import": "error",
|
||||
"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": "error",
|
||||
"node/no-unsupported-features/es-syntax": "error",
|
||||
"node/no-unsupported-features/node-builtins": "error",
|
||||
"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",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version passed to actions/setup-node
|
||||
required: true
|
||||
ignore-scripts:
|
||||
description: Skip the prepare scripts that build the workspace packages
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
# The lockfile is not committed, so the usual lockfile hash key does not
|
||||
# apply. Instead a new store snapshot is saved whenever a package.json
|
||||
# changes or a week passes, and every run restores the most recent one.
|
||||
- id: store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
||||
# their previous output is present. Every run saves its output and the
|
||||
# next run starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
packages/*/node_modules/.cache
|
||||
plugins/*/node_modules/.cache
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
|
||||
- shell: bash
|
||||
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
|
||||
# Drop packages the restored store had but this install did not use, so
|
||||
# the snapshot does not grow with every version bump.
|
||||
- shell: bash
|
||||
run: pnpm store prune
|
||||
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
|
||||
|
||||
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
|
||||
|
||||
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
|
||||
-->
|
||||
|
||||
### Changelog
|
||||
```
|
||||
### Features
|
||||
- Added doodad to web interface. #999
|
||||
|
||||
### Changes
|
||||
- ...
|
||||
|
||||
### Fixes
|
||||
- ...
|
||||
|
||||
### Breaking Changes
|
||||
- ...
|
||||
```
|
||||
@@ -1,159 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
# Publish `master` as Docker `latest` image.
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
# Publish `v1.2.3` tags as releases.
|
||||
tags:
|
||||
- v*
|
||||
|
||||
# Run tests for any PRs.
|
||||
pull_request:
|
||||
|
||||
release:
|
||||
types: [released, prereleased]
|
||||
|
||||
env:
|
||||
# TODO: Change variable to your image's name.
|
||||
IMAGE_NAME: clusterio
|
||||
|
||||
jobs:
|
||||
# Build dependencies and clusterio image
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build docker image
|
||||
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_final
|
||||
|
||||
- name: Export docker image
|
||||
run: docker save -o clusterio_docker_image $IMAGE_NAME
|
||||
|
||||
- name: Upload docker image
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: clusterio_docker_image
|
||||
# Build dependencies and clusterio image with test tooling
|
||||
build_testing:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Build docker image
|
||||
run: DOCKER_BUILDKIT=1 docker build . -t $IMAGE_NAME --target clusterio_testing
|
||||
|
||||
- name: Export docker image
|
||||
run: docker save -o clusterio_docker_image_testing $IMAGE_NAME
|
||||
|
||||
- name: Upload docker image
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image_testing
|
||||
path: clusterio_docker_image_testing
|
||||
# Upload clusterio_lib and subspace_storage versions
|
||||
upload_mods:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download docker image
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: .
|
||||
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image
|
||||
|
||||
- name: Create container
|
||||
run: docker create --name clusterio_container $IMAGE_NAME
|
||||
|
||||
- name: Move mods out of container
|
||||
run: docker cp clusterio_container:/clusterio/sharedMods ./mods
|
||||
|
||||
- name: Remove container
|
||||
run: docker rm clusterio_container
|
||||
|
||||
- name: List extracted mods
|
||||
run: ls mods
|
||||
|
||||
- name: Upload mods
|
||||
uses: actions/upload-artifact@master
|
||||
with:
|
||||
name: mods
|
||||
path: mods
|
||||
# Run tests.
|
||||
# See also https://docs.docker.com/docker-hub/builds/automated-testing/
|
||||
test:
|
||||
needs: build_testing
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download docker image
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image_testing
|
||||
path: .
|
||||
- name: ls
|
||||
run: ls && ls clusterio_docker_image_testing && ls /home/runner/work/clusterio && ls /home/runner/work/clusterio/clusterio
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image_testing
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -f docker-compose.test.yml ]; then
|
||||
docker-compose --file docker-compose.test.yml run unit_test
|
||||
else
|
||||
docker build . --file Dockerfile
|
||||
fi
|
||||
# Push image to GitHub Packages.
|
||||
# See also https://docs.docker.com/docker-hub/builds/
|
||||
push:
|
||||
# Ensure test job passes before pushing image.
|
||||
needs: [test, build]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Download artifact
|
||||
uses: actions/download-artifact@master
|
||||
with:
|
||||
name: clusterio_docker_image
|
||||
path: .
|
||||
|
||||
- name: Load docker image
|
||||
run: docker load -i clusterio_docker_image
|
||||
|
||||
- name: Log into registry
|
||||
run: echo "${{ secrets.CR_PAT }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
IMAGE_ID=ghcr.io/${{ github.actor }}/$IMAGE_NAME
|
||||
|
||||
# Change all uppercase to lowercase
|
||||
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
|
||||
|
||||
# Strip git ref prefix from version
|
||||
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
|
||||
|
||||
# Strip "v" prefix from tag name
|
||||
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
|
||||
|
||||
# Use Docker `latest` tag convention
|
||||
[ "$VERSION" == "master" ] && VERSION=latest
|
||||
|
||||
echo IMAGE_ID=$IMAGE_ID
|
||||
echo VERSION=$VERSION
|
||||
|
||||
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
|
||||
docker push $IMAGE_ID:$VERSION
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Lua Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: lts/*
|
||||
- name: Generate Factorio typedefs
|
||||
run: |
|
||||
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
|
||||
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
|
||||
- name: Install emmylua_check
|
||||
run: |
|
||||
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP"
|
||||
- name: Run Lint Report
|
||||
shell: bash
|
||||
run: |
|
||||
# The typedefs are generated per run, so their path is passed via a second config
|
||||
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
|
||||
'{ workspace: { library: [ $lib ] } }' \
|
||||
> "$RUNNER_TEMP/ci.emmyrc.json"
|
||||
|
||||
# Exits non zero on error severity, so the gate below is used instead
|
||||
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
|
||||
--output-format json --output check.json || true
|
||||
|
||||
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
|
||||
# Although some minor fixes were needed
|
||||
|
||||
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
code: .code,
|
||||
line: .range.start.line,
|
||||
message: (.message | split("\n")
|
||||
| map(if startswith("- ") then .[2:] else . end)
|
||||
| join("\n")),
|
||||
severity: (if .severity <= 1 then "ERR" else "WARN" end)
|
||||
} |
|
||||
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
|
||||
check.json > "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
echo ""
|
||||
|
||||
# Github Annotations (limited to 10)
|
||||
jq -r --arg root "$PWD/" \
|
||||
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
|
||||
{
|
||||
file: $file,
|
||||
title: .code,
|
||||
line: .range.start.line,
|
||||
endLine: .range.end.line,
|
||||
col: .range.start.character,
|
||||
endColumn: .range.end.character,
|
||||
message: (.message | gsub("\n"; "%0A"; "m")),
|
||||
level: (if .severity <= 1 then "error" else "warning" end)
|
||||
} |
|
||||
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
|
||||
check.json
|
||||
|
||||
# An entry is emitted for every file checked, including clean ones, so
|
||||
# the gate counts diagnostics and asserts the workspace was actually read
|
||||
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
|
||||
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
|
||||
exit 1
|
||||
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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:
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.x
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Supported node versions are active lts and maintenance see https://nodejs.org/en/about/releases/
|
||||
node-version: [24.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
include:
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
coverage: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: Use Factorio ${{ matrix.factorio-version }}
|
||||
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: pnpm test
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
run: pnpm run ci-cover
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ matrix.coverage }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test logs for debugging
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
# npm version spec for the published side of the compatibility test.
|
||||
# If each release is compatible with the one before it then testing
|
||||
# against the latest release transitively covers older versions too.
|
||||
PUBLISHED_VERSION: latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- controller: local
|
||||
host: published
|
||||
- controller: published
|
||||
host: local
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Prepare and install
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
|
||||
run: >
|
||||
node scripts/version-compat-test.js
|
||||
--controller ${{ matrix.controller }}
|
||||
--host ${{ matrix.host }}
|
||||
--published-version $PUBLISHED_VERSION
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
- run: pnpm run lint
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
ignore-scripts: true
|
||||
- run: pnpm run build-mod
|
||||
# Upload build to artifacts
|
||||
- name: Upload build to artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clusterio_lib
|
||||
path: ./dist
|
||||
if-no-files-found: error
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
name: Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- released
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
factorio:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.x
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match run build-mod --output-dir ../../dist
|
||||
- run: node ./publish.js factorio ./dist
|
||||
env:
|
||||
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ./dist/*
|
||||
+9
-12
@@ -1,7 +1,6 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
*debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
@@ -11,9 +10,8 @@ pids
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directories used by tools like nyc and codecov
|
||||
# Coverage directories used by tools like c8 and codecov
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
@@ -30,18 +28,17 @@ dist/
|
||||
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
/factorio
|
||||
/factorio*
|
||||
/instances
|
||||
/sharedMods
|
||||
/mods
|
||||
/database
|
||||
/plugin-list.json
|
||||
/config-master.json
|
||||
/config-control.json
|
||||
/config-slave.json
|
||||
package-lock.json
|
||||
/config-*.json
|
||||
/config-*.json.lock
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
static/export
|
||||
.luarc.json
|
||||
/static/
|
||||
saves
|
||||
/temp/
|
||||
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 12
|
||||
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 --hoist --no-ci
|
||||
- node packages/lib/build_mod --source-dir packages/slave/lua/clusterio_lib --output-dir temp/test/sharedMods
|
||||
- curl -o temp/test/sharedMods/subspace_storage_1.99.8.zip -L https://github.com/clusterio/subspace_storage/releases/download/1.99.6/subspace_storage_1.99.8.zip
|
||||
- npx lerna run build
|
||||
- npm run-script ci-cover
|
||||
- codecov -f coverage/lcov.info
|
||||
- npm run-script lint
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!settings.json
|
||||
Vendored
+11
@@ -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,
|
||||
},
|
||||
}
|
||||
+762
-23
@@ -1,5 +1,746 @@
|
||||
# Changelog
|
||||
|
||||
<!-- Template for entries. Add sections that apply as changes are made.
|
||||
## Version 2.0.0-alpha.xx
|
||||
|
||||
### Major Features
|
||||
|
||||
- Major additions and/or major improvements to managing clusters.
|
||||
|
||||
### Features
|
||||
|
||||
- New features added.
|
||||
|
||||
### Changes
|
||||
|
||||
- Small changes to existing behaviour.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Bugfixes
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changes which break backwards compatibility with older hosts or plugins code.
|
||||
|
||||
### Meta
|
||||
|
||||
- Changes to the project activities / structure worth highlighting but have no impact on the published package.
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@username](https://github.com/username)
|
||||
-->
|
||||
|
||||
## Version 2.0.0
|
||||
|
||||
### Features
|
||||
|
||||
- Mod packs now show informational notices for disabled recommended mods and available compatible updates. [#949](<https://github.com/clusterio/clusterio/issues/949>)
|
||||
- Added host, server name, save copy and start options to the create instance dialog in the web UI. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||
- Added `InstanceSaveGameRequest` and `clusterioctl instance save-game` to save the running game of an instance. [#405](<https://github.com/clusterio/clusterio/issues/405>)
|
||||
- Added `permissions` to `PluginDeclaration` and a `Permissions` type registry so plugin permission names are checked at compile time. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Added Update All to the web interface and `--all` to the ctl update commands, updating the core package and all npm installed plugins in one npm call. [#910](<https://github.com/clusterio/clusterio/issues/910>)
|
||||
- Added typed hook system for plugin registration. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Added `plugin install <package>` command to controller, host and ctl which installs a plugin from npm and adds it to the plugin list. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||
- Added a guard that prevents controller and host restart requests from unintentionally downgrading Clusterio. [#950](<https://github.com/clusterio/clusterio/issues/950>)
|
||||
- Added option to load static assets from a separate URL from where the controller's web API is hosted at. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||
- Added `clusteriocontroller copy-static` command to copy static web assets to a folder to ease hosting assets on a separate server. [#983](<https://github.com/clusterio/clusterio/pull/983>)
|
||||
- Added Mark Restart Required to the host and controller pages and matching ctl commands, for plugins installed by other means than the install request. [#1043](<https://github.com/clusterio/clusterio/pull/1043>)
|
||||
- Added the RoleViewPageBeforePermissions web extension slot for plugin components above the permissions on the role page. [#1045](<https://github.com/clusterio/clusterio/pull/1045>)
|
||||
- Added a list of permission groups to the role page, highlighting groups with granted permissions. [#1044](<https://github.com/clusterio/clusterio/pull/1044>)
|
||||
- Added `clusteriohost create-scenario` to create a scenario with the Clusterio modules patched in for use outside of Clusterio. [#1014](<https://github.com/clusterio/clusterio/pull/1014>)
|
||||
- Plugins can declare a `migrationEntrypoint` to migrate the config fields and role permissions core stores for them. Configs track the plugin version they were last loaded with in a hidden `<plugin>.version` field. [#1073](<https://github.com/clusterio/clusterio/pull/1073>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Deprecation warning DEP0190 is no longer printed when installing. [#851](<https://github.com/clusterio/clusterio/issues/851>)
|
||||
- Events sent to subscribers are no longer addressed to the request id of the request that created the subscription. [#847](<https://github.com/clusterio/clusterio/issues/847>)
|
||||
- Factorio account token no longer written to the log when a mod download fails. [#795](<https://github.com/clusterio/clusterio/issues/795>)
|
||||
- Failed downloads no longer leave a temporary file and an open file handle behind, which could shift the file names used by later downloads. [#957](<https://github.com/clusterio/clusterio/pull/957>)
|
||||
- Requests that could not be forwarded to the next hop are no longer answered a second time when the link disconnects. [#650](<https://github.com/clusterio/clusterio/issues/650>)
|
||||
- Events broadcast from an instance to all hosts are now delivered to every host instead of only the one running the instance. [#575](<https://github.com/clusterio/clusterio/issues/575>)
|
||||
- Web interface now asks to be reloaded when the controller's plugins change, instead of continuing against plugins that are no longer loaded. [#804](<https://github.com/clusterio/clusterio/issues/804>)
|
||||
- Plugin list no longer reports a plugin added since the page was opened as having failed to load. [#729](<https://github.com/clusterio/clusterio/issues/729>)
|
||||
- Instance console no longer hides the Factorio server's replies to console commands, or Factorio's own errors, when filtering to game activity. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||
- Allow player online autosave slots to be disabled while autosaves are enabled. [#966](<https://github.com/clusterio/clusterio/issues/966>)
|
||||
- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||
- Fixed inventory_sync not preserving the personal roboport toggle, the equipment movement bonus toggle, game view settings such as alt mode, and toggleable mod shortcuts between instances. [#898](<https://github.com/clusterio/clusterio/issues/898>)
|
||||
- Schema validation errors are now included when an InvalidMessage is logged from IPC and event handlers. [#986](<https://github.com/clusterio/clusterio/issues/986>)
|
||||
- Fixed clusterioctl not being able to reference hosts, instances, mod packs and roles by an integer like name. [#886](<https://github.com/clusterio/clusterio/issues/886>)
|
||||
- Fixed the web-only plugin template referencing a permission that was never defined. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Config files are no longer world readable by default. [#1005](<https://github.com/clusterio/clusterio/issues/1005>)
|
||||
- Fixed failed recipe notification names being duplicated on every upload from the same instance in inventory sync. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||
- Instances refuse to start with a mod pack for a newer Factorio version than the instance runs, instead of Factorio silently loading default mod settings. [#1006](<https://github.com/clusterio/clusterio/issues/1006>)
|
||||
- Documented the config options missing from configuration.md, marked the internal ones and noted the validation rules. [#985](<https://github.com/clusterio/clusterio/issues/985>)
|
||||
- Fixed concurrent saves of the same file racing on a shared temporary file. [#1026](<https://github.com/clusterio/clusterio/pull/1026>)
|
||||
- Fixed control connections leaving their log subscription and debug dump listeners attached after closing. [#1027](<https://github.com/clusterio/clusterio/pull/1027>)
|
||||
- Fixed the Load Scenario modal reading the wrong field for map settings. [#1032](<https://github.com/clusterio/clusterio/pull/1032>)
|
||||
- Fixed the controller exiting when a control message's permission check threw an unexpected error. [#1033](<https://github.com/clusterio/clusterio/pull/1033>)
|
||||
- Fixed the host exiting on server log lines that look like actions but do not match the expected format. [#1034](<https://github.com/clusterio/clusterio/pull/1034>)
|
||||
- Fixed the controller crashing when a control connection with the WebSocket debug dump active was resumed. [#1035](<https://github.com/clusterio/clusterio/pull/1035>)
|
||||
- Fixed deleted users keeping their permissions on any open connection until they disconnected. [#1030](<https://github.com/clusterio/clusterio/pull/1030>)
|
||||
- Restored pagination on the instance save list. [#967](<https://github.com/clusterio/clusterio/issues/967>)
|
||||
- Mod dependency resolution now only picks releases made for the mod pack's Factorio version. [#965](<https://github.com/clusterio/clusterio/issues/965>)
|
||||
- Inventory sync no longer uploads an empty inventory over the stored one after a failed download. [#912](<https://github.com/clusterio/clusterio/issues/912>)
|
||||
- Inventory sync now releases players who leave while using a temporary inventory, instead of showing them as busy until the instance stops. [#975](<https://github.com/clusterio/clusterio/pull/975>)
|
||||
- Fixed reading map exchange strings from Factorio 2.1.13 and later, which broke `instance load-scenario --map-exchange-string` on current Factorio. [#977](<https://github.com/clusterio/clusterio/pull/977>)
|
||||
- Fixed instances getting stuck on a host after a failed assign, and instance create requests no longer accept `instance.assigned_host`. [#1029](<https://github.com/clusterio/clusterio/pull/1029>)
|
||||
- Fixed the host exiting when an instance's whitelist or server settings config changed while it was starting, or when tags contained non-string values. [#1028](<https://github.com/clusterio/clusterio/pull/1028>)
|
||||
- Fixed a host being able to modify the controller's stored save list for, or forge log messages attributed to, instances assigned to other hosts. [#1036](<https://github.com/clusterio/clusterio/pull/1036>)
|
||||
- Fixed a stop request or RCON command hanging when the Factorio process exited before its RCON connection was established. [#1031](<https://github.com/clusterio/clusterio/pull/1031>)
|
||||
- Fixed core plugins needlessly installing the web_ui, controller, host and ctl packages. [#1021](<https://github.com/clusterio/clusterio/pull/1021>)
|
||||
- Installing a plugin on a host or the controller now marks it as needing a restart. [#1042](<https://github.com/clusterio/clusterio/pull/1042>)
|
||||
- Fixed plugin manifest path resolution when loading web assets. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||
- A plugin whose web build is expected but missing is reported again: `Controller.loadJsonObject(path, true)` throws on a missing file instead of returning `{}`, and a malformed JSON file always throws. [#1046](<https://github.com/clusterio/clusterio/issues/1046>)
|
||||
- Preserve full configuration field names when using the CLI config editor. [#796](<https://github.com/clusterio/clusterio/issues/796>)
|
||||
- Plugin metrics hook handlers may return nothing. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||
- Fixed save names given when starting an instance or creating a save not being checked, allowing files outside the instance directory to be written or modified. [#1053](<https://github.com/clusterio/clusterio/pull/1053>)
|
||||
- Fixed access tokens from request headers being written to the controller and cluster logs. [#1054](<https://github.com/clusterio/clusterio/pull/1054>)
|
||||
- Fixed subscriptions and log following continuing after the user lost permission for them. [#1056](<https://github.com/clusterio/clusterio/pull/1056>)
|
||||
- Fixed web interface data staying marked as up to date after the user lost permission to view it, and not loading after permission was granted. [#1058](<https://github.com/clusterio/clusterio/pull/1058>)
|
||||
- Fixed ctl config edit showing fields that cannot be edited and leaving out field descriptions. [#1051](<https://github.com/clusterio/clusterio/issues/1051>)
|
||||
- Fixed the reason an instance failed to start being logged as "undefined". [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||
- Fixed subspace inventory combinators missing items after instance startup. [#1069](<https://github.com/clusterio/clusterio/issues/1069>)
|
||||
- Fixed the controller crashing when a WebSocket client sent an invalid frame before completing the handshake. [#1063](<https://github.com/clusterio/clusterio/issues/1063>)
|
||||
- Fixed an instance reusing a stored game port that another process holds, which made it fail to start until the port was freed. [#1065](<https://github.com/clusterio/clusterio/issues/1065>)
|
||||
- Fixed credentials in request query parameters, such as OAuth codes, being written to the controller's HTTP log. [#1074](<https://github.com/clusterio/clusterio/pull/1074>)
|
||||
- Fixed plugin `ctlConfigFields` not being registered in the clusterioctl config. [#1075](<https://github.com/clusterio/clusterio/pull/1075>)
|
||||
- Fixed startup failures and plugin loading when installing Clusterio from published npm packages after the ES module migration. [#1070](<https://github.com/clusterio/clusterio/pull/1070>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Global chat now filters server-specific GPS, blueprint, train, and train-stop rich-text tags before relaying messages between instances. [#593](<https://github.com/clusterio/clusterio/issues/593>)
|
||||
- Instance console filter is now a labelled Game/Instance control instead of a single-label toggle, and Factorio's output in the log consoles is prefixed with `[server]` like every other log level. [#853](<https://github.com/clusterio/clusterio/issues/853>)
|
||||
- Inventory sync sends only the recipe notifications that differ from the destination instance's state on download. [#964](<https://github.com/clusterio/clusterio/issues/964>)
|
||||
- Log the source of responses received without a pending request, the peer of timed out connections and the number of messages resent on session resume. [#1007](<https://github.com/clusterio/clusterio/issues/1007>)
|
||||
- Plugins that are not expected to have a web build are no longer shown with an error in the plugins page. [#1004](<https://github.com/clusterio/clusterio/issues/1004>)
|
||||
- Plugins should now return a function to register handlers via hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Plugin templates from `@clusterio/create` use entrypoint functions and hooks. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Improved typing for plugin APIs and webui extension points. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Plugin hooks run concurrently instead of one plugin at a time. ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- Access tokens are also accepted as an `Authorization: Bearer` header on HTTP API endpoints. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||
- `plugin install` and `plugin add` with a package name now check that they are run from a Clusterio installation directory. [#1008](<https://github.com/clusterio/clusterio/issues/1008>)
|
||||
- Bumped pnpm to 12, the GitHub Actions used by CI to their current majors, and the CI Factorio version to 2.1.17. [#1022](<https://github.com/clusterio/clusterio/pull/1022>)
|
||||
- Bumped dependencies, including eslint 10, yargs 18, chalk 6, @types/express 5 and @types/node 24. [#1023](<https://github.com/clusterio/clusterio/pull/1023>)
|
||||
- Plugin discovery now traverses dependencies only after confirming the parent package is a Clusterio plugin. [#963](<https://github.com/clusterio/clusterio/pull/963>)
|
||||
- Converted the project to using ES Modules. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||
- Converted the bundled plugins to entrypoint functions. [#1052](<https://github.com/clusterio/clusterio/pull/1052>)
|
||||
- Added `controller.max_subscription_filters` config to limit the number of filters a connection can subscribe with for one event. [#1055](<https://github.com/clusterio/clusterio/pull/1055>)
|
||||
- Added `controller.max_send_buffer_size` config to limit how much the controller buffers for a connection that does not acknowledge messages. [#1057](<https://github.com/clusterio/clusterio/pull/1057>)
|
||||
- Added links to the ExpCluster and Chat Relay community plugins in the README. [#1062](<https://github.com/clusterio/clusterio/pull/1062>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980](<https://github.com/clusterio/clusterio/issues/980>)
|
||||
- `checkPermission` and the web UI `hasPermission` helpers take `PermissionName`; TypeScript plugins must augment the `Permissions` interface. [#987](<https://github.com/clusterio/clusterio/issues/987>)
|
||||
- Replaced `plugins` map with `loadedPlugins` set across core classes (except WebUi). ([#730](<https://github.com/clusterio/clusterio/issues/730>))
|
||||
- The `/metrics` endpoint requires a user token with the new `core.controller.metrics` permission. Concurrent scrapes share a single gather from the hosts. [#1009](<https://github.com/clusterio/clusterio/issues/1009>)
|
||||
- Config files now have to be explicitly created. Use the `--create-config` flag to get the old behaviour of creating it if missing. [#548](<https://github.com/clusterio/clusterio/issues/548>)
|
||||
- Renamed clusterioctl's config-control subcommand to config. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Renamed clusterioctl's main class from Control to Ctl. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Renamed clusterioctl's config entries from control.[name] to ctl.[name]. [#1039](<https://github.com/clusterio/clusterio/pull/1039>)
|
||||
- Plugin entrypoints must now specify the full filename including the `.js` extension. [#984](<https://github.com/clusterio/clusterio/pull/984>)
|
||||
- Renamed the inventory_sync plugin to player_sync, published as `@clusterio/plugin-player_sync`. Config fields, the inventory view permission, the controller database file and save data are migrated on load. Uninstall `@clusterio/plugin-inventory_sync` before installing the new package. [#1072](<https://github.com/clusterio/clusterio/pull/1072>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Added a `NO_EXTERNAL_TEST` flag to skip tests that use external APIs. [#976](<https://github.com/clusterio/clusterio/pull/976>)
|
||||
- Added a CI check that the controller and host stay compatible with the latest published release. [#978](<https://github.com/clusterio/clusterio/pull/978>)
|
||||
- Added a Lua lint CI job using emmylua_check. [#979](<https://github.com/clusterio/clusterio/pull/979>)
|
||||
- Added a plugin migration guide for 2.0 and documented that minor versions may contain breaking changes for the plugin API. [#1050](<https://github.com/clusterio/clusterio/pull/1050>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>), [@lntutor](<https://github.com/lntutor>), [@ColumbusLabs](<https://github.com/ColumbusLabs>), [@floze-the-genius](<https://github.com/floze-the-genius>), [@Cooldude2606](<https://github.com/Cooldude2606>), [@solarcloud7](<https://github.com/solarcloud7>), [@Hornwitser](<https://github.com/Hornwitser>), [@ILLISIS](<https://github.com/ILLISIS>), [@Danielv123](<https://github.com/Danielv123>), [@PHIDIAS0303](<https://github.com/PHIDIAS0303>)
|
||||
|
||||
## Version 2.0.0-alpha.27
|
||||
|
||||
### Features
|
||||
|
||||
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
|
||||
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
|
||||
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
|
||||
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
|
||||
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
|
||||
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
|
||||
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
|
||||
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
|
||||
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
|
||||
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
|
||||
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
|
||||
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
|
||||
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
|
||||
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
|
||||
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@bbassie](<https://github.com/bbassie>)
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.26
|
||||
|
||||
### Features
|
||||
|
||||
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
|
||||
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
|
||||
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
|
||||
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
|
||||
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
|
||||
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
|
||||
|
||||
### Meta
|
||||
|
||||
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
|
||||
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
|
||||
## Version 2.0.0-alpha.25
|
||||
|
||||
### Features
|
||||
|
||||
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
|
||||
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
|
||||
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
|
||||
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
|
||||
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
|
||||
|
||||
### Changes
|
||||
|
||||
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
|
||||
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](<https://github.com/Cooldude2606>)
|
||||
[@Daniel](<https://github.com/Daniel>)
|
||||
[@Hornwitser](<https://github.com/Hornwitser>)
|
||||
|
||||
## Version 2.0.0-alpha.24
|
||||
|
||||
### Features
|
||||
|
||||
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
|
||||
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
|
||||
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
|
||||
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
|
||||
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Changes
|
||||
|
||||
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
|
||||
|
||||
### Meta
|
||||
|
||||
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
|
||||
## Version 2.0.0-alpha.23
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
|
||||
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
|
||||
|
||||
### Features
|
||||
|
||||
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
|
||||
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
|
||||
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
|
||||
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
|
||||
|
||||
### Changes
|
||||
|
||||
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
|
||||
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
|
||||
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
|
||||
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
|
||||
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
|
||||
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
|
||||
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
|
||||
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
|
||||
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
|
||||
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
|
||||
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
|
||||
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
|
||||
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
|
||||
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
|
||||
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
|
||||
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
|
||||
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
|
||||
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
|
||||
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
|
||||
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Daniel](https://github.com/Danielv123)
|
||||
[@Double](https://github.com/DoubleThoughtTheProgrammer)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
[@Ryan Walker](https://github.com/solarcloud7)
|
||||
|
||||
## Version 2.0.0-alpha.22
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
|
||||
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
|
||||
|
||||
### Features
|
||||
|
||||
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
|
||||
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
|
||||
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
|
||||
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
|
||||
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
|
||||
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
### Changes
|
||||
|
||||
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
|
||||
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
|
||||
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
|
||||
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
|
||||
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
|
||||
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
|
||||
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
|
||||
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
|
||||
- Fixed npm update button failing to apply update and restart.
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
|
||||
|
||||
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.21
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added import, export, and restore of user lists (bans / admins / whitelist / combined). [#717](https://github.com/clusterio/clusterio/issues/717)
|
||||
- Added remote update for core packages and third party plugins. [#761](https://github.com/clusterio/clusterio/issues/761)
|
||||
- Added config cloning on instance creation. [#720](https://github.com/clusterio/clusterio/issues/720)
|
||||
|
||||
### Features
|
||||
|
||||
- Added social links and instructions to error page. [#742](https://github.com/clusterio/clusterio/issues/742)
|
||||
- Added button to download control config. [#740](https://github.com/clusterio/clusterio/issues/740)
|
||||
- Added server action filter (aka "chat filter") to instance log in web ui. [#744](https://github.com/clusterio/clusterio/issues/744)
|
||||
- Added `factorio.console_logging` to enable dedicated console log file for servers. [#748](https://github.com/clusterio/clusterio/issues/748)
|
||||
- Added a recovery mode to host and controller accessible as an option on the run command. [#752](https://github.com/clusterio/clusterio/issues/752)
|
||||
- Added username search to the users list. [#751](https://github.com/clusterio/clusterio/issues/751)
|
||||
- Added log level filtering to the web ui. [#745](https://github.com/clusterio/clusterio/issues/745)
|
||||
- Added pending update detection for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
|
||||
- Added restart required indicator for host and controller. [#758](https://github.com/clusterio/clusterio/issues/758)
|
||||
- Added version mismatch indicator in host list. [#758](https://github.com/clusterio/clusterio/issues/758)
|
||||
- Added host plugin list command to crl where previously only controller plugin list existed. [#760](https://github.com/clusterio/clusterio/issues/760)
|
||||
- Added optional feature which allows new plugins to be installed remotely.
|
||||
- Added bidirectional whitelist syncing. [#716](https://github.com/clusterio/clusterio/issues/716)
|
||||
- Added support for v2 map exchange strings. [#700](https://github.com/clusterio/clusterio/issues/700)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed handling of zip files with multiple top-level folders. [#739](https://github.com/clusterio/clusterio/issues/739)
|
||||
- Fixed looping between instances of ban and admin sync events. [#736](https://github.com/clusterio/clusterio/issues/736)
|
||||
- Fixed quality being stripped from power armour equipment when moving between servers [#753](https://github.com/clusterio/clusterio/pull/753)
|
||||
- Resolved webpack type export warnings [#750](https://github.com/clusterio/clusterio/pull/750)
|
||||
- Reduce page load time on users list by enabling pagination. [#718](https://github.com/clusterio/clusterio/issues/718)
|
||||
- Corrected stack trace and enabled source mapping during bootstrap. [#764](https://github.com/clusterio/clusterio/pull/764)
|
||||
- Fixed map gen settings using the incorrect name for cliff settings following exchange string decode.
|
||||
|
||||
### Meta
|
||||
|
||||
- Silent testing is now truly silent. [#764](https://github.com/clusterio/clusterio/pull/764)
|
||||
- Node 18 has reached end of life and is no longer maintained, we no longer guarantee support for it.
|
||||
- CI testing now runs on node 20 & 22 as well as factorio 1.1.110 & 2.0.47.
|
||||
|
||||
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.20
|
||||
|
||||
### Features
|
||||
|
||||
- Update subspace storage to 2.0 and space age. [#733](https://github.com/clusterio/clusterio/pull/733)
|
||||
- A fresh install of Clusterio will include default mod packs. [#719](https://github.com/clusterio/clusterio/issues/719)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix crash when leaving a server without a logistic requester point [#731](https://github.com/clusterio/clusterio/pull/731)
|
||||
- Use floor instead of round to prevent "1 minute and 60 seconds" being shown [#723](https://github.com/clusterio/clusterio/issues/723)
|
||||
- Fixed production statistics overwriting consumption statistics and vise versa [#734](https://github.com/clusterio/clusterio/pulls/734)
|
||||
- Hot patch to add the updated 2.0.29 control.lua freeplay hash to know scenarios. [commit](https://github.com/clusterio/clusterio/commit/26269df7d4d8f39042c59653008bc8f034a4f3b4)
|
||||
|
||||
### Meta
|
||||
|
||||
- We have now introduced a new [decisions log](https://github.com/clusterio/clusterio/blob/master/docs/decisions.md) to aid tracking all the technical and design decisions we make and their reasons. [#714](https://github.com/clusterio/clusterio/pull/714)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@psihius](https://github.com/psihius)
|
||||
|
||||
## Version 2.0.0-alpha.19
|
||||
|
||||
### Major Features
|
||||
|
||||
- Added support for 2.0 builtin mods during mod pack creation. [#684](https://github.com/clusterio/clusterio/pull/684)
|
||||
- Added basic 2.0 support to clusterio lua api and 1st party plugins. [#685](https://github.com/clusterio/clusterio/pull/685)
|
||||
|
||||
### Features
|
||||
|
||||
- Added Datastore and Datastore Provider classes to the library to support savable Maps. [#629](https://github.com/clusterio/clusterio/pull/629)
|
||||
- Added clean script to speed up publishing and debugging of build steps. [#640](https://github.com/clusterio/clusterio/pull/640)
|
||||
- Added sub-tile option on plugin page headers. [#642](https://github.com/clusterio/clusterio/pull/642)
|
||||
- Greatly improved plugin and module template generation. [#641](https://github.com/clusterio/clusterio/pull/641)
|
||||
- Duplicate host connections from the same address will kill the older host process. [#647](https://github.com/clusterio/clusterio/pull/647)
|
||||
- Controller will refuse duplicate host connections from different addresses. [#647](https://github.com/clusterio/clusterio/pull/647)
|
||||
- Monorepos are now supported for external plugins. [#654](https://github.com/clusterio/clusterio/pull/654)
|
||||
- Changed default role selection to be a dropdown menu rather than id. [#664](https://github.com/clusterio/clusterio/pull/664)
|
||||
- Added credential option to config entries to allow writing sensitive data that can't be read back remotely.
|
||||
- Allow hidden and readonly graphical representations of config values. [#665](https://github.com/clusterio/clusterio/pull/665)
|
||||
- Added locale exporting for all builtin mods, not just for the base mod. [#684](https://github.com/clusterio/clusterio/pull/684)
|
||||
- Added option for silent testing which only shows the results, can be ran using `pnpm silent-test` [#680](https://github.com/clusterio/clusterio/pull/680)
|
||||
- Added prompt for http port on installation of Clusterio. [#564](https://github.com/clusterio/clusterio/issues/564)
|
||||
- Added drag and drop overlay to upload button on mods page and save table on instance page. [#591](https://github.com/clusterio/clusterio/issues/591)
|
||||
- Automatically add plugins from ./plugins, ./external_plugins and installed npm modules. [#692](https://github.com/clusterio/clusterio/pull/692)
|
||||
- `compat.table_to_json` and `compat.json_to_table` supported in all stages in pre 2.0 [#687](https://github.com/clusterio/clusterio/issues/687)
|
||||
- Add "connect to server" button to web interface, launches with steam api. [#608](https://github.com/clusterio/clusterio/issues/608)
|
||||
- Add space platform name mapping to space platform statistics. [#706](https://github.com/clusterio/clusterio/pull/706)
|
||||
- Track space platform speed metric clusterio_statistics_exporter_platform_speed. [#706](https://github.com/clusterio/clusterio/pull/706)
|
||||
- Track space platform weight metric clusterio_statistics_exporter_platform_weight. [#706](https://github.com/clusterio/clusterio/pull/706)
|
||||
- Added bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed IPC errors reporting undefined when no stack trace was present. [#624](https://github.com/clusterio/clusterio/pull/624)
|
||||
- Fixed loopback routing for instances reporting the wrong error message. [#625](https://github.com/clusterio/clusterio/pull/625)
|
||||
- Fixed docs linking to non-existing js files after ts migration. [#633](https://github.com/clusterio/clusterio/pull/633)
|
||||
- Fixed CI workflows using deprecated version of nodejs. [#637](https://github.com/clusterio/clusterio/pull/637)
|
||||
- Fixed linux only tests to run on windows. [#661](https://github.com/clusterio/clusterio/pull/661)
|
||||
- Fixed log events being sent from a host over an invalid websocket. [#639](https://github.com/clusterio/clusterio/pull/639)
|
||||
- Fixed spaces passed in arguments to the installer causing it to break. [#620](https://github.com/clusterio/clusterio/issues/620)
|
||||
- Fixed numeric admin name breaking login. [#536](https://github.com/clusterio/clusterio/issues/536)
|
||||
- Fixed issues with subscriptions which prevented them from working on instances or generic events. [#655](https://github.com/clusterio/clusterio/pull/655).
|
||||
- Fixed unreliable datastore tests. [#662](https://github.com/clusterio/clusterio/pull/662).
|
||||
- Fixed 2.0 version extraction from linux headless. downloaded during installation of clusterio. [#671](https://github.com/clusterio/clusterio/pull/671)
|
||||
- Fixed invalid transient state during server start. [#676](https://github.com/clusterio/clusterio/issues/676)
|
||||
- Fixed host user sync to instances after reconnecting to the. controller. [#678](https://github.com/clusterio/clusterio/pull/678)
|
||||
- Fixed WebUI adding and displaying the wrong base mod version. [#684](https://github.com/clusterio/clusterio/pull/684)
|
||||
- Fixed WebUI inconsistent pattern matching for factorio version. [#684](https://github.com/clusterio/clusterio/pull/684)
|
||||
- Fixed `safeOutputFile` not being atomic because of a missing path separator. [#659](https://github.com/clusterio/clusterio/pull/659)
|
||||
- Fixed built-in mods showing as missing in the WebUI. [#581](https://github.com/clusterio/clusterio/issues/581)
|
||||
- Fixed dirty flag not being set when only the config of an instance is updated. [#675](https://github.com/clusterio/clusterio/pull/675)
|
||||
- Fixed incorrect command being used to remove players from the whitelist. [#679](https://github.com/clusterio/clusterio/pull/679)
|
||||
- Fixed CI following undocumented breaking change from downstream dependency. [#683](https://github.com/clusterio/clusterio/pull/683)
|
||||
- Fixed instance crash when missing saves folder. [#698](https://github.com/clusterio/clusterio/issues/698)
|
||||
- Fixed statistics exporter not working on newer versions of windows 11 due to wmic deprecation. [#707](https://github.com/clusterio/clusterio/issues/707)
|
||||
|
||||
### Changes
|
||||
|
||||
- Added prompt for the HTTP port to host the controller to the installer. [#619](https://github.com/clusterio/clusterio/pull/619)
|
||||
- Added tests for sendEvent and sendRequest throwing the correct error messages. [#638](https://github.com/clusterio/clusterio/pull/638)
|
||||
- Updated all web ui pages to use correct page header component. [#643](https://github.com/clusterio/clusterio/pull/643)
|
||||
- Bumped Typescript version to 5.5 and implemented `${configDir}` in base configs for "outDir". [#648](https://github.com/clusterio/clusterio/pull/648).
|
||||
- Added factorio.executable_path option which allows overriding the default path to the Factorio executable run.
|
||||
- Added instance version display. [#573](https://github.com/clusterio/clusterio/pull/573)
|
||||
- Added `factorio.shutdown_timeout` config to set the time the host will wait for a Factorio server to shut down befor killing it.
|
||||
- Changed shutdown logic to prefer sending a /quit command via RCON instead of using diverging logic on Windows and Linux.
|
||||
- Added `controller.factorio_username` and `controller.factorio_token` config to set Factorio credentials used for the whole cluster.
|
||||
- Added `controller.share_factorio_credential_with_hosts` config to optionally require hosts to provide their own credentials.
|
||||
- Added `host.factorio_username` and `host.factorio_token` config to set Factorio credentials used on a given host.
|
||||
- Changed `instance.rcon_password` config to be a credential field that can not be read by the control interface.
|
||||
- Changed `host.id` `instance.id` and `instance.assign_host` config to be hidden fields that can not be seen on the web ui.
|
||||
- Updated display name and description of `controller.external_address` to avoid confusion. [#674](https://github.com/clusterio/clusterio/pull/674)
|
||||
- Renamed config value `controller.external_address` to `controller.public_url`, migrations are applied automatically.
|
||||
- String config values with whitelisted values (enum) now display as a selection dropdown. [#701](https://github.com/clusterio/clusterio/pull/701)
|
||||
- Existing selection dropdowns now support search filtering. [#701](https://github.com/clusterio/clusterio/pull/701)
|
||||
- Added surface label to clusterio_statistics_exporter_instance_force_flow_statistics. [#691](https://github.com/clusterio/clusterio/issues/691)
|
||||
- Compat lib now supports versions from 0.17 to 2.0 [#711](https://github.com/clusterio/clusterio/pull/711)
|
||||
- Add bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Previously silent errors for controller.sendEvent now throw exceptions [#625](https://github.com/clusterio/clusterio/pull/625).
|
||||
- @clusterio/controller export InstanceInfo has added factorioVersion parameter.
|
||||
- Argument order for `Config.canAccess` changed to require an access mode passed as the second argument.
|
||||
- Instances can now have `factorio.enable_script_commands` disabled which will throw an error when any script command is used over rcon. [#681](https://github.com/clusterio/clusterio/pull/681).
|
||||
- User IDs are now case insensitive, duplicate users will be automatically merged with a backup created. [#682](https://github.com/clusterio/clusterio/pull/682)
|
||||
- Plugins now require the keyword "clusterio-plugin" in package.json to be automatically loaded [#692](https://github.com/clusterio/clusterio/pull/692)
|
||||
|
||||
Many thanks to the following for contributing to this release:
|
||||
[@CCpersonguy](https://github.com/CCpersonguy)
|
||||
[@Cooldude2606](https://github.com/Cooldude2606)
|
||||
[@Danielv123](https://github.com/Danielv123)
|
||||
[@Hornwitser](https://github.com/Hornwitser)
|
||||
[@Laar](https://github.com/Laar)
|
||||
[@psihius](https://github.com/psihius)
|
||||
|
||||
|
||||
## Version 2.0.0-alpha.18
|
||||
|
||||
### Features
|
||||
|
||||
- Added logging of HTTP requests and errors on the controller [#556](https://github.com/clusterio/clusterio/pull/556).
|
||||
- Added caching of data parsed from mods to speed up startup time [#606](https://github.com/clusterio/clusterio/pull/606).
|
||||
- Added command to installer to generate the code for making a new plugin [#570](https://github.com/clusterio/clusterio/pull/570).
|
||||
|
||||
### Changes
|
||||
|
||||
- Fixed Inventory Sync reporting a negative database size [#598](https://github.com/clusterio/clusterio/pull/598).
|
||||
- Fixed plugin events being invoked while a host connection was in an invalid state.
|
||||
- Suppresed bogus warning about event listener leak in the browser's console [#600](https://github.com/clusterio/clusterio/pull/600).
|
||||
- Fixed installer breaking on Windows after Node.js released a security fix [#614](https://github.com/clusterio/clusterio/pull/614).
|
||||
- During a data export the mod settings in mod packs will now be corrected to the type of the settings prototype if the type is incorrect.
|
||||
- Fixed missing color setting causing the Mod Pack view in the Web UI to show an error [#609](https://github.com/clusterio/clusterio/issues/609).
|
||||
|
||||
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.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
|
||||
|
||||
- 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
|
||||
@@ -8,25 +749,25 @@
|
||||
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.
|
||||
- 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, upload, download, delete and load saves for instances.
|
||||
- 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.
|
||||
- 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 master interface is accessed under, allowing it to be proxied behind web-server.
|
||||
- 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.
|
||||
@@ -37,18 +778,18 @@
|
||||
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 slave startup.
|
||||
- Added metric mapping of slave and instance ids to their names, allowing them to be displayed by name in queries that join with the mapping.
|
||||
- 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 master server.
|
||||
- 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
|
||||
|
||||
- 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().
|
||||
@@ -69,14 +810,14 @@
|
||||
- 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.
|
||||
- Controller now defaults to hosting on https on port 8443.
|
||||
- Renamed client to slave.
|
||||
- Removed rotation of factorio-current.log files.
|
||||
|
||||
@@ -86,7 +827,6 @@
|
||||
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.
|
||||
- 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.
|
||||
@@ -101,7 +841,7 @@
|
||||
- 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 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.
|
||||
@@ -110,7 +850,7 @@
|
||||
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.
|
||||
- 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.
|
||||
@@ -135,17 +875,16 @@
|
||||
- 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.
|
||||
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
|
||||
|
||||
- Removed broken remote combinator signaling.
|
||||
|
||||
+13
-18
@@ -1,4 +1,4 @@
|
||||
FROM node:12 as subspace_storage_builder
|
||||
FROM node:24 AS subspace_storage_builder
|
||||
RUN apt update && apt install -y git
|
||||
WORKDIR /
|
||||
RUN git clone https://github.com/clusterio/subspace_storage.git
|
||||
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
|
||||
RUN npm install \
|
||||
&& node build
|
||||
|
||||
FROM node:12 as clusterio_builder
|
||||
FROM node:24 AS clusterio_builder
|
||||
RUN apt update \
|
||||
&& apt install -y wget \
|
||||
&& mkdir /clusterio
|
||||
@@ -15,25 +15,22 @@ RUN wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/head
|
||||
# Copy project files into the container
|
||||
COPY . .
|
||||
|
||||
RUN npm install \
|
||||
&& npx lerna bootstrap --hoist
|
||||
RUN pnpm install
|
||||
|
||||
# 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 npm install @clusterio/plugin-subspace_storage
|
||||
#RUN npx clusteriomaster plugin add @clusterio/plugin-subspace_storage
|
||||
#RUN pnpm install @clusterio/plugin-subspace_storage
|
||||
#RUN npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
|
||||
|
||||
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/sharedMods/
|
||||
COPY --from=subspace_storage_builder /subspace_storage/dist/ /clusterio/mods/
|
||||
|
||||
# Build submodules (web UI, libraries, plugins etc)
|
||||
RUN npx lerna run build
|
||||
# Build Lua library mod
|
||||
RUN node packages/lib/build_mod --build --source-dir packages/slave/lua/clusterio_lib \
|
||||
&& mv dist/* sharedMods/ \
|
||||
RUN node packages/lib/build_mod --build --source-dir packages/host/lua/clusterio_lib \
|
||||
&& mv dist/* mods/ \
|
||||
&& mkdir temp \
|
||||
&& mkdir temp/test \
|
||||
&& cp sharedMods/ temp/test/ -r \
|
||||
&& ls sharedMods
|
||||
&& cp mods/ temp/test/ -r \
|
||||
&& ls mods
|
||||
|
||||
# Remove node_modules
|
||||
RUN find . -name 'node_modules' -type d -prune -print -exec rm -rf '{}' \;
|
||||
@@ -46,8 +43,7 @@ COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN npm install --production
|
||||
RUN npx lerna bootstrap -- --production --no-optional
|
||||
RUN pnpm install --production
|
||||
LABEL maintainer "danielv@danielv.no"
|
||||
|
||||
FROM frolvlad/alpine-glibc AS clusterio_testing
|
||||
@@ -58,6 +54,5 @@ COPY --from=clusterio_builder /clusterio /clusterio
|
||||
WORKDIR /clusterio
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN npm install
|
||||
RUN npx lerna bootstrap
|
||||
RUN npm install chalk semver
|
||||
RUN pnpm install
|
||||
RUN pnpm install chalk semver
|
||||
@@ -10,9 +10,10 @@ Discord for development/support/play: https://discord.gg/5XuDkje
|
||||
|
||||
## Important notice
|
||||
|
||||
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.
|
||||
Clusterio 2.0 is the current stable release and requires Node.js 22 or newer.
|
||||
The previous stable series is no longer supported.
|
||||
If you are upgrading from a 2.0 alpha, review the [changelog](CHANGELOG.md) before upgrading.
|
||||
Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-migration-to-2.0.md).
|
||||
|
||||
|
||||
### Ways to support me/the project:
|
||||
@@ -33,12 +34,13 @@ If you are starting a new cluster it's highly recommended to use the 2.0 alpha.
|
||||
* [Windows setup](#windows-setup)
|
||||
* [MacOS setup](#macos-setup)
|
||||
* [Installing Plugins](#installing-plugins)
|
||||
* [Configure Master Server](#configure-master-server)
|
||||
* [Configure Controller](#configure-controller)
|
||||
* [Managing Factorio mods](#managing-factorio-mods)
|
||||
* [Setting up shared storage](#setting-up-shared-storage)
|
||||
* [Running Clusterio](#running-clusterio)
|
||||
* [Setting up remote slaves](#setting-up-remote-slaves)
|
||||
* [Setting up remote hosts](#setting-up-remote-hosts)
|
||||
* [Setting up clusterioctl](#setting-up-clusterioctl)
|
||||
* [Known issues](#known-issues)
|
||||
* [Common problems](#Common-problems)
|
||||
|
||||
|
||||
@@ -49,7 +51,7 @@ It was previously best known for implementing cross server transfer and cloud st
|
||||
But this functionality has been pulled out of Clusterio into its own plugin for Clusterio named [Subspace Storage](https://github.com/clusterio/subspace_storage).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
## Features
|
||||
@@ -71,41 +73,46 @@ These are the plugins supported and maintained by the Clusterio developers:
|
||||
|
||||
- [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 master server.
|
||||
- [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.
|
||||
- [Player Sync](/plugins/player_sync/README.md): Synchronizes your inventory and player settings between servers.
|
||||
|
||||
There's also plugins developed and maintained by the community:
|
||||
|
||||
- [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 server in the cluster.
|
||||
- [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.
|
||||
- [ExpCluster](https://github.com/explosivegaming/ExpCluster) (@expcluster/*): A collection of plugins and scenario features including role and group syncing, moderation tools, quality of life additions, and discord account linking.
|
||||
See repo readme for plugin breakdown. Maintained by Cooldude2606.
|
||||
- [Chat Relay](https://github.com/PHIDIAS0303/chat_relay) (@PHIDIAS0303/chat_relay): Chat relay of Discord and Factorio, seperate channel.
|
||||
|
||||
Want to make your own plugin?
|
||||
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
|
||||
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Clusterio runs on Node.js v12 and up, it's distributed via npm and comes with a guided install script.
|
||||
If you alread have a recent Node.js installed you can set it up in a new directory with.
|
||||
Clusterio runs on all LTS version of NodeJS, it is 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:
|
||||
|
||||
npm init "@clusterio"
|
||||
|
||||
Otherwise see below for OS specific instructions.
|
||||
|
||||
Otherwise see below for OS specific instructions.
|
||||
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
|
||||
|
||||
### Ubuntu setup
|
||||
|
||||
1. Install Node.js v12 or higher.
|
||||
For 20.04 LTS or below the version of Node.js provided by the Ubuntu repos are too old and you will have to use the nodesource PPA, otherwise you may skip the first line.
|
||||
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
|
||||
|
||||
wget -qO - https://deb.nodesource.com/setup_14.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
2. Create a new directory and run the Clusterio installer:
|
||||
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
|
||||
@@ -113,19 +120,12 @@ Otherwise see below for OS specific instructions.
|
||||
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
3. If you chose to use local factorio directory for the Factorio installation then download the headless build of Factorio and unpack it:
|
||||
|
||||
wget -O factorio.tar.xz https://www.factorio.com/get-download/latest/headless/linux64
|
||||
tar -xf factorio.tar.xz
|
||||
|
||||
To specify a version of Factorio to download replace "latest" in the URL with a version number like "1.0.0".
|
||||
|
||||
4. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
|
||||
3. Optionally copy the generated systemd service files in `systemd` folder to `/etc/systemd/system/`.
|
||||
|
||||
|
||||
**Ubuntu with Docker**
|
||||
|
||||
The Docker support for Clusterio is curently broken.
|
||||
The Docker support for Clusterio is currently broken.
|
||||
If you need it open an issue about it.
|
||||
|
||||
<!--
|
||||
@@ -133,9 +133,9 @@ Clusterio has *very* limited support for using docker.
|
||||
|
||||
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:/clusterio/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.
|
||||
@@ -152,7 +152,7 @@ Your instances (save files etc) will be stored there.
|
||||
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
3. If you chose to use local factorio directory for the Factorio installation then 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.
|
||||
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
|
||||
@@ -167,109 +167,128 @@ Your instances (save files etc) will be stored there.
|
||||
|
||||
Make sure to note down the admin authentication token it provides at the end as you will need it later.
|
||||
|
||||
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 to the `factorio` folder in your clusterio installation folder.
|
||||
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
|
||||
|
||||
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
|
||||
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
|
||||
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
|
||||
|
||||
npm install @clusterio/plugin-subspace_storage
|
||||
npx clusteriocontroller plugin 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
|
||||
|
||||
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, instead use the clusterio command you have available.
|
||||
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
|
||||
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
|
||||
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).
|
||||
|
||||
|
||||
## Configure Master Server
|
||||
## Configure Controller
|
||||
|
||||
By default the master server will listen for HTTP on port 8080.
|
||||
By default the controller will listen for HTTP on port 8080.
|
||||
You can change the port used with the command
|
||||
|
||||
npx clusteriomaster config set master.http_port 1234
|
||||
npx clusteriocontroller config set controller.http_port 1234
|
||||
|
||||
When changing the port you will also need to change the address slaves connect with.
|
||||
When changing the port you will also need to change the address hosts connect with.
|
||||
For the standalone installation mode you can use
|
||||
|
||||
npx clusterioslave config set slave.master_url http://localhost:1234/
|
||||
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 clusteriomaster config set master.external_address http://203.0.113.4:1234/
|
||||
npx clusteriocontroller config set controller.public_url http://203.0.113.4:1234/
|
||||
|
||||
Change the url to reflect the IP, protocol, and port the master server is accessible under, dns names are also supported.
|
||||
If you're planning on making the master server 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.
|
||||
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 master server with the `npx clusteriomaster config list` command.
|
||||
See the [readme for @clusterio/master](/packages/master/README.md) for more information.
|
||||
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 slave installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that slave 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.
|
||||
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
|
||||
|
||||
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.
|
||||
Mod settings can be managed under the modpack configuration page in the web UI. For mod settings to show, you first need to run an export. This is done by creating an instance with the modpack assigned, opening the instance page and pressing More -> Export data.
|
||||
|
||||

|
||||
|
||||
Once this is done, the mod settings will show up under the modpack configuration page.
|
||||
|
||||

|
||||
|
||||
|
||||
## Setting up shared storage
|
||||
|
||||
The chests teleporting items to and from the shared storage on the master server 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 slave installation.
|
||||
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 completing the installation start up the master server and at least one slave separately.
|
||||
The installer provides the `run-master` and `run-slave` scripts to make this simple.
|
||||
Once the master 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.
|
||||
After completing the installation and creating your admin user, 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 slave and then click start.
|
||||
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. You can also use the "Connect via Steam" button to launch Factorio directly and connect to the server using Steam's protocol handler.
|
||||
|
||||
### Running via Systemd
|
||||
|
||||
The install script creates systemd service scripts for clusteriomaster and clusterioslave (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:
|
||||
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 clusteriomaster # for the master server
|
||||
sudo systemctl start clusterioslave # for each server hosting a slave
|
||||
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 clusteriomaster # for the master server
|
||||
sudo systemctl enable clusterioslave # for each server hosting a slave
|
||||
sudo systemctl enable clusteriocontroller # for the controller
|
||||
sudo systemctl enable clusteriohost # for each physical server intended to host Factorio servers
|
||||
|
||||
|
||||
## Setting up remote slaves
|
||||
## Setting up remote hosts
|
||||
|
||||
Run the installer as described in the installation section and choose "Slave only" as the operating mode to install, it'll ask for a master URL and an authentication token.
|
||||
The URL is the same as what is needed to connect to the web interface, and the athentication token can be generated on the Slaves page in the web interface.
|
||||
Once you start up the slave it should show up in the Slaves list and be available for assigning and running instances on.
|
||||
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.
|
||||
|
||||
|
||||
## Setting up clusterioctl
|
||||
|
||||
There's a command line interface available for Clusterio which is installed separately with the same installer as for the master and slave.
|
||||
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 master 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
|
||||
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 bootstrap generate-user-token <username>
|
||||
npx clusteriocontroller bootstrap generate-user-token <username>
|
||||
|
||||
|
||||
## Known Issues
|
||||
|
||||
- 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
|
||||
@@ -283,7 +302,7 @@ According to [this link](https://askubuntu.com/questions/839520/open-port-443-fo
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Clean the repository of build and execution artifacts.
|
||||
*
|
||||
* clean: A quick clean that removes all build artifacts making the output safe to publish
|
||||
* clean-tests: Removes all build and test artifacts should not be required if tests are setup correctly.
|
||||
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import yargs from "yargs";
|
||||
|
||||
import npmPackage from "./package.json" with { type: "json" };
|
||||
const workspaces = npmPackage.workspaces
|
||||
.map(p => p.slice(0, -2))
|
||||
.filter(p => !p.includes("external_plugins"));
|
||||
|
||||
let DRY = false;
|
||||
|
||||
/**
|
||||
* Attempt to remove a file / directory if it exists.
|
||||
*
|
||||
* @param {string} filePath The path to remove.
|
||||
*/
|
||||
async function tryRemove(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
console.log(filePath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DRY) {
|
||||
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all build artifacts
|
||||
*/
|
||||
async function removeBuildArtifacts() {
|
||||
const tasks = [];
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const basePath = path.resolve(workspace);
|
||||
const packages = await fs.readdir(basePath);
|
||||
for (const packageName of packages) {
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all test artifacts
|
||||
*/
|
||||
async function removeTestArtifacts() {
|
||||
const artifacts = [
|
||||
".nyc_output", "coverage", "temp", "static",
|
||||
];
|
||||
|
||||
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all execution artifacts
|
||||
*/
|
||||
async function removeExecutionArtifacts() {
|
||||
const artifacts = [
|
||||
"database", "instances", "logs", "mods",
|
||||
"config-ctl.json", "config-controller.json", "config-host.json",
|
||||
"FactorioAdminToken.txt", "plugin-list.json",
|
||||
];
|
||||
|
||||
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function for this script
|
||||
*/
|
||||
async function main() {
|
||||
await yargs(process.argv.slice(2))
|
||||
.scriptName("clean")
|
||||
.option("dry-run", {
|
||||
alias: "dry",
|
||||
nargs: 0,
|
||||
describe: "Will not delete any files",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.command("all", "Remove all artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
removeTestArtifacts();
|
||||
removeExecutionArtifacts();
|
||||
})
|
||||
.command("tests", "Remove all build and test artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
removeTestArtifacts();
|
||||
})
|
||||
.command("$0", "Remove all build artifacts", () => {}, argv => {
|
||||
DRY = argv.dry;
|
||||
removeBuildArtifacts();
|
||||
})
|
||||
.demandCommand(1, "A command must be specified")
|
||||
.strict()
|
||||
.parse();
|
||||
}
|
||||
|
||||
// Run main if started from command line
|
||||
if (import.meta.main) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
if_ci_failed: success
|
||||
informational: true
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
master:
|
||||
controller:
|
||||
build: .
|
||||
command: npx clusteriomaster run
|
||||
slave:
|
||||
command: npx clusteriocontroller run
|
||||
host:
|
||||
build: .
|
||||
command: npx clusterioslave run
|
||||
command: npx clusteriohost run
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 KiB |
@@ -11,8 +11,8 @@ Links represents one side of a two way communication pipe, connectors deal with
|
||||
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.
|
||||
@@ -30,7 +30,7 @@ There's also the virtual connector which lets two links in the same program be c
|
||||
## 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.
|
||||
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) 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.
|
||||
+61
-24
@@ -2,36 +2,34 @@
|
||||
|
||||
<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, host and instance 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/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` 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
|
||||
|
||||
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 `"master"`, `"slave"` and `"control"`.
|
||||
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.
|
||||
Takes the value of `defaultAccess` set on the config group class if not passed.
|
||||
Note that instance config groups should have both `"master"` and `"slave"` for all of its fields or unexpected behaviour may occur.
|
||||
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.
|
||||
|
||||
**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.
|
||||
@@ -44,35 +42,74 @@ The `define` method takes an object as argument with the following properties:
|
||||
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.
|
||||
|
||||
**validator** (optional):
|
||||
Function called with the value being set and the config it belongs to.
|
||||
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
|
||||
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
|
||||
The config is read only while the validator runs.
|
||||
|
||||
**dependsOn** (optional):
|
||||
Names of other fields the validator reads the value of.
|
||||
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
|
||||
|
||||
**restartRequired** (optional):
|
||||
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
|
||||
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
|
||||
Defaults to false.
|
||||
|
||||
**restartRequiredProps** (optional):
|
||||
For object fields, names of properties that have the opposite of `restartRequired`.
|
||||
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
|
||||
|
||||
**credential** (optional):
|
||||
Locations allowed to read the value of this field.
|
||||
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
|
||||
|
||||
**readonly** (optional):
|
||||
Locations allowed to change the value of this field.
|
||||
Other locations can still read the value if allowed by `access`.
|
||||
|
||||
**hidden** (optional):
|
||||
True if the web interface should not show this field.
|
||||
Used for internal values that are managed by Clusterio.
|
||||
|
||||
**autoComplete** (optional):
|
||||
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
|
||||
Only used for string and number fields.
|
||||
|
||||
**inputComponent** (optional):
|
||||
Name of the input component the web interface should render for this field instead of the default for its type.
|
||||
Plugins can add their own components through `inputComponents` on their web plugin class.
|
||||
|
||||
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
|
||||
|
||||
On the Config instance there's two ways to access fields: through the set and get methods using dot notation.
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
+458
-111
@@ -1,9 +1,19 @@
|
||||
# Configuration
|
||||
|
||||
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
|
||||
|
||||
## Master Configuration
|
||||
All values are checked against the type of the field when set.
|
||||
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
|
||||
Setting a value that fails a check is rejected with an error and the old value is kept.
|
||||
|
||||
### master.name
|
||||
A few fields are internal to Clusterio.
|
||||
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
|
||||
|
||||
|
||||
## 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.
|
||||
@@ -11,37 +21,57 @@ Used to distinguish it from other clusters and is prepended to the name of serve
|
||||
Defaluts to "Your Cluster".
|
||||
|
||||
|
||||
### master.database_directory
|
||||
### controller.database_directory
|
||||
|
||||
Directory used to store cluster data to.
|
||||
Needs to be writeable by the master server.
|
||||
Needs to be writeable by the controller.
|
||||
This is also used by plugins to store its data.
|
||||
|
||||
Defaults to "database".
|
||||
|
||||
|
||||
### master.http_port
|
||||
### controller.mods_directory
|
||||
|
||||
Directory where mods shared with the cluster are stored.
|
||||
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
|
||||
Needs to be writeable by the controller.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### controller.autosave_interval
|
||||
|
||||
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
|
||||
The data is also saved on shutdown.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### 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 master.https_port needs to be set to a port.
|
||||
At least one of this and controller.https_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
Defaults to 8080.
|
||||
|
||||
|
||||
### master.https_port
|
||||
### controller.https_port
|
||||
|
||||
Port to host HTTPS server on.
|
||||
Uses master.tls_certificate and master.tls_private_key as the certificate and private key for the HTTPS server.
|
||||
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 master.http_port needs to be set to a port.
|
||||
At least one of this and controller.http_port needs to be set to a port.
|
||||
Must be a whole number greater than 0.
|
||||
|
||||
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### master.bind_address
|
||||
### controller.bind_address
|
||||
|
||||
IP address to bind the HTTP and HTTPS ports on.
|
||||
Useful to limit which interface to accept connections from.
|
||||
@@ -51,73 +81,174 @@ interfaces.
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### master.external_address
|
||||
### controller.trusted_proxies
|
||||
|
||||
External address the master server is accessible on.
|
||||
Currently only used for `clusteriomaster bootstrap create-ctl-config` in order to give the right url to connect to.
|
||||
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
|
||||
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
|
||||
Entries that are not valid IP addresses are logged as errors and ignored.
|
||||
|
||||
Defaults to null meaning the header is never trusted.
|
||||
|
||||
|
||||
### controller.public_url
|
||||
|
||||
Public address the controller is accessible on.
|
||||
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
|
||||
This should be a full URL ending with a /.
|
||||
|
||||
Defaults to null meaning assume localhost.
|
||||
|
||||
|
||||
### master.tls_certificate
|
||||
### controller.static_url
|
||||
|
||||
Path to TLS certificate to use for the HTTPS server when master.https_port is configured.
|
||||
Used to override where static assets such as the web bundle is loaded from.
|
||||
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
|
||||
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
|
||||
Defaults to "database/certificates/cert.crt".
|
||||
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
|
||||
|
||||
Defaults to null meaning use the internally hosted assets.
|
||||
|
||||
|
||||
### master.tls_private_key
|
||||
### controller.grafana_url
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when master.https_port is configured.
|
||||
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
|
||||
If null no link is shown.
|
||||
|
||||
Defaults to "database/certificates/cert.key".
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### master.auth_secret
|
||||
### controller.tls_certificate
|
||||
|
||||
Base64 encoded authentication secret used to verify tokens issued to slaves, users, and sessions.
|
||||
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
|
||||
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.tls_private_key
|
||||
|
||||
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### 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.
|
||||
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
|
||||
|
||||
Defaults to null which means generate a secure secret on startup.
|
||||
|
||||
|
||||
### master.heartbeat_interval
|
||||
### controller.heartbeat_interval
|
||||
|
||||
Interval in seconds heartbeats are sent out at for WebSocket connections.
|
||||
If a connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
|
||||
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 45.
|
||||
|
||||
|
||||
### master.connector_shutdown_timeout
|
||||
|
||||
Timeout in seconds to use while shutting down the master server for link sessions that currently don't have an active connection and haven't been closed down properly.
|
||||
This happens when the network between a slave and the master server goes down unexpectedly, or the slave goes down without closing the connection properly either do to crashing or the machine it's hosted on going down.
|
||||
If the timeout expires the session is terminated and data loss may occur, if the slave went down this is unavoidable.
|
||||
|
||||
Defaults to 30.
|
||||
|
||||
|
||||
### master.metrics_timeout
|
||||
|
||||
Timeout in seconds before a call to gather metrics from a slave times out.
|
||||
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### master.stream_timeout
|
||||
|
||||
Timeout in seconds for proxy streams through the master 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.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### master.default_role_id
|
||||
### 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`.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### controller.max_send_buffer_size
|
||||
|
||||
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
|
||||
The controller keeps these so they can be resent if the connection drops and resumes.
|
||||
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
|
||||
Changes apply to new sessions.
|
||||
Set to 0 for no limit.
|
||||
|
||||
Defaults to 64.
|
||||
|
||||
|
||||
### 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.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 8.
|
||||
|
||||
|
||||
### controller.max_subscription_filters
|
||||
|
||||
Maximum number of filters one connection can hold for a single subscribed event.
|
||||
Subscription requests that would go over this limit are rejected.
|
||||
Set to 0 for no limit.
|
||||
Lowering the limit does not remove filters connections already hold.
|
||||
|
||||
Defaults to 10000.
|
||||
|
||||
|
||||
### controller.system_metrics_interval
|
||||
|
||||
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
|
||||
Whether the controller needs a restart to apply an update is also checked on this interval.
|
||||
If 0 system metrics are not gathered.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 10.
|
||||
|
||||
|
||||
### controller.proxy_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.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 15.
|
||||
|
||||
|
||||
### controller.factorio_username
|
||||
|
||||
Username of the Factorio.com account to authenticate with the Factorio API with.
|
||||
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.factorio_token
|
||||
|
||||
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
|
||||
This is the token shown on your profile page on factorio.com, not your password.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.share_factorio_credentials_with_hosts
|
||||
|
||||
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
|
||||
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
|
||||
Disable this if you don't want the controller's credentials to be sent to the hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### controller.default_mod_pack_id
|
||||
|
||||
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
|
||||
Instances without a mod pack of their own fail to start if this is null.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### controller.default_role_id
|
||||
|
||||
Role to automatically grant to new users.
|
||||
If null no role is granted.
|
||||
@@ -125,32 +256,82 @@ If null no role is granted.
|
||||
Defaults to 1 which correspond to the default Player role.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
### controller.mod_portal_cache_duration_minutes
|
||||
|
||||
Whether to load the plugin on the master server.
|
||||
Plugins not loaded on the master server will not be loaded on instances.
|
||||
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
|
||||
Set to 0 to disable the cache and fetch the list on every request.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 30.
|
||||
|
||||
|
||||
### controller.mod_portal_page_size
|
||||
|
||||
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 1000.
|
||||
|
||||
|
||||
### controller.allow_remote_updates
|
||||
|
||||
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Slave Configuration
|
||||
### controller.allow_plugin_updates
|
||||
|
||||
### slave.name
|
||||
Whether remote requests to update plugins installed on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Name of the slave.
|
||||
Shows up in slave lists and is used to reference this slave in the clusterioctl command line interface.
|
||||
|
||||
Defaults to "New Slave".
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### slave.id
|
||||
### controller.allow_plugin_install
|
||||
|
||||
Immutable numeric id of the slave which uniquely identifies this slave in the cluster.
|
||||
Whether remote requests to install new plugins on the controller with npm are allowed.
|
||||
Like controller.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### controller.version
|
||||
|
||||
Version of the controller package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the controller has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <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.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaults to a random 31 bit number.
|
||||
|
||||
|
||||
### slave.factorio_directory
|
||||
### 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.
|
||||
@@ -158,51 +339,120 @@ This can either point to a Factorio server directory, or to a directory containi
|
||||
Defaults to "factorio".
|
||||
|
||||
|
||||
### slave.instances_directory
|
||||
### host.mods_directory
|
||||
|
||||
Directory the host stores mods downloaded from the controller in.
|
||||
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
|
||||
|
||||
Defaults to "mods".
|
||||
|
||||
|
||||
### host.instances_directory
|
||||
|
||||
Directory to store instances in.
|
||||
One sub-directory will be created in it for each instance assigned to this slave and these sub-directories will contain logs, saves, and various auto generated configuration files for the instances.
|
||||
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".
|
||||
|
||||
|
||||
### slave.master_url
|
||||
### host.controller_url
|
||||
|
||||
URL to connect to the master server to.
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to "http://localhost:8080/".
|
||||
|
||||
|
||||
### slave.master_token
|
||||
### host.controller_token
|
||||
|
||||
Access token used for authenticating with the master server.
|
||||
You can generate an access token with `clusterioctl slave generate-token --id <slave-id>`, or use the `clusterioctl slave create-config` to create a new slave config with the correct url and token in it.
|
||||
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.
|
||||
The value can only be read and changed from the host itself.
|
||||
|
||||
Defaults to "enter token here".
|
||||
|
||||
|
||||
### slave.tls_ca
|
||||
### host.public_address
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the master server.
|
||||
If you have a self signed certificate on the master server you will need to copy the certificate to your slaves and set it as the certificate authority with this option.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### slave.public_address
|
||||
|
||||
External address instances hosted on this slave can be accessed on.
|
||||
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".
|
||||
|
||||
|
||||
### slave.reconnect_delay
|
||||
### host.factorio_port_range
|
||||
|
||||
Delay in seconds to wait after connection to the master server 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 slaves trying to reconnect at the same time.
|
||||
UDP ports the host hands out to instances that don't have factorio.game_port set.
|
||||
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
|
||||
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
|
||||
Starting an instance fails if all ports in the range are taken.
|
||||
|
||||
Defaults to 5.
|
||||
Defaults to "34100-34199".
|
||||
|
||||
|
||||
### host.factorio_username
|
||||
|
||||
Username of the Factorio.com account to put in the server settings of instances on this host.
|
||||
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### host.factorio_token
|
||||
|
||||
Token of the Factorio.com account to go with host.factorio_username.
|
||||
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### 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.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 60.
|
||||
|
||||
|
||||
### host.allow_remote_updates
|
||||
|
||||
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
|
||||
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
|
||||
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_updates
|
||||
|
||||
Whether remote requests to update plugins installed on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### host.allow_plugin_install
|
||||
|
||||
Whether remote requests to install new plugins on the host with npm are allowed.
|
||||
Like host.allow_remote_updates this can only be changed locally.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### host.version
|
||||
|
||||
Version of the host package that last loaded this config.
|
||||
Set automatically on startup and used to detect when the host has been updated or downgraded.
|
||||
Internal, hidden in the web interface and should not be changed.
|
||||
|
||||
|
||||
### <plugin_name>.load_plugin
|
||||
|
||||
Whether to load the plugin on the host.
|
||||
Only plugins with a host or instance part have this field in the host config.
|
||||
Plugins not loaded on the host will not be loaded on the instances it hosts.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Instance Configuration
|
||||
@@ -217,23 +467,33 @@ Defaults to "New Instance".
|
||||
### instance.id
|
||||
|
||||
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
|
||||
Hidden in the web interface.
|
||||
|
||||
Defaluts to a random 31 bit integer.
|
||||
|
||||
|
||||
### instance.assigned_slave
|
||||
### instance.assigned_host
|
||||
|
||||
Slave this instance is assigned to.
|
||||
Host this instance is assigned to.
|
||||
To change this you need to use the separate assign instance interface.
|
||||
After being assigned to a slave the directory for the instance is created on the slave.
|
||||
After being assigned to a host the directory for the instance is created on the host.
|
||||
Internal and hidden in the web interface.
|
||||
|
||||
Defaults to null meaning not assigned.
|
||||
|
||||
|
||||
### instance.auto_start
|
||||
|
||||
If enabled start this instance when the slave it is assigned to is started up.
|
||||
Does not affect start or stop of the instance while the slave is running.
|
||||
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.
|
||||
|
||||
|
||||
### instance.exclude_from_start_all
|
||||
|
||||
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
|
||||
This allows you to prevent certain instances from being started when using the bulk start operation.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
@@ -241,23 +501,52 @@ 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 slave.
|
||||
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
|
||||
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
|
||||
Values that don't match one of these forms are rejected.
|
||||
|
||||
Defaults to "latest".
|
||||
|
||||
|
||||
### factorio.executable_path
|
||||
|
||||
Path relative to the Factorio installation directory of the executable to run.
|
||||
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
|
||||
Only needed in special setups.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.shutdown_timeout
|
||||
|
||||
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
|
||||
Set to 0 to wait forever.
|
||||
Changing this while the instance is stopping restarts the timer with the new value.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to 300.
|
||||
|
||||
|
||||
### 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.
|
||||
If null a port from host.factorio_port_range on the host is assigned to the instance.
|
||||
Negative values are rejected.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.host_assigned_game_port
|
||||
|
||||
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
|
||||
Internal, set by the host and only accessible from the host.
|
||||
|
||||
|
||||
### 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.
|
||||
Negative values are rejected.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
@@ -266,6 +555,30 @@ Defaults to null.
|
||||
|
||||
Password to use for the RCON interafec.
|
||||
If null a random secure password is generated and used each time the instance starts.
|
||||
The value is treated as a credential and can only be read back by the controller and the host.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
|
||||
### factorio.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.
|
||||
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
|
||||
The same check is done when factorio.settings is changed.
|
||||
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.mod_pack_id
|
||||
|
||||
ID of the mod pack to use on this instance.
|
||||
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
|
||||
If null the mod pack set in controller.default_mod_pack_id is used.
|
||||
|
||||
Defaults to null.
|
||||
|
||||
@@ -276,6 +589,19 @@ 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.
|
||||
Plugins that need save patching can't be loaded on the instance while this is disabled.
|
||||
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
### factorio.enable_script_commands
|
||||
|
||||
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
|
||||
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
|
||||
When disabled attempts to send such a command fail with an error.
|
||||
This does not stop players from using these commands themselves.
|
||||
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
@@ -287,6 +613,13 @@ Turn on the whitelist on the server.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.enable_authserver_bans
|
||||
|
||||
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
|
||||
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.settings
|
||||
|
||||
Object with the settings to put into `server-settings.json` for the Factorio server.
|
||||
@@ -294,7 +627,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
|
||||
|
||||
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}.
|
||||
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
|
||||
|
||||
|
||||
### factorio.verbose_logging
|
||||
@@ -305,6 +638,14 @@ This prints more messages to the log.
|
||||
Defaults to false.
|
||||
|
||||
|
||||
### factorio.console_logging
|
||||
|
||||
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
|
||||
Useful for 3rd party tools that read the console log of the server.
|
||||
|
||||
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.
|
||||
@@ -327,25 +668,35 @@ Defaults to true.
|
||||
### factorio.sync_adminlist
|
||||
|
||||
Synchronize in-game admin list with admin status of the users in cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
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.
|
||||
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.sync_whitelist
|
||||
|
||||
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
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.
|
||||
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
|
||||
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.sync_banlist
|
||||
|
||||
Synchronize in-game banlist with the banned status of users in the cluster.
|
||||
One of "disabled", "enabled" or "bidirectional".
|
||||
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.
|
||||
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
|
||||
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
|
||||
|
||||
Defaults to true.
|
||||
Defaults to "bidirectional".
|
||||
|
||||
|
||||
### factorio.max_concurrent_commands
|
||||
@@ -362,6 +713,7 @@ This means a high number of concurrent commands can amplify resends due to packe
|
||||
|
||||
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.
|
||||
Must be greater than 0.
|
||||
|
||||
Defaults to 5.
|
||||
|
||||
@@ -369,38 +721,33 @@ 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 master server in order for them to be loaded on instances.
|
||||
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
|
||||
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
|
||||
|
||||
Defaults to true.
|
||||
|
||||
|
||||
## Control Configuration
|
||||
## Ctl Configuration
|
||||
|
||||
### control.master_url
|
||||
### ctl.controller_url
|
||||
|
||||
URL to connect to the master server to.
|
||||
URL to connect to the controller to.
|
||||
|
||||
Defaults to null meaning complain about it not being set and exit.
|
||||
|
||||
|
||||
### control.master_token
|
||||
### ctl.controller_token
|
||||
|
||||
Access token used for authenticating with the master server.
|
||||
You can generate an access token with `clusteriomaster bootstrap generate-user-token <username>`, or use the `clusteriomaster bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
|
||||
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
|
||||
### ctl.max_reconnect_delay
|
||||
|
||||
Path to certificate in PEM format to use for validating a TLS connection to the master server.
|
||||
If you have a self signed certificate on the master server you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
|
||||
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.
|
||||
Must be 0 or greater.
|
||||
|
||||
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
|
||||
|
||||
|
||||
### control.reconnect_delay
|
||||
|
||||
Duration in seconds to wait before attempting to reconnect with the master server after the connection is dropped.
|
||||
|
||||
Defaults to 2.
|
||||
Defaults to 60.
|
||||
+55
-58
@@ -3,6 +3,8 @@
|
||||
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.
|
||||
We have included all the pratical considerations in this document.
|
||||
Although, we recomend checking out out our [past decisions](/docs/decisions.md) if you have the time.
|
||||
|
||||
|
||||
## Contents
|
||||
@@ -15,14 +17,15 @@ Though we have some conventions and workflows that we kindly ask you to follow.
|
||||
- [Editing your Feature Branch](#editing-your-feature-branch)
|
||||
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
||||
- [Updating Your GitHub Fork](#updating-your-github-fork)
|
||||
- [Publishing releases to npm](#publishing-releases-to-npm)
|
||||
- [Changelog](#changelog)
|
||||
- [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
|
||||
@@ -49,12 +52,10 @@ 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 --hoist
|
||||
npx lerna run build
|
||||
pnpm install
|
||||
|
||||
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:
|
||||
@@ -70,24 +71,30 @@ The `plugins/` directory contains the core plugins for the project and can be ad
|
||||
|
||||
The documentation uses the binaries provided by the packages, but for the development environment you will need to call the packages directly:
|
||||
|
||||
npx clusteriomaster -> node packages/master
|
||||
npx clusterioslave -> node packages/slave
|
||||
npx clusteriocontroller -> node packages/controller
|
||||
npx clusteriohost -> node packages/host
|
||||
npx clusterioctl -> node packages/ctl
|
||||
|
||||
Note that to use `clusterioctl` you will have to create a control config first:
|
||||
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`.
|
||||
|
||||
node packages/master bootstrap create-ctl-config [admin-user]
|
||||
Note that to use `clusterioctl` you will have to create a ctl config first:
|
||||
|
||||
Once you've set up the cluster you can use `node packages/master run` to run the master server and `node packages/slave run` to run the slave which connects to the master server.
|
||||
node packages/controller bootstrap create-ctl-config [admin-user]
|
||||
|
||||
For web development on the master there are also the following flags.
|
||||
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.
|
||||
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `npx lerna run build` before you start the master without these flags again.
|
||||
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
|
||||
@@ -113,11 +120,16 @@ But it's nice to have its name be somewhat descriptive of what it implements.
|
||||
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.
|
||||
|
||||
The full test suite can take a while to run.
|
||||
Use `pnpm run fast-test` to skip tests marked as slow, or `pnpm run no-external-test` to skip tests that depend on external APIs such as the Factorio mod portal or GitHub.
|
||||
These correspond to setting the `FAST_TEST` and `NO_EXTERNAL_TEST` environment variables and can be combined.
|
||||
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
|
||||
|
||||
|
||||
### Submitting a Pull Request
|
||||
|
||||
@@ -130,6 +142,8 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
|
||||
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.
|
||||
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
@@ -173,48 +187,65 @@ Fetch the main repository directly as described above and base your feature bran
|
||||
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.
|
||||
|
||||
|
||||
### Publishing releases to npm
|
||||
|
||||
For publishing new releases see [package release workflow](./release-workflow.md#packages).
|
||||
|
||||
## 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.
|
||||
The format of this log should be self explanatory, please follow it carefully. Please make sure to document any [breaking changes](/docs/decisions.md#breaking-changes).
|
||||
|
||||
|
||||
## Supported Node.js and Factorio Versions
|
||||
|
||||
Clusterio run on Node.js v12 and up, 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 v12 is the default.
|
||||
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.
|
||||
The GitHub Actions tests are run against the latest experimental release.
|
||||
|
||||
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
See our [decisions document](/docs/decisions.md) for justifcations.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
Imported modules from the lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
|
||||
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("@clusterio/lib/link");
|
||||
const libLuaTools = require("@clusterio/lib/lua_tools");
|
||||
import libLink from "./link";
|
||||
import libLuaTools from "./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
|
||||
|
||||
@@ -268,37 +299,3 @@ And there's two spaces before the next heading.
|
||||
|
||||
Headings use the `# heading` format.
|
||||
```
|
||||
|
||||
|
||||
## Pushing releases to npm
|
||||
|
||||
The releases are done by creating a new release tag with lerna and then uploading that with lerna.
|
||||
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`, otherwise they will be auto commited!
|
||||
|
||||
2. Run a lerna version to create and tag a new version.
|
||||
This will push a commit to the master branch.
|
||||
|
||||
```sh
|
||||
npx lerna version prerelease --git-remote upstream --no-granular-pathspec
|
||||
```
|
||||
|
||||
3. Run a full build in every package to update the dist/ files.
|
||||
|
||||
```sh
|
||||
npx lerna run build
|
||||
```
|
||||
|
||||
4. Publish the packages with lerna.
|
||||
|
||||
```sh
|
||||
npx lerna publish from-package
|
||||
```
|
||||
@@ -0,0 +1,479 @@
|
||||
# Decisions
|
||||
|
||||
This document outlines key decisions taken by the project team that should be followed by all maintainers and contributors.
|
||||
If there is a particular issue or concern you would like the team to comment on then please create a github issue for it.
|
||||
|
||||
<!-- Try to use categories that already exist if possible -->
|
||||
<!-- Sublists should be alphabetical, avoiding starting with "the" -->
|
||||
<!-- For code style, add (Lang) to section names if present in both -->
|
||||
|
||||
1. Documentation
|
||||
- [Decision Log](#decision-log)
|
||||
- [TSDoc](#tsdoc)
|
||||
2. Code Style (General)
|
||||
- [Indenting](#indenting)
|
||||
- [Line Length](#line-length)
|
||||
- [Naming Booleans](#naming-booleans)
|
||||
- [Naming Files](#naming-files)
|
||||
- [Naming Times and Durations](#naming-times-and-durations)
|
||||
- [Strings](#strings)
|
||||
3. Code Style (TS / JS / Web)
|
||||
- [ESLint](#eslint)
|
||||
- [Imports Within `lib` Package](#imports-within-lib-package)
|
||||
- [Name Style](#name-style-ts)
|
||||
4. Code Style (Lua)
|
||||
- [FMTK](#fmtk)
|
||||
- [Name Style](#name-style-lua)
|
||||
5. Code Style (Other)
|
||||
- [Markdown](#markdown)
|
||||
6. Repository Structure
|
||||
- [External Plugins](#external-plugins)
|
||||
- [First Party Plugins](#first-party-plugins)
|
||||
- [Core Packages](#core-packages)
|
||||
- [TypeScript Configs](#typescript-configs)
|
||||
- [Workspace](#workspace)
|
||||
7. Project Architecture
|
||||
- [Deprecation of Phin in favour of `fetch`](#deprecation-of-phin-in-favour-of-fetch)
|
||||
- [Minimal Dependencies](#minimal-dependencies)
|
||||
- [React](#react)
|
||||
- [Separation of Host and Controller](#separation-of-host-and-controller)
|
||||
- [Typescript](#typescript)
|
||||
- [Webpack](#webpack)
|
||||
8. Supportability
|
||||
- [Breaking Changes](#breaking-changes)
|
||||
- [Distribution](#distribution)
|
||||
- [Factorio](#factorio)
|
||||
- [General Support](#general-support)
|
||||
- [NodeJS](#nodejs)
|
||||
- [Operating System](#operating-systems)
|
||||
9. Testing
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Continuous Deployment](#continuous-deployment)
|
||||
- [Mocha](#mocha)
|
||||
- [Version Matrix](#version-matrix)
|
||||
|
||||
> [!note]
|
||||
> The use of `Time Immemorial` indicates an obsverion of a previously undocumented decision with no known start date, which has remained unchallenged.
|
||||
|
||||
<!--
|
||||
### Template
|
||||
|
||||
🧾 0th Month 2000 | Source
|
||||
|
||||
> A short sentence describing the action to take in the context of our project.
|
||||
|
||||
Write a breif description which includes: the reason a decision was needed, the outcome of the discussion, and the justification of the outcome.
|
||||
Write using the active voice in third person "we".
|
||||
Remember to have one sentence per line.
|
||||
-->
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
### Decision Log
|
||||
|
||||
🧾 22nd December 2024 | Discord Voice Call
|
||||
|
||||
> Maintain a document that includes a brief description of the outcomes and the reasoning behind our decisions, along with links to the relevant sources for further reading.
|
||||
|
||||
We discovered that we were beginning to ask similar questions to other maintainers that we had previously raised, which led to a waste of time for all involved.
|
||||
Consequently, we decided that a log of important decisions should be kept for the benefit of ourselves and future maintainers.
|
||||
|
||||
|
||||
### TSDoc
|
||||
|
||||
🧾 12th August 2023 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1320573126731370506) | [Github PR #493](https://github.com/clusterio/clusterio/pull/493)
|
||||
|
||||
> When a function or it's arguments do not have a clear purpose, a TSDoc comment should be used to describe it.
|
||||
|
||||
While TypeScript provides useful type infomation in function hover text, it does not allow for comments about the purpose of functions and their arguments.
|
||||
Therefore, we decided to use TSDoc comments for this purpose as it is well intergrated in many text editors and IDEs (such as VSCode).
|
||||
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
|
||||
API Extractor was also considered but not selected.
|
||||
|
||||
|
||||
## Code Style (General)
|
||||
|
||||
### Indenting
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Use tabs for indention. Except for Markdown where spaces carry meaning.
|
||||
|
||||
We needed to maintain a consistent whitespace style across all our files.
|
||||
Therefore, we selected tabs as our standard.
|
||||
|
||||
|
||||
### Line Length
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Lines are limited to 120 characters where tabs count as 4 characters.
|
||||
|
||||
We needed to maintain a consistent max line legnth across all our files.
|
||||
Therefore, we selected 120 characters as our standard.
|
||||
|
||||
|
||||
### Naming Booleans
|
||||
|
||||
🧾 25th November 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1310611960508977223)
|
||||
|
||||
> Variable and member properties which contain booleans should be named starting with a verb unless it ends with "ed", e.g. `canRestart` `expectEmpty` `hasFallback` `connected` `enabled` `loaded` `banned`
|
||||
|
||||
We had inconsistencies in our naming of booleans and aimed to standardise them.
|
||||
There was little preference for any particular approach, so we chose the one that would require the fewest changes to existing code.
|
||||
As a result, all boolean values should begin with a verb, unless they end with "ed." For clarity, both "is" and "has" are considered verbs.
|
||||
|
||||
|
||||
### Naming Files
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Files use lowercase_underscore if multiple values are exported. If a single class is exported then it uses the class name in PascalCase.
|
||||
|
||||
We needed to maintain a consistent name style across all our files.
|
||||
Therefore, we selected lowercase undercore for most files, with class files using PascalCase.
|
||||
|
||||
|
||||
### Naming Times and Durations
|
||||
|
||||
🧾 18th January 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1197566542548828170)
|
||||
|
||||
> Variable and member properties which contain times or durations should end with their SI unit of messure, e.g. `updatedAtMs`
|
||||
|
||||
We found verifying the units of measurement used in different locations to be a simple yet repetitive task that could lead to errors.
|
||||
Our main concern was the mixing of variables containing seconds and milliseconds.
|
||||
Therefore, we decided that times and durations should explicitly include their units, whereas other value types do not require any units.
|
||||
|
||||
|
||||
### Strings
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Strings should use double quotes `"` unless the string contains them, in which case single quotes `'` can be used. For JS string templates use backticks ``` ` ``` as normal.
|
||||
|
||||
We needed to maintain a consistent string style across all our files.
|
||||
Therefore, we selected double quotes as our standard.
|
||||
|
||||
## Code Style (TS / JS / Web)
|
||||
|
||||
### ESLint
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Use ESLint for style checking.
|
||||
|
||||
We needed to maintain a consistent style across all our files.
|
||||
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
|
||||
|
||||
|
||||
### Imports Within `lib` Package
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> When importing files within the lib package, all names should be prefixed with "lib".
|
||||
|
||||
The exports of the various library files are often very generic and may conflict with variable names.
|
||||
Therefore, when importing these files the "lib" prefix should be used to avoid confussing.
|
||||
This only applies to files within `lib` importing other files in `lib` because all other packages import the whole of `lib` as a single package.
|
||||
|
||||
|
||||
### Name Style (TS)
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Variables and members of classes use camelCase. Classes use PascalCase. Config values use lowercase_underscore. Exceptions allowed for over the write objects.
|
||||
|
||||
We needed to maintain a consistent name style across all our files.
|
||||
Therefore, we follow common practive for JavaScript of using camelCase and PascalCase.
|
||||
Although, we have also allowed the use of lowercase_underscore for config values to distinguish them from other variables.
|
||||
|
||||
|
||||
## Code Style (Lua)
|
||||
|
||||
### FMTK
|
||||
|
||||
🧾 14th June 2024 | [External PR](https://github.com/justarandomgeek/vscode-factoriomod-debug/pull/126)
|
||||
|
||||
> Use of FMTK is not required but is advised.
|
||||
> To enable Clusterio support, use the CLI flag `--clusterio-modules` under `Lua.runtime.pluginArgs`.
|
||||
|
||||
We needed to maintain a consistent style across all our files.
|
||||
There are multiple competing standards for Lua code, and given the limited amount of Lua code in this project, we chose not to adopt any automatic linting or shared luarc file.
|
||||
Nevertheless, we recomend the use of [FMTK](https://github.com/justarandomgeek/vscode-factoriomod-debug) and have taken steps towords supporting it in Clusterio.
|
||||
|
||||
|
||||
### Name Style (Lua)
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Everything uses lowercase_underscore
|
||||
|
||||
We needed to maintain a consistent name style across all our files.
|
||||
Therefore, we follow common practive for Lua of using lowercase_underscore.
|
||||
|
||||
|
||||
## Code Style (Other)
|
||||
|
||||
### Markdown
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Use one sentence per line.
|
||||
> Place two lines before each heading, except for the first one or when two headings are adjacent.
|
||||
|
||||
We needed to maintain a consistent code style across all our markdown files.
|
||||
Therefore, we selected an approach which makes diff reports more useful and which makes headings use additional space to make them stand out.
|
||||
|
||||
|
||||
## Repository Structure
|
||||
|
||||
### External Plugins
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> No external plugins should be included in the main repository.
|
||||
> A folder will exist which external plugin repositories can be cloned into and will be given the same treatment as first party plugins.
|
||||
|
||||
We require a designated space for developers to create their plugins without the concern of conflicts with first-party plugins.
|
||||
To address this, we have introduced a dedicated folder for external plugins.
|
||||
This solution is straightforward and has since proven to be highly effective.
|
||||
|
||||
|
||||
### First Party Plugins
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> First party plugins are included in the main repository.
|
||||
> Those selected to be first party are held to the same stanards which apply to the core packages.
|
||||
> Each will recieve a new release in step with the core packages.
|
||||
|
||||
We require a designated space for first-party plugins, separate from the core packages, to clearly represent the dependency between them.
|
||||
Creating a folder provided a simple solution to this.
|
||||
|
||||
|
||||
### Core Packages
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> All core packages will be contained in the packages folder of the repository.
|
||||
> They are allowed to be interdependent on each other.
|
||||
|
||||
We require a set of core packages to represent the different applications used in a cluster.
|
||||
See [Separation of Host and Controller](#separation-of-host-and-controller).
|
||||
To avoid putting more files into the repository root we created a folder for the core packages.
|
||||
|
||||
|
||||
### TypeScript Configs
|
||||
|
||||
🧾 26th August 2023 | [Github PR #504](https://github.com/clusterio/clusterio/pull/504)
|
||||
|
||||
> Each package / plugin may contain any of: `tsconfig.json` `tsconfig.node.json` and `tsconfig.web.json`.
|
||||
> The main respository root will contain all three files in addition to `tsconfig.base.json` which node and web extend from.
|
||||
> The configs inside packages / plugins should extend from those in the respository root.
|
||||
|
||||
We found that maintaining independent tsconfig files became burdensome and required a way to consolidate them.
|
||||
Initially, we introduced a base configuration file; however, this proved insufficient, so we extended it to include separate configurations for Node.js and the Web.
|
||||
This approach led to each package having up to three tsconfig files.
|
||||
Nevertheless, we believe this to be justified, given the need to differentiate between Node.js and Web builds while maintaining a single, shared configuration.
|
||||
|
||||
|
||||
### Workspace
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> The workspace root should contain only: configuration files, scripts used to build / clean the workspace, the readme, and the changelog.
|
||||
|
||||
We need to maintain a clean workspace root by only including files which effect all pacakges and plugins such as configuration files.
|
||||
We have allowed for build and clean scripts to exist here as long as they are not published as part of any package or plugin.
|
||||
|
||||
|
||||
## Project Architecture
|
||||
|
||||
### Deprecation of Phin in favour of `fetch`
|
||||
|
||||
🧾 12th December 2024 | [Discord Discussion Dec 24](https://discord.com/channels/450361298220351489/450361298220351491/1316781609206419567) | [Discord Discussion May 25](https://discord.com/channels/450361298220351489/450361298220351491/1240246997496102942)
|
||||
|
||||
> The builtin `fetch` method should be used as a replacement of the deprecated Phin module.
|
||||
|
||||
We required a replacement for the deprecated Phin HTTP client, which was used for making server-side requests and as part of testing.
|
||||
With `fetch` being marked as stable in Node.js v21.0.0 and having first been made available in v16.15.0, it serves as a suitable replacement without the need to add new dependencies.
|
||||
It had previously not been used because it was not marked as stable in any LTS version of NodeJS.
|
||||
|
||||
|
||||
### Minimal Dependencies
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Any new dependencies need to be well justified.
|
||||
|
||||
We aim to minimise our exposure to upstream changes by using a minimal number of dependencies.
|
||||
This also serves to reduce the bloat of a cluster installation and improve installation times.
|
||||
Consequently, the addition of any new dependencies must be thoroughly justified, with evidence of research into alternative options and comparisons between them.
|
||||
|
||||
|
||||
### React
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Our web stack uses ReactJS
|
||||
|
||||
We require a reactive website for our control panel, and ReactJS was selected due to its widespread use and familiarity.
|
||||
Switching away from React for any reason would represent a significant undertaking.
|
||||
|
||||
|
||||
### Separation of Host and Controller
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> The primary application locations are Control (web and cli), Controller, and Host.
|
||||
> Therefore the core packages are: `ctl` `controller` `host`. There are two libaray packages `lib` and `web_ui`.
|
||||
> Additionally `create` exists to provide easy creation of a cluster.
|
||||
|
||||
A single application to perform all the duties of a cluster would complicate the reasoning and maintenance of the project, so we decided to split it into several packages.
|
||||
The primary components of a cluster include servers connected to an instance, which runs on a host that connects to the controller, whose configuration is managed via a control connection from a CLI or web portal.
|
||||
Based on this structure, we found it logical to divide the packages according to their installation location, resulting in `ctl`, `controller`, and `host`, with shared code separated into `lib` and `web_ui` depending on where it is utilised.
|
||||
The additional `create` package is used by `npm create` to provide a user-friendly installation process.
|
||||
|
||||
|
||||
### TypeScript
|
||||
|
||||
🧾 6th August 2023 | [Github PR #491](https://github.com/clusterio/clusterio/issues/491)
|
||||
|
||||
> We use TypeScript as our language of choice.
|
||||
|
||||
We previously used JavaScript and switched to TypeScript to provide a robust type system.
|
||||
Transitioning to any other langauge would be too significant an undertaking.
|
||||
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
|
||||
|
||||
|
||||
### Webpack
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> Our web stack uses Webpack and associated plugins.
|
||||
|
||||
We required a bundler for our web build, and at the time, Webpack was the standard.
|
||||
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
|
||||
|
||||
|
||||
## Supportability
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
🧾 7th October 2026
|
||||
|
||||
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
|
||||
> All breaking changes are documented in the changelog along with migration actions.
|
||||
|
||||
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
|
||||
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
|
||||
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
|
||||
Changes to the plugin API may still be made in minor versions.
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
|
||||
|
||||
### Distribution
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We use `git` and `pnpm` for development distribution.
|
||||
> We use `npm` and the factorio mod portal for production distribution.
|
||||
|
||||
We need to distribute both production builds and development source code for the project.
|
||||
By necessity, we use `npm` and the Factorio Mod Portal to distribute our production builds.
|
||||
For development, we rely on `git` and GitHub, which are industry standards, while using `pnpm` as a more efficient variant of `npm` for development installations.
|
||||
Although bun was considered upon its release, we were not convinced it was the right choice.
|
||||
|
||||
|
||||
### Factorio
|
||||
|
||||
🧾 26th October 2024 | [Discord Discussion](https://discord.com/channels/450361298220351489/450361298220351491/1299637042350653461)
|
||||
|
||||
> We actively provide full support for the last two major versions of Factorio.
|
||||
> This approach does not follow semantic versioning; instead, it follows the "middle" versions: 2.1 > 2.0 > 1.1 > 1.0 > 0.18 > 0.17.
|
||||
> We offer passive support for all versions from 0.17 onwards, where implementation is trivial and does not impose restrictions on our actively supported versions.
|
||||
|
||||
Following the 2.0 update of Factorio, which introduced a large number of breaking changes, we needed to reconsider which versions would be actively supported, as it was no longer trivial to support all versions.
|
||||
Anticipating that future breaking changes would also be significant, and assuming the majority of players would update to the latest stable version upon release, we decided to support the latest stable version and the previous major version that introduced breaking changes.
|
||||
Support for other versions will not be removed if they have already been implemented and do not impose restrictions on our ability to support newer versions.
|
||||
|
||||
|
||||
### General Support
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We provide full support via GitHub and our Discord server for the latest published version of Clusterio, as well as assistance with migrating from the previous published version.
|
||||
> However, we do not offer any guarantees with our support; we assist when time permits and may not be able to resolve every issue.
|
||||
> Additionally, while we offer support in setting up development workspaces and guidance for plugin development, we do not provide support for those running development builds as production instances.
|
||||
|
||||
We aim to support as many users of Clusterio as possible in setting up and using the platform for a variety of purposes, including setting up production instances, development workspaces, and providing guidance on plugin development.
|
||||
However, our time to work on Clusterio is limited, and even more so our ability to offer support.
|
||||
As such, we cannot assist with every issue and must set reasonable restrictions to ensure expectations are aligned.
|
||||
Consequently, we do not offer any guarantees for our support, but we strive to provide as much assistance as possible for those setting up or running the latest published version.
|
||||
Additionally, we support users in setting up development workspaces, as the more people willing to contribute, the better.
|
||||
|
||||
|
||||
### NodeJS
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We support all LTS versions of NodeJS in their active and maintenance phases.
|
||||
|
||||
Our options were: supporting all versions, supporting all active versions, supporting all LTS versions, or supporting only the latest version.
|
||||
As we aim to provide a stable, production-ready tool while still limiting the number of versions we need to manage, we chose to support all LTS versions because NodeJS can be installed by the majority of linux distro package managers which will always offer at least one LTS version.
|
||||
|
||||
|
||||
### Operating Systems
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We support all major versions of Windows, Linux, and MacOS.
|
||||
This is largely inpart to our use of NodeJS and is not a guarantee from us.
|
||||
|
||||
Our choice of Node.js has enabled us to support all major operating systems with minimal effort on our part.
|
||||
Nevertheless, there have been instances where code paths have diverged between systems, and we have attempted to maintain support for as many systems as possible.
|
||||
However, this is not a priority, and such support has often been reactive or provided by external contributors.
|
||||
|
||||
|
||||
## Testing
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We run unit and integration tests through GitHub CI actions as well as test coverage through CodeCov.
|
||||
|
||||
Continuous integration actions in GitHub are a valuable tool that we use to assist with the review of pull requests from both maintainers and contributors.
|
||||
While we aim to increase the number of useful checks performed by CI in the future, this is not a priority over the development of new features.
|
||||
|
||||
|
||||
### Continuous Deployment
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We do not use any CD actions, and have no plans of implementing them.
|
||||
|
||||
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
|
||||
This is due to the frequent breaking changes merged into the main branch, which we prefer to bundle together as a single version.
|
||||
In the future, we may automate the deployment process, but it will always require manual activation.
|
||||
|
||||
|
||||
### Mocha
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We use Mocha for testing and CodeCov for coverage.
|
||||
|
||||
We chose Mocha because it was a good fit when we selected it.
|
||||
However, we are always looking for a better framework which is worth the effort of switching.
|
||||
|
||||
|
||||
### Version Matrix
|
||||
|
||||
🧾 Time Immemorial
|
||||
|
||||
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
|
||||
|
||||
We perform tests on all versions of NodeJS and Factorio that we support.
|
||||
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
|
||||
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
|
||||
## Communicating with Clusterio
|
||||
|
||||
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.
|
||||
See [protocol.md](devs/protocol.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.
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# 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
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
import Data from "./Data";
|
||||
|
||||
/**
|
||||
* Composed data class example
|
||||
* @alias module:Data
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
```
|
||||
|
||||
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,327 @@
|
||||
# Migrating Plugins to 2.0
|
||||
|
||||
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
|
||||
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
|
||||
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
|
||||
|
||||
|
||||
## Versioning from 2.0
|
||||
|
||||
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
|
||||
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
|
||||
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
|
||||
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
|
||||
|
||||
|
||||
## Convert to ES modules
|
||||
|
||||
Clusterio's packages are now ES modules and plugins should be too.
|
||||
Add `"type": "module"` to the plugin's `package.json` and replace `require()` and `module.exports` with `import` and `export`.
|
||||
|
||||
Relative imports must include the full filename with the `.js` extension.
|
||||
This also applies in TypeScript, where you import `./messages.js` even though the source file is `messages.ts`.
|
||||
Importing a directory no longer resolves to its `index.js`.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
"use strict";
|
||||
const lib = require("@clusterio/lib");
|
||||
const messages = require("./messages");
|
||||
|
||||
const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
plugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```js
|
||||
import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
controllerEntrypoint: "controller.js",
|
||||
messages: [messages.Frobnicate],
|
||||
};
|
||||
```
|
||||
|
||||
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
|
||||
|
||||
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
|
||||
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
|
||||
|
||||
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
|
||||
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
|
||||
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
|
||||
|
||||
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
|
||||
|
||||
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
|
||||
Converting the plugin avoids the problem.
|
||||
|
||||
|
||||
## Replace plugin classes with entrypoint functions
|
||||
|
||||
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
|
||||
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
|
||||
Class exports still load, but they are deprecated and a warning is logged for each one.
|
||||
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
|
||||
|
||||
**Alpha 27**
|
||||
```js
|
||||
const { BaseControllerPlugin } = require("@clusterio/controller");
|
||||
|
||||
class ControllerPlugin extends BaseControllerPlugin {
|
||||
async init() {
|
||||
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
|
||||
}
|
||||
|
||||
async onSaveData() {
|
||||
await this.saveFrobnicationData();
|
||||
}
|
||||
|
||||
async onInstanceStatusChanged(instance, prev) {
|
||||
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
}
|
||||
|
||||
async handleFrobnicate(request) { /* ... */ }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControllerPlugin,
|
||||
};
|
||||
```
|
||||
|
||||
**2.0 (JS)**
|
||||
```js
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
|
||||
controller.handle(Frobnicate, async (request) => { /* ... */ });
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
|
||||
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
|
||||
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**2.0 (TS)**
|
||||
```ts
|
||||
import type { ControllerPluginContext } from "@clusterio/controller";
|
||||
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
// Same as above
|
||||
}
|
||||
```
|
||||
|
||||
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
|
||||
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
|
||||
|
||||
|
||||
### Keeping a class for state
|
||||
|
||||
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
|
||||
`fromContext` can then be the default export of the entrypoint.
|
||||
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
|
||||
|
||||
```ts
|
||||
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
export class Frobber {
|
||||
private static instances = new WeakMap<Controller, Frobber>();
|
||||
private frobnications = new Map<string, number>();
|
||||
|
||||
private constructor(
|
||||
private controller: Controller,
|
||||
private logger: lib.Logger,
|
||||
) { }
|
||||
|
||||
static async fromContext(context: ControllerPluginContext) {
|
||||
const frobber = new Frobber(context.controller, context.logger);
|
||||
await frobber.load();
|
||||
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
|
||||
Frobber.instances.set(context.controller, frobber);
|
||||
return frobber;
|
||||
}
|
||||
|
||||
static get(controller: Controller) {
|
||||
return Frobber.instances.get(controller);
|
||||
}
|
||||
|
||||
async load() { /* ... */ }
|
||||
async save() { /* ... */ }
|
||||
}
|
||||
|
||||
export default Frobber.fromContext;
|
||||
```
|
||||
|
||||
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
|
||||
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
|
||||
|
||||
|
||||
### Method to hook names
|
||||
|
||||
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
|
||||
The exceptions are:
|
||||
|
||||
- `onSaveData` on the controller is `controller.hooks.save`.
|
||||
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
|
||||
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
|
||||
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
|
||||
|
||||
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
|
||||
The ctl hooks are passed directly as `context.hooks`.
|
||||
|
||||
**Alpha 27**
|
||||
```jsx
|
||||
class WebPlugin extends BaseWebPlugin {
|
||||
async init() {
|
||||
this.pages = [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2.0**
|
||||
```jsx
|
||||
export default async function(context) {
|
||||
const { control, plugin } = context;
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Replacing other class members
|
||||
|
||||
The class properties are on the context object passed to the entrypoint:
|
||||
|
||||
- `this.info` is `context.plugin`.
|
||||
- `this.logger` is `context.logger`.
|
||||
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
|
||||
- `this.metrics` on controller plugins is `context.metrics`.
|
||||
- `this.package` on web plugins is `context.package`.
|
||||
|
||||
The helper methods of the base classes have the following replacements:
|
||||
|
||||
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
|
||||
The plugin name is used to label the RCON metrics.
|
||||
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
|
||||
- `this.sendOrderedRcon(message)` has no replacement.
|
||||
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
|
||||
|
||||
|
||||
### Changes to hook behaviour
|
||||
|
||||
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
|
||||
Don't rely on another plugin's handler having finished before yours runs.
|
||||
|
||||
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
|
||||
Each hook accepts one handler per name, attach it under `plugin.name`.
|
||||
|
||||
|
||||
## Accessing other plugins
|
||||
|
||||
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
|
||||
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
|
||||
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
|
||||
|
||||
```js
|
||||
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
|
||||
const loaded = info !== undefined && controller.loadedPlugins.has(info);
|
||||
```
|
||||
|
||||
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
|
||||
`control.plugins` is deprecated and only holds plugins still using the class export.
|
||||
|
||||
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
|
||||
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
|
||||
|
||||
```ts
|
||||
import { Frobber } from "other_plugin/dist/node/controller.js";
|
||||
|
||||
const frobber = Frobber.get(controller);
|
||||
if (!frobber) {
|
||||
throw new Error("other_plugin is not loaded");
|
||||
}
|
||||
```
|
||||
|
||||
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
|
||||
|
||||
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
|
||||
The web plugin template created by `@clusterio/create` shows this for a subscriber.
|
||||
|
||||
|
||||
## Declare permissions on the plugin
|
||||
|
||||
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
|
||||
`lib.definePermission()` still works, but don't use both for the same permission name.
|
||||
|
||||
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
|
||||
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
|
||||
|
||||
```ts
|
||||
declare module "@clusterio/lib" {
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
|
||||
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
|
||||
|
||||
|
||||
## Renamed ctl classes and config
|
||||
|
||||
The remaining uses of "control" for clusterioctl were renamed to "ctl":
|
||||
|
||||
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
|
||||
Fields under the old name are silently ignored.
|
||||
- The `ControlConfigFields` interface is `CtlConfigFields`.
|
||||
- `lib.ControlConfig` is `lib.CtlConfig`.
|
||||
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
|
||||
|
||||
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
|
||||
|
||||
|
||||
## Static assets in the web interface
|
||||
|
||||
`staticRoot` now includes the `static/` prefix.
|
||||
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
|
||||
|
||||
|
||||
## Package dependencies
|
||||
|
||||
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
|
||||
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
|
||||
|
||||
```json
|
||||
"peerDependencies": {
|
||||
"@clusterio/controller": "^2.0.0",
|
||||
"@clusterio/host": "^2.0.0",
|
||||
"@clusterio/lib": "^2.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
},
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
+26
-31
@@ -1,7 +1,7 @@
|
||||
# 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.
|
||||
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/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.
|
||||
@@ -12,9 +12,9 @@ Events and requests are defined as instances of the `Request` and `Event` classe
|
||||
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 master server there's a `Link` instance for each slave and control connection stored in the `WsServer` class.
|
||||
For slaves the `Slave` instance is a link to the master server, and each `Instance` instance is an in-memory link to the `Slave` instance and has a corresponding `InstanceConnection` link stored on the `Slave` instance.
|
||||
For ctl and web_ui there's a `Control` instance that acts as the link to the master server.
|
||||
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 there's a `Ctl` instance 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.
|
||||
@@ -24,7 +24,7 @@ The handlers are defined on the link class itself for core messages, and on the
|
||||
## 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_slave` or `register_control` message depending on what kind of client it is to the server.
|
||||
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.
|
||||
@@ -33,7 +33,7 @@ Messages sent over the link should be stored in a send buffer and only cleared f
|
||||
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_slave` or `register_control`.
|
||||
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`.
|
||||
|
||||

|
||||
|
||||
@@ -96,30 +96,27 @@ The following messages are the only valid messages that can be sent during the h
|
||||
|
||||
#### `hello`
|
||||
|
||||
Sent by the master server when the WebSocket connection has been opened.
|
||||
Sent by the controller 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.
|
||||
- `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_slave`
|
||||
#### `register_host`
|
||||
|
||||
Slave handshake for establishing a new connection session.
|
||||
Host 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.
|
||||
- `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.
|
||||
- `agent: string` - Human readable string of the software connecting.
|
||||
- `version: string` - The protocol version of the control, e.g. "2.0.0".
|
||||
|
||||
#### `resume`
|
||||
@@ -131,12 +128,12 @@ Handshake for resuming an existing connection session where the connection had d
|
||||
|
||||
#### `invalidate`
|
||||
|
||||
Sent by the master server in reply to a resume for a session that cannot be resumed.
|
||||
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 master server to signal resuming the session was successful.
|
||||
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.
|
||||
@@ -144,7 +141,7 @@ Sent by the master server to signal resuming the session was successful.
|
||||
|
||||
#### `ready`
|
||||
|
||||
Sent by the master server to signal the connection has been established with a new session.
|
||||
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.
|
||||
@@ -159,7 +156,7 @@ The heartbeat should contain the `seq` of the last received message, and receivi
|
||||
|
||||
#### `heartbeat`
|
||||
|
||||
Sent by both the master server and clients at the interval given in the continue/ready messages.
|
||||
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.
|
||||
|
||||
@@ -169,7 +166,7 @@ Events are messages send in one direction over the link, that the receiver is ex
|
||||
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.
|
||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
|
||||
Plugins may defined their own events as well.
|
||||
|
||||
#### `*_event`
|
||||
@@ -184,7 +181,7 @@ They function a lot like HTTP requests except that both parties of a connection
|
||||
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.
|
||||
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
|
||||
Plugins may define their own requests as well.
|
||||
|
||||
#### `*_request`
|
||||
@@ -235,23 +232,21 @@ Server hello after opening the WebSocket.
|
||||
}
|
||||
```
|
||||
|
||||
Slave registering a new connection session.
|
||||
Host registering a new connection session.
|
||||
|
||||
```json
|
||||
{
|
||||
"seq": null,
|
||||
"type": "register_slave",
|
||||
"type": "register_host",
|
||||
"data": {
|
||||
"token": "xyzabc123",
|
||||
"agent": "Clusterio Slave",
|
||||
"version": "2.0.0",
|
||||
"name": "Foo",
|
||||
"id": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Master server reply signaling the session is established and ready.
|
||||
Controller reply signaling the session is established and ready.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -265,4 +260,4 @@ Master server reply signaling the session is established and ready.
|
||||
}
|
||||
```
|
||||
|
||||
Slave and master can now send events and requests over the connection.
|
||||
Host and controller can now send events and requests over the connection.
|
||||
@@ -5,12 +5,14 @@ The save patching is based on the built in [event_handler](https://github.com/wu
|
||||
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.
|
||||
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
|
||||
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
|
||||
|
||||
<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
|
||||
|
||||
The master server creates and stores accounts for all Factorio users that log into instances under its control.
|
||||
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"
|
||||
}
|
||||
}
|
||||
+214
-5
@@ -1,22 +1,124 @@
|
||||
# Managing a Cluster
|
||||
|
||||
Clusterio clusters 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.
|
||||
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 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.
|
||||
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
|
||||
|
||||
|
||||
## Slaves
|
||||
## Hosts
|
||||
|
||||
To be written.
|
||||
### 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.
|
||||
|
||||
### Update Clusterio
|
||||
|
||||
ctl> host update [--id <host id>] [--name <host name>] [--restart]
|
||||
|
||||
If remote updates are enabled on the host, this will attempt to update `@clusterio/host` on the machine. Changes will not be applied until the host is restarted. If the `--restart` flag is provided, the host will automatically restart once the update is complete.
|
||||
|
||||
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the following command locally while the host is offline:
|
||||
|
||||
host> config set host.allow_remote_updates true/false
|
||||
|
||||
The controller also supports remote updates, following the same logic. The corresponding commands are:
|
||||
|
||||
ctl> controller update [--restart]
|
||||
controller> config set controller.allow_remote_updates true/false
|
||||
|
||||
### Mark restart required
|
||||
|
||||
ctl> host mark-restart-required <host>
|
||||
|
||||
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
|
||||
|
||||
ctl> controller mark-restart-required
|
||||
|
||||
## 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.
|
||||
|
||||
### Save game
|
||||
|
||||
ctl> instance save-game <name>
|
||||
|
||||
Saves the running game of the instance to the save it was started from and waits for the save to finish.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -74,6 +176,33 @@ Since ban status is a part of the account data the account must exist for this t
|
||||
|
||||
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
|
||||
|
||||
@@ -89,7 +218,7 @@ By default there's a roled named Cluster Admin which grants access to everything
|
||||
|
||||
ctl> user delete <name>
|
||||
|
||||
Deletes everything stored on the master server for this user.
|
||||
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.
|
||||
|
||||
@@ -99,3 +228,83 @@ Note: If the player joins the cluster again a new account will be made for them
|
||||
## Roles
|
||||
|
||||
To be written.
|
||||
|
||||
|
||||
## Plugins (Local)
|
||||
|
||||
To be written.
|
||||
|
||||
|
||||
## Plugins (Remote)
|
||||
|
||||
By default, remote updates are enabled unless you selected "no" during installation.
|
||||
Remote updates allow ctl and the web UI to update plugins on machines within the cluster.
|
||||
However, remote installations are disabled by default to prevent the risk of arbitrary code execution through the installation of malicious npm packages onto the machine. See below if you wish to enable this feature.
|
||||
|
||||
### List
|
||||
|
||||
ctl> controller plugin list
|
||||
ctl> host plugin list <host>
|
||||
|
||||
This command lists all plugins currently known to the remote. Newly installed plugins will not be displayed until the remote is restarted. Plugins that are distributed as an npm package can be updated remotely.
|
||||
|
||||
### Update
|
||||
|
||||
ctl> controller plugin update <name> [--restart]
|
||||
ctl> host plugin update <host> <name> [--restart]
|
||||
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Update All
|
||||
|
||||
ctl> controller update --all [--restart]
|
||||
ctl> host update <host> --all [--restart]
|
||||
|
||||
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
|
||||
|
||||
### Install
|
||||
|
||||
ctl> controller plugin install <name> [--restart]
|
||||
ctl> host plugin install <host> <name> [--restart]
|
||||
|
||||
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Enable/Disable Updates
|
||||
|
||||
host> config set host.allow_plugin_updates true/false
|
||||
controller> config set controller.allow_plugin_updates true/false
|
||||
|
||||
Remote updates are enabled by default but can be disabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
|
||||
|
||||
### Enable/Disable Installs
|
||||
|
||||
host> config set host.allow_plugin_install true/false
|
||||
controller> config set controller.allow_plugin_install true/false
|
||||
|
||||
Remote installs are disabled by default but can be enabled on a per-machine basis at the owner's discretion. For security reasons, this configuration setting cannot be changed remotely. To modify it, run the above commands locally while the remote is offline.
|
||||
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
|
||||
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
|
||||
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
|
||||
|
||||
Create a role with just this permission, a user to hold it, and generate a token for that user.
|
||||
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
|
||||
|
||||
ctl> role create Prometheus --permissions core.controller.metrics
|
||||
ctl> user create #prometheus
|
||||
ctl> user set-roles #prometheus Prometheus
|
||||
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
|
||||
|
||||
Then pass the token in the scrape job for the controller in `prometheus.yml`:
|
||||
|
||||
scrape_configs:
|
||||
- job_name: clusterio
|
||||
authorization:
|
||||
credentials: <token>
|
||||
static_configs:
|
||||
- targets: ["localhost:8080"]
|
||||
|
||||
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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)
|
||||
|
||||
If you for some reason want to run this function in 2.0+, use the following command:
|
||||
|
||||
/sc local a={name="pack",description="",factorio_version=script.active_mods.base,mods={},settings={startup={},["runtime-global"]={},["runtime-per-user"]={}}}for b,c in pairs(script.active_mods)do table.insert(a.mods,{name=b,enabled=true,version=c})end;local function d(e,f)for b,c in pairs(e)do a.settings[f][b]=c end end;d(settings.startup,"startup")d(settings.global,"runtime-global")if game.player then d(game.player.mod_settings,"runtime-per-user")else d(settings.player,"runtime-per-user")end;local g=helpers.encode_string(helpers.table_to_json(a))log(g)rcon.print(g)
|
||||
|
||||
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,75 @@
|
||||
# Release workflow
|
||||
|
||||
## Update changelog
|
||||
|
||||
- CHANGELOG.md in the repository root
|
||||
- The changelog.txt file in mod directories
|
||||
|
||||
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
|
||||
|
||||
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
|
||||
|
||||
## Packages
|
||||
|
||||
The [Publish workflow](../.github/workflows/publish.yml) publishes npm packages and Lua mods when a GitHub release is published.
|
||||
For this workflow, first merge the changelog and package version updates into `master`, then create and push the release tag on that merged commit.
|
||||
Create a draft GitHub release for the existing tag and use the GitHub Release Markdown from `scripts/pr_to_changelog.mts` as its notes.
|
||||
Publishing the draft triggers the workflow; pushing a tag alone does not.
|
||||
The steps below describe publishing npm packages manually instead.
|
||||
|
||||
> [!NOTE]
|
||||
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
|
||||
|
||||
1. Ensure you are on the latest version of master branch with no untracked files
|
||||
|
||||
```sh
|
||||
git switch master
|
||||
git pull --ff-only upstream master
|
||||
git status
|
||||
```
|
||||
|
||||
2. Bump the version in the `package.json` files
|
||||
|
||||
```sh
|
||||
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
|
||||
````
|
||||
|
||||
3. Tag and push the release to github
|
||||
|
||||
```sh
|
||||
git add -p
|
||||
git commit -m v2.0.0-alpha.XX
|
||||
git tag v2.0.0-alpha.XX
|
||||
```
|
||||
|
||||
4. Perform a clean install and publish the packages.
|
||||
|
||||
```sh
|
||||
pnpm clean
|
||||
pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
1. Bump the version in the `package.json` file
|
||||
2. Run `pnpm install` in the repository root
|
||||
3. Run `pnpm adduser` to authenticate
|
||||
4. Run `pnpm publish` in the plugin/package directory
|
||||
|
||||
Before confirming the publish, ensure the packed size is approximately the same size as the last version (or within the expected size)
|
||||
|
||||
After publishing, test creating and installing the plugin on a fresh install.
|
||||
|
||||
## Lua mods
|
||||
|
||||
To publish lua mods like clusterio_lib, start by building from source:
|
||||
|
||||
```sh
|
||||
pnpm run build-mod
|
||||
```
|
||||
|
||||
or download the latest artifacts from the CI. When uploading the mods to the mod portal, make sure the latest version is the last version uploaded, as the game/mod portal/ingame updater isn't consistent in how it figures out the "latest version"
|
||||
|
||||
## Release notes
|
||||
|
||||
Release notes are expected to be published alongside new releases. We use the github releases system as well as posting in the #announcements channel on the discord. Major releases may warrant a ping.
|
||||
+28
-13
@@ -1,7 +1,7 @@
|
||||
# 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 master server.
|
||||
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.
|
||||
|
||||
@@ -11,17 +11,17 @@ See the last section for how to create and use a self-signed certificate for an
|
||||
|
||||
## Using a reverse proxy
|
||||
|
||||
Configure the proxy to forward requests over HTTP to the port the master server 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 master server with the sub-path stripped out.
|
||||
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 master server to listen only on the loopback interface to prevent it being directly connected to from the outside.
|
||||
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 clusteriomaster config set master.bind_address 127.0.0.1
|
||||
npx clusteriocontroller config set controller.bind_address 127.0.0.1
|
||||
|
||||
|
||||
### Apache Config
|
||||
@@ -44,7 +44,7 @@ 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 master server via the `master.https_port` option provided paths to a certificate file and a private key file is configured for the `master.tls_certificate` and `master.tls_private_key` options.
|
||||
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.
|
||||
|
||||
|
||||
@@ -58,15 +58,30 @@ substituting `<ip>` with the IP address the server will be accessed under, for e
|
||||
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 `master.tls_certificate` and `master.tls_private_key` config options respectively on the master server:
|
||||
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 clusteriomaster config set master.tls_certificate cert.pem
|
||||
npx clusteriomaster config set master.tls_private_key key.pem
|
||||
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 slaves and configure it as the `slave.tls_ca` option:
|
||||
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
|
||||
|
||||
npx clusterioslave config set slave.tls_ca cert.pem
|
||||
**For run-host.cmd**
|
||||
|
||||
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:
|
||||
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
|
||||
|
||||
npx clusterioctl control-config set control.tls_ca cert.pem
|
||||
**For run-host.sh**
|
||||
|
||||
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
|
||||
|
||||
**For clusteriohost.service**
|
||||
|
||||
Environment=NODE_EXTRA_CA_CERTS=path/to/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 set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
|
||||
|
||||
# On Windows
|
||||
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
|
||||
# On Linux
|
||||
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
|
||||
# You can now use clusterioctl
|
||||
npx clusterioctl instance list
|
||||
+423
-282
@@ -1,19 +1,19 @@
|
||||
# Writing Plugins
|
||||
|
||||
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
|
||||
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
|
||||
Plugins for Clusterio are JavaScript modules that run under Node.js.
|
||||
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
|
||||
|
||||
|
||||
## Contents
|
||||
|
||||
- [Plugin Structure](#plugin-structure)
|
||||
- [Defining the plugin class](#defining-the-plugin-class)
|
||||
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
|
||||
- [Logging Messages](#logging-messages)
|
||||
- [Plugin Configuration](#plugin-configuration)
|
||||
- [Plugin Permissions](#plugin-permissions)
|
||||
- [Migrating Stored Data](#migrating-stored-data)
|
||||
- [Communicating with Factorio](#communicating-with-factorio)
|
||||
- [Defining Link Messages](#defining-link-messages)
|
||||
- [Defining Events](#defining-events)
|
||||
- [Defining Requests](#defining-requests)
|
||||
- [Sending Link Messages](#sending-link-messages)
|
||||
- [Handling connection events](#handling-connection-events)
|
||||
- [Collecting Statistics](#collecting-statistics)
|
||||
@@ -25,11 +25,11 @@ The plugin classes have pre-defined hooks that are called during various stages
|
||||
The basic file structure of a plugin is the following.
|
||||
|
||||
plugin_name/
|
||||
+- info.js
|
||||
+- index.js
|
||||
+- package.json
|
||||
+- master.js
|
||||
+- controller.js
|
||||
+- instance.js
|
||||
+- control.js
|
||||
+- ctl.js
|
||||
+- test/
|
||||
| +- plugin.js
|
||||
+- module/
|
||||
@@ -41,22 +41,21 @@ The usual guides for creating such packages apply.
|
||||
At minimum the `package.json` file must contain a version entry.
|
||||
|
||||
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
|
||||
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriomaster, clusterioslave or clusterioctl.
|
||||
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
|
||||
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriocontroller, clusteriohost or clusterioctl.
|
||||
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
|
||||
|
||||
For a plugin the most important file is the `info.js` file.
|
||||
Without it the plugin will not recognized by Clusterio.
|
||||
Here's an example of it:
|
||||
For a plugin to be recognised by Clusterio it needs to export an entry named `plugin` from its main entrypoint.
|
||||
By default the main entrypoint is the `index.js` file, but this may be changed by setting the `"main"` entry to a different file in `package.json`.
|
||||
Here's an example of `index.js`:
|
||||
|
||||
```js
|
||||
const { libLink } = require("@clusterio/lib"); // For messages
|
||||
|
||||
module.exports = {
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
title: "Foo Frobber",
|
||||
description: "Does advanced frobnication",
|
||||
instanceEntrypoint: "instance",
|
||||
masterEntrypoint: "master",
|
||||
instanceEntrypoint: "instance.js",
|
||||
controllerEntrypoint: "controller.js",
|
||||
messages: {
|
||||
/* See below */
|
||||
},
|
||||
@@ -78,95 +77,113 @@ The following properties are recognized:
|
||||
Currently not used.
|
||||
|
||||
**instanceEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
|
||||
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin may have code only for instances but it must still be loaded on the master in order for it to be possible to load it on an instance.
|
||||
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
|
||||
|
||||
**InstanceConfigGroup**:
|
||||
Subclass of `PluginConfigGroup` for defining the per instance configuration fields for this plugin.
|
||||
**instanceConfigFields**:
|
||||
Object mapping instance configuration fields to `FieldDefinitions`for this plugin.
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**masterEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the MasterPlugin class definiton for this plugin.
|
||||
**controllerEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
|
||||
This is an optional parameter.
|
||||
A plugin can be made that only runs on the master server.
|
||||
A plugin can be made that only runs on the controller.
|
||||
|
||||
**MasterConfigGroup**:
|
||||
Subclass of `PluginConfigGroup` for defining the master server configuration fields for this plugin.
|
||||
**ControllerConfigGroup**:
|
||||
Object mapping controller configuration fields to `FieldDefinitions`for this plugin.
|
||||
See [Plugin Configuration](#plugin-configuration)
|
||||
|
||||
**controlEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which contains the ControlPlugin class definition for this plugin.
|
||||
**ctlEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
|
||||
This is an optional paramater.
|
||||
A plugin can be made that only runs on the clusterioctl side.
|
||||
|
||||
**migrationEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
||||
This is an optional parameter.
|
||||
See [Migrating Stored Data](#migrating-stored-data)
|
||||
|
||||
**features**:
|
||||
Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
|
||||
See [Handling Invalid Configuration](#handling-invalid-configuration)
|
||||
|
||||
**messages**:
|
||||
Object with link messages definitions for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
**permissions**:
|
||||
Array of permission definitions for this plugin.
|
||||
See [Plugin Permissions](#plugin-permissions)
|
||||
|
||||
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
|
||||
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
|
||||
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
|
||||
|
||||
While there is no standard for how to organize a plugin it's recommended to put the MasterPlugin class definition into master.js and the InstancePlugin class definition into instance.js.
|
||||
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
|
||||
You can put them into whatever file you want (even the same one for both).
|
||||
|
||||
For both instanceEntrypoint and masterEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
|
||||
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
|
||||
|
||||
|
||||
## Defining the plugin class
|
||||
## Defining the plugin entrypoint
|
||||
|
||||
The plugin class should derive from its respective base class defined in `lib/plugin`.
|
||||
For example, to define a MasterPlugin class the following code can be used:
|
||||
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
|
||||
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
|
||||
For example, a controller entrypoint can look like this:
|
||||
|
||||
```js
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
export default async function(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
|
||||
class MasterPlugin extends libPlugin.BaseMasterPlugin {
|
||||
async init() {
|
||||
this.foo = 42;
|
||||
await this.startFrobnication();
|
||||
}
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await saveFrobnicationData();
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MasterPlugin,
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("Stopping frobnication");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
For the instance plugin it's exactly the same except "Master" is replaced with "Instance", and for the clusterioctl plugin "Control" is used.
|
||||
The available hooks that you can override are documented in the base class [in lib/plugin.js](/packages/lib/plugin.js).
|
||||
The context has the following properties:
|
||||
|
||||
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
|
||||
E.g.:
|
||||
**plugin**:
|
||||
The plugin's own info, as exported from the main entrypoint.
|
||||
|
||||
```js
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
**logger**:
|
||||
Logger for this plugin, see [Logging Messages](#logging-messages).
|
||||
|
||||
// Code here
|
||||
}
|
||||
```
|
||||
**controller**, **host**, **instance**, **hooks** or **control**:
|
||||
The object the entrypoint is loaded for.
|
||||
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
|
||||
|
||||
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
|
||||
The async init method is always called immediatly after the constructor, so there's little reason to do this.
|
||||
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
|
||||
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
|
||||
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
|
||||
|
||||
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
|
||||
|
||||
### Class based plugins
|
||||
|
||||
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
|
||||
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
|
||||
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
|
||||
|
||||
|
||||
## Logging Messages
|
||||
|
||||
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
|
||||
For instances a copy of the log is also stored on the slave the instance is on.
|
||||
The load context provides a winston logger for logging messages to the shared cluster log.
|
||||
For instances a copy of the log is also stored on the host the instance is on.
|
||||
To use it, pass a string to one of the log levels functions, for example:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.logger.info("Initializing frobbing");
|
||||
export default async function(context) {
|
||||
context.logger.info("Initializing frobbing");
|
||||
}
|
||||
```
|
||||
|
||||
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
|
||||
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
|
||||
|
||||
The available levels are fatal, error, warn, audit, info, and verbose.
|
||||
These levels have roughly the following meanings:
|
||||
@@ -192,7 +209,7 @@ For guarding unexpected errors the best option is to log a short description alo
|
||||
try {
|
||||
// Operation that should not throw but may end up throwing
|
||||
} catch (err) {
|
||||
this.logger.error(`Operation failed:\n${err.stack}`);
|
||||
logger.error(`Operation failed:\n${err.stack}`);
|
||||
)
|
||||
```
|
||||
|
||||
@@ -202,71 +219,185 @@ For plugin hooks expections thrown are automatically catched and logged, but for
|
||||
## Plugin Configuration
|
||||
|
||||
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
|
||||
You can take advantage of it by subclassing `PluginConfigGroup`, setting `defaultAccess` to where config entries can be accessed from, setting the `groupName` to your plugin name, defining fields on it, finalizing it, and passing it as either `MasterConfigGroup` or `InstanceConfigGroup` in the `info.js` export.
|
||||
For example in info.js:
|
||||
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
|
||||
For example in index.js:
|
||||
|
||||
```js
|
||||
const libConfig = require("@clusterio/lib/config");
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
class MasterConfigGroup extends libConfig.PluginConfigGroup { }
|
||||
MasterConfigGroup.defaultAccess = ["master", "slave", "control"];
|
||||
MasterConfigGroup.groupName = "foo_frobber";
|
||||
MasterConfigGroup.define({
|
||||
name: "level",
|
||||
description: "Level of frobnication done",
|
||||
type: "number",
|
||||
initial_value: 2,
|
||||
});
|
||||
MasterConfigGroup.finalize();
|
||||
declare module "@clusterio/lib" {
|
||||
// Extend the interface defining available fields so that instance.config.get(...)
|
||||
// will have our own options.
|
||||
export interface ControllerConfigFields {
|
||||
"foo_frobber.level": number, // If optional is true then this should have | null in addition.
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export const plugin = {
|
||||
...
|
||||
MasterConfigGroup: MasterConfigGroup,
|
||||
};
|
||||
controllerConfigFields: {
|
||||
"foo_frobber.level": {
|
||||
description: "Level of frobnication done",
|
||||
type: "number",
|
||||
initialValue: 2,
|
||||
},
|
||||
}
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
Code inside the `MasterPlugin` class will then be able to access the level config field through the `Config` object at `this.master.config`, for example in the MasterPluginClass:
|
||||
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
let level = this.master.config.get("foo_frobber.level");
|
||||
this.logger.info(`I got a frobnication level of ${level}`);
|
||||
```ts
|
||||
export default async function(context: ControllerPluginContext) {
|
||||
const level = context.controller.config.get("foo_frobber.level");
|
||||
context.logger.info(`I got a frobnication level of ${level}`);
|
||||
}
|
||||
```
|
||||
|
||||
The same applies for instance configs, replace "master" with "instance" where appropriate.
|
||||
The same applies for instance configs, replace "controller" with "instance" where appropriate.
|
||||
See [Configuration System](config-system.md) for more details on how this system works.
|
||||
|
||||
|
||||
### Handling Invalid Configuration
|
||||
|
||||
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
|
||||
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
|
||||
For example:
|
||||
If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
if (!this.instance.config.get("factorio.enable_save_patching")) {
|
||||
throw new Error("foo_frobber plugin requires save patching.");
|
||||
export const plugin = {
|
||||
name: "foo_frobber",
|
||||
// ...
|
||||
features: ["SavePatching"],
|
||||
};
|
||||
```
|
||||
|
||||
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
|
||||
|
||||
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
|
||||
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
|
||||
See [Configuration System](config-system.md) for details.
|
||||
|
||||
|
||||
## Plugin Permissions
|
||||
|
||||
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
|
||||
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
|
||||
Names must start with the plugin name followed by a dot.
|
||||
For example in index.ts:
|
||||
|
||||
```ts
|
||||
import type * as lib from "@clusterio/lib";
|
||||
|
||||
declare module "@clusterio/lib" {
|
||||
// Extend the interface of known permission names so that
|
||||
// user.checkPermission(...) accepts our permission.
|
||||
export interface Permissions {
|
||||
"foo_frobber.frobnicate": never;
|
||||
}
|
||||
}
|
||||
|
||||
export const plugin = {
|
||||
...
|
||||
permissions: [
|
||||
{
|
||||
name: "foo_frobber.frobnicate",
|
||||
title: "Frobnicate",
|
||||
description: "Run frobnication on instances.",
|
||||
grantByDefault: false, // Whether the generated Player role is granted this permission
|
||||
},
|
||||
],
|
||||
} satisfies lib.PluginDeclaration;
|
||||
```
|
||||
|
||||
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
|
||||
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
|
||||
In JavaScript the `declare module` block is not needed.
|
||||
|
||||
Permissions declared this way are registered when the plugin is loaded.
|
||||
The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
|
||||
|
||||
|
||||
## Migrating Stored Data
|
||||
|
||||
Clusterio stores the config fields and permissions of plugins, so a plugin that renames them needs to migrate the stored values or users lose what they had set.
|
||||
This is done from the migration entrypoint, which is loaded on the controller and hosts before any config or role is read, for all plugins in the plugin list including those disabled with `load_plugin`.
|
||||
It is passed a context with `hooks` and attaches handlers for the data it needs to migrate:
|
||||
|
||||
```js
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export default function(context) {
|
||||
const { hooks, plugin } = context;
|
||||
// Plugin was renamed from frobber in 2.0.0
|
||||
const renameFields = config => lib.renameConfigPrefix(config, "frobber.", "foo_frobber.");
|
||||
hooks.controllerConfig.attach(plugin.name, renameFields);
|
||||
hooks.hostConfig.attach(plugin.name, renameFields);
|
||||
hooks.instanceConfig.attach(plugin.name, renameFields);
|
||||
hooks.role.attach(plugin.name, role => lib.renamePermissionPrefix(role, "frobber.", "foo_frobber."));
|
||||
}
|
||||
```
|
||||
|
||||
The handlers get the serialized config or role after the core migrations and return the migrated version.
|
||||
They run synchronously, a handler that throws stops the controller or host from starting.
|
||||
`lib.renameConfigField`, `lib.renameConfigPrefix`, `lib.renamePermission` and `lib.renamePermissionPrefix` cover the common case of renaming.
|
||||
|
||||
Handlers run every time the data is loaded and must leave data that is already migrated unchanged.
|
||||
For changes that can't be repeated, each config has a hidden `<plugin>.version` field with the version of the plugin it was last loaded with, which is updated after the migrations have run:
|
||||
|
||||
```js
|
||||
hooks.instanceConfig.attach(plugin.name, config => {
|
||||
const version = config["foo_frobber.version"];
|
||||
// The default delay went from 1 to 2 in 1.2.0, update configs still on the old default
|
||||
if (
|
||||
version && lib.integerFullVersion(version) < lib.integerFullVersion("1.2.0")
|
||||
&& config["foo_frobber.delay"] === 1
|
||||
) {
|
||||
config["foo_frobber.delay"] = 2;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
The version field is missing or empty in configs saved before Clusterio tracked it, and in configs last loaded in recovery mode.
|
||||
|
||||
Roles have no version of their own.
|
||||
They are stored on the controller, so use the version in the controller config, which is loaded before the roles:
|
||||
|
||||
```js
|
||||
let controllerVersion;
|
||||
hooks.controllerConfig.attach(plugin.name, config => {
|
||||
controllerVersion ??= config["foo_frobber.version"] ?? "";
|
||||
return config;
|
||||
});
|
||||
hooks.role.attach(plugin.name, role => {
|
||||
// foo_frobber.edit was split out of foo_frobber.view in 1.2.0
|
||||
if (
|
||||
controllerVersion && lib.integerFullVersion(controllerVersion) < lib.integerFullVersion("1.2.0")
|
||||
&& role.permissions.includes("foo_frobber.view")
|
||||
) {
|
||||
role.permissions.push("foo_frobber.edit");
|
||||
}
|
||||
return role;
|
||||
});
|
||||
```
|
||||
|
||||
Data the plugin stores itself, like files in the database directory or values in the save, is up to the plugin to migrate from its other entrypoints.
|
||||
Modules don't need migrating, save patching replaces the files of every module when an instance starts.
|
||||
|
||||
|
||||
## Communicating with Factorio
|
||||
|
||||
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
|
||||
This is done by calling the `sendRcon` method on the plugin object.
|
||||
This is done by calling the `sendRcon` method on the instance.
|
||||
For example:
|
||||
|
||||
```js
|
||||
async onStart() {
|
||||
let response = await this.sendRcon(
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
const response = await instance.sendRcon(
|
||||
"/sc rcon.print('data')"
|
||||
);
|
||||
|
||||
// Do stuff with response.
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
|
||||
@@ -279,15 +410,16 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
|
||||
For example in the plugin code:
|
||||
|
||||
```js
|
||||
async init() {
|
||||
this.instance.server.on("ipc-my_plugin_foo", content =>
|
||||
this.handleFoo(content).catch(err => this.logger.error(
|
||||
export default async function(context) {
|
||||
const { instance, logger } = context;
|
||||
instance.server.on("ipc-my_plugin_foo", content =>
|
||||
handleFoo(content).catch(err => logger.error(
|
||||
`Error handling foo:\n${err.stack}`
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
async handleFoo(content) {
|
||||
async function handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
@@ -301,6 +433,20 @@ local clusterio_api = require("modules/clusterio/api")
|
||||
clusterio_api.send_json("my_plugin_foo", { data = 123 })
|
||||
```
|
||||
|
||||
For convience a helper function is exposed on server specifically for handling IPC events.
|
||||
|
||||
```js
|
||||
export default async function(context) {
|
||||
context.instance.server.handle("my_plugin_foo", handleFoo);
|
||||
}
|
||||
|
||||
async function handleFoo(content) {
|
||||
// Do stuff with content
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`.
|
||||
|
||||
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
|
||||
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
|
||||
|
||||
@@ -313,8 +459,8 @@ For RCON, commands longer than 50 characters may end up being executed after sho
|
||||
|
||||
## Defining Link Messages
|
||||
|
||||
You will most likely have to communicate with the master or other instances in your plugin for it to do anything useful.
|
||||
For this there's a WebSocket communication channel established between the slaves and the master server that plugins can define their own messages to send over it.
|
||||
You will most likely have to communicate with the controller or other instances in your plugin for it to do anything useful.
|
||||
For this there's a WebSocket communication channel established between the hosts and the controller that plugins can define their own messages to send over it.
|
||||
This channel is bi-directional and all messages sent over it are validated with a JSON schema (see [this guide][guide] for an introduction to writing JSON schema).
|
||||
|
||||
[guide]: https://json-schema.org/learn/getting-started-step-by-step.html
|
||||
@@ -324,199 +470,180 @@ Events are simple one-way notifications that invoke a handler on the target it's
|
||||
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
|
||||
The requests are similar to HTTP requests, except both parties of a link may initiate one.
|
||||
|
||||
|
||||
### Defining Events
|
||||
|
||||
Events are defined as properties of the messages object exported by `info.js` that map to instances of the `Event` class from `lib/link`.
|
||||
The name of the property correspond to the handler invoked on the plugin class.
|
||||
The Event constructor takes an object of properties that define the event, for example the following could be defined in `info.js`:
|
||||
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
|
||||
For example, the following could be defined in `plugin`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
startFrobnication: new libLink.Event({
|
||||
type: "foo_frobber:start_frobnication",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
eventRequired: ["frobnication_type"],
|
||||
eventProperties: {
|
||||
"frobnication_type": { type: "string" },
|
||||
"urgent": { type: "boolean" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
```
|
||||
|
||||
This specifies an event that can be sent from the master to a slave, and from a slave to an instance.
|
||||
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
|
||||
It will also be forwarded by slaves to a specific instance.
|
||||
|
||||
The following properties are recognized by the Event constructor:
|
||||
|
||||
#### type
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_event` appended to it.
|
||||
|
||||
#### links
|
||||
|
||||
An array of strings describing which links this event can be sent over.
|
||||
Direction matters, `"master-slave"` means the event can be sent from the master to the slave, but can't be sent back the other way, unless `"slave-master"` is also present in the links array.
|
||||
|
||||
The available endpoints are `master`, `slave`, `instance`, and `control`.
|
||||
Master talks with slave and control, and slave talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
|
||||
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
|
||||
|
||||
#### forwardTo
|
||||
|
||||
Target to forward an event to.
|
||||
Can either be `"master"`, to indicate a slave should forward it to the master server, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
|
||||
This works by using a default handler for the event at the links that forward it.
|
||||
|
||||
#### broadcastTo
|
||||
|
||||
Target to broadcast this message towards.
|
||||
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
|
||||
Currently, only "instance" is supported.
|
||||
This means that sending the event to a slave from an instance will cause it to be broadcast to all instances of that slave except for the instance it came from.
|
||||
|
||||
#### eventRequired
|
||||
|
||||
By default all properties defined in `eventProperties` are required to be present in the payload for the event.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
|
||||
#### eventProperties
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the event.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas.
|
||||
|
||||
The forwardTo and broadcastTo can be combined such that specifying `"master"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
|
||||
For this to work, you will need to specify `instance-slave`, `slave-master`, `master-slave`, and `slave-instance` as the links.
|
||||
|
||||
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
|
||||
Use a request if you need a confirmation that the message was received.
|
||||
|
||||
### Definining Requests
|
||||
|
||||
Requests are defined as properties of the messages object exported by `info.js` that map to instances of the `Request` class from `lib/link`.
|
||||
The name of the property corresponds to the handler invoked on the plugin class.
|
||||
The Request constructor takes an object of properties that define the event.
|
||||
For example, the following could be defined in `info.js`:
|
||||
|
||||
```js
|
||||
messages: {
|
||||
reportFrobnication: new libLink.Request({
|
||||
type: "foo_frobber:report_frobnication",
|
||||
links: ["master-slave", "slave-instance"],
|
||||
forwardTo: "instance",
|
||||
requestRequired: ["verbosity"],
|
||||
requestProperties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
responseProperties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
messages: [
|
||||
class Frobnicate {
|
||||
static plugin = "foo_frobber"; // Plugin name
|
||||
static type = "request";
|
||||
static src = "controller";
|
||||
static dst = "instance";
|
||||
static jsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
"verbosity": { type: "integer" },
|
||||
"special": { type: "boolean" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
required: ["verbosity"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
constructor(json) {
|
||||
this.verbosity = json.verbosity;
|
||||
this.special = json.special;
|
||||
}
|
||||
static fromJSON(json) {
|
||||
return new Frobnicate(json);
|
||||
}
|
||||
toJSON() {
|
||||
return {
|
||||
verbosity: this.verbosity,
|
||||
special: this.special,
|
||||
};
|
||||
}
|
||||
static Response = {
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
"report": {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
fromJSON(json) {
|
||||
return json
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This specifies a request that can be sent from the master to a slave, and from a slave to an instance.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent must contain a `report` property mapping to an array of strings.
|
||||
When received by a slave, it will also be forwarded to the instance specified by `instance_id`.
|
||||
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
|
||||
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
|
||||
It also defines that the response sent may contain a `report` property mapping to an array of strings.
|
||||
|
||||
The following properties are recognized by the Request constructor:
|
||||
The following properties are recognized as part of a message class:
|
||||
|
||||
#### type
|
||||
#### static plugin
|
||||
|
||||
The message type sent over the wire.
|
||||
This must start with the name of the plugin followed by colon and and be unique for the plugin.
|
||||
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
|
||||
Must contain the name of the plugin this message is defined by.
|
||||
This property is not used in core messages.
|
||||
|
||||
#### links
|
||||
#### static type
|
||||
|
||||
An array of strings describing which links this request can be sent over.
|
||||
Direction matters; `"master-slave"` means the request can be sent from the master to the slave and the slave can reply to the master, but the slave can't send a request to the master unless `"slave-master"` is also present in the links array.
|
||||
A string describing the message type sent over the wire.
|
||||
This can be either `"request"` or `"event"`.
|
||||
The primary difference is whether they allow for a response.
|
||||
|
||||
The available endpoints are `master`, `slave`, `instance`, and `control`.
|
||||
Master talks with slave and control, and slave talks to instance.
|
||||
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from master to instance it must have both `"master-slave"` and `"slave-instance"` in the links array).
|
||||
See `forwardTo` for ways to forward a request to the next link in a chain.
|
||||
#### static src
|
||||
|
||||
#### forwardTo
|
||||
A string or an array of strings describing where this message is allowed to originate from.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
Messages sent from a different source will be rejected.
|
||||
|
||||
Target to forward the request to.
|
||||
Can either be `"master"` to indicate a slave should forward it to the master server when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
|
||||
This works by using a default handler for the request by the links that forward it.
|
||||
#### static dst
|
||||
|
||||
#### requestRequired
|
||||
A string or array of strings describing where this message may be addressed to.
|
||||
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
|
||||
If the target is multiple hops away it the message will be forwarded towards its destination by the intermediaries.
|
||||
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
|
||||
Messages sent to a different destination will be rejected.
|
||||
|
||||
By default all properties defined in `requestProperties` are required to be present in the payload for the request.
|
||||
This can be overriden by specifying an array of properties which are required here.
|
||||
This is equivalent to using the `required` keyword in JSON schema.
|
||||
#### static jsonSchema
|
||||
|
||||
#### requestProperties
|
||||
A JSON schema that specifies what's valid to send in the message.
|
||||
|
||||
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the request.
|
||||
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
|
||||
See [this guide][guide] for an introduction to writing JSON schemas
|
||||
#### toJSON
|
||||
|
||||
#### responseRequired
|
||||
Optional method to serialize an instance of the class to a JSON object, see the [documentation for `JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) for the semantics of this method.
|
||||
This should return an object that's valid according to `jsonSchema` and accepted by `fromJSON`, and may be omitted if the default serialisation by `JSON.stringify()` already does this.
|
||||
|
||||
Same as the requestRequired only for the response sent back by the target.
|
||||
#### static fromJSON
|
||||
|
||||
#### responseProperties
|
||||
Required function to restore the class from a JSON object.
|
||||
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
|
||||
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
|
||||
|
||||
Same as the requestProperties only for the response sent back by the target.
|
||||
#### static Response
|
||||
|
||||
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
|
||||
Required properties are `jsonSchema` and `fromJSON`.
|
||||
|
||||
## Sending Link Messages
|
||||
|
||||
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
|
||||
For `InstancePlugin` code the link to the slave is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
|
||||
The `.info` property of the plugin class exposes the data exported from the plugin's `info.js` module.
|
||||
In other words:
|
||||
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
|
||||
For example:
|
||||
|
||||
```js
|
||||
// In an InstancePlugin class
|
||||
async frobnicate() {
|
||||
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
|
||||
// In an instance entrypoint
|
||||
async function frobnicate(instance) {
|
||||
const response = await instance.sendTo("controller", new messages.Frobnicate({
|
||||
verbosity: 2,
|
||||
special: false,
|
||||
}));
|
||||
console.log(response); // { report: [...] }
|
||||
}
|
||||
```
|
||||
Because the send and handle methods rely on this, you should always call them directly on the connection object.
|
||||
|
||||
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
|
||||
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
|
||||
|
||||
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
|
||||
|
||||
Here is what that looks like in practice:
|
||||
|
||||
```js
|
||||
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
|
||||
const send = instance.sendTo;
|
||||
send("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
|
||||
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
|
||||
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
|
||||
```
|
||||
|
||||
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
|
||||
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
|
||||
|
||||
Example destinations:
|
||||
|
||||
```js
|
||||
sendTo("controller", message);
|
||||
sendTo("allHosts", message);
|
||||
sendTo("allControls", message);
|
||||
sendTo("allInstances", message);
|
||||
sendTo({ hostId: 123 }, message);
|
||||
sendTo({ instanceId: 123 }, message);
|
||||
sendTo({ controlId: 123 }, message);
|
||||
```
|
||||
|
||||
### Handling connection events
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
The most important is the prepare disconnect for the link between master and slave.
|
||||
This is signaled to `MasterPlugin` classes via the `onPrepareSlaveDisconnect` hook and to `InstancePlugin` classes via the `onPrepareMasterDisconnect` hook.
|
||||
The most important is the prepare disconnect for the link between controller and host.
|
||||
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
|
||||
|
||||
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
|
||||
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
|
||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `SlaveConnection` class and `Slave` class on the master and slave respectively.
|
||||
For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
|
||||
For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
|
||||
|
||||
```js
|
||||
if (this.slave.connected) {
|
||||
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
|
||||
if (host.connected) {
|
||||
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
|
||||
}
|
||||
```
|
||||
|
||||
If the event or request needs to be sent to the master it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `onMasterConnectionEvent` and `onSlaveConnectionEvent` hooks.
|
||||
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
|
||||
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
|
||||
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onMasterConnectionEvent` for `InstancePlugin` classes and through `onSlaveConnectionEvent` for `MasterPlugin` classes.
|
||||
This is raised when the connection between the master and slave in question is lost, most likely due to networking issues.
|
||||
When in the dropped state the slave will keep trying to reconnect to the master server in order to re-establish it, and if successful no events or requests will be lost.
|
||||
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
|
||||
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
|
||||
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
|
||||
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
|
||||
This means that if your plugin sends a lot of events or requests, they can end up being queued up in a buffer and sent out all at once the connection is re-estabished.
|
||||
To avoid this you should be throtteling and/or stopping your requests/events after `drop` has been raised, and continue back as normal when `resume` is raised.
|
||||
@@ -530,23 +657,23 @@ In its simplest form collecting data from plugins consists of defining the metri
|
||||
For example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib/prometheus");
|
||||
import { Counter } from "@clusterio/lib";
|
||||
|
||||
const fooMetric = new Counter(
|
||||
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
|
||||
);
|
||||
|
||||
// Somewhere in the master plugin code
|
||||
// Somewhere in the controller plugin code
|
||||
fooMetric.inc();
|
||||
```
|
||||
|
||||
This works for master plugins, and the metric will be automatically available through the /metric HTTP endpoint.
|
||||
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
|
||||
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
|
||||
|
||||
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
|
||||
|
||||
```js
|
||||
const { Counter } = require("@clusterio/lib/prometheus");
|
||||
import { Counter } from "@clusterio/lib";
|
||||
|
||||
const barMetric = new Gauge(
|
||||
"clusterio_foo_frobber_bar_metric", "Bar instance level",
|
||||
@@ -554,23 +681,48 @@ const barMetric = new Gauge(
|
||||
);
|
||||
|
||||
// Somewhere in the instance plugin code
|
||||
barMetric.labels(String(this.instance.id)).set(someValue);
|
||||
barMetric.labels(String(instance.id)).set(someValue);
|
||||
```
|
||||
|
||||
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the master server on slaves.
|
||||
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a slave.
|
||||
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
|
||||
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
|
||||
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
|
||||
|
||||
For statistics you need to update on collection there's an `onMetrics` hook on both master and instance plugins that is run before the metrics in the default registry are collected.
|
||||
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
|
||||
|
||||
```js
|
||||
import { Gauge } from "@clusterio/lib";
|
||||
|
||||
const frobnicationsMetric = new Gauge(
|
||||
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
|
||||
);
|
||||
|
||||
// In the controller entrypoint
|
||||
controller.hooks.metrics.attach(plugin.name, async () => {
|
||||
frobnicationsMetric.set(frobnications.size);
|
||||
});
|
||||
```
|
||||
|
||||
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
|
||||
|
||||
```js
|
||||
import { CollectorRegistry, Counter } from "@clusterio/lib";
|
||||
|
||||
const fooRegistry = new CollectorRegistry();
|
||||
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
|
||||
fooRegistry.register(fooCounter);
|
||||
|
||||
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
|
||||
```
|
||||
|
||||
|
||||
## Adding Custom Commands to clusterioctl
|
||||
|
||||
The control entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
|
||||
The creation of custom commands typically starts with defining a command tree for the plugin:
|
||||
|
||||
```js
|
||||
const { Command, CommandTree } = require("@clusterio/lib/command");
|
||||
import { Command, CommandTree } from "@clusterio/lib";
|
||||
const fooFrobberCommands = new CommandTree({
|
||||
name: "foo-frobber", description: "Foo Frobber Plugin commands"
|
||||
});
|
||||
@@ -579,41 +731,30 @@ const fooFrobberCommands = new CommandTree({
|
||||
Then commands are added to the the plugin's command tree:
|
||||
|
||||
```js
|
||||
const info = require("./info");
|
||||
import * as messages from "./messages.js";
|
||||
|
||||
fooFrobberCommands.add(new Command({
|
||||
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
|
||||
yargs.positional("level", {
|
||||
yargs.positional("type", {
|
||||
describe: "type of frobnication", type: "string"
|
||||
});
|
||||
}],
|
||||
handler: async function(args, control) {
|
||||
await info.messages.frobnicate.send(control, {
|
||||
instance_name: "Console",
|
||||
content: args.message,
|
||||
});
|
||||
handler: async function(args, ctl) {
|
||||
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
|
||||
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
|
||||
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
|
||||
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
|
||||
|
||||
Note that messages sent from clusterioctl needs to have `"control-master"` as a part of the links array for it to be accepted by the master server, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the master.
|
||||
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Control plugin:
|
||||
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
|
||||
|
||||
```js
|
||||
const libPlugin = require("@clusterio/lib/plugin");
|
||||
|
||||
class ControlPlugin extends libPlugin.BaseControlPlugin {
|
||||
async addCommands(rootCommand) {
|
||||
export default async function(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(fooFrobberCommands);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ControlPlugin,
|
||||
}
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,504 @@
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
|
||||
"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", {
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"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", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"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": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": ["constructor"],
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"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": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-return-await": "off",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"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": "off",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
"object-curly-newline": "error",
|
||||
"object-curly-spacing": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-named-capture-group": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"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": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
|
||||
"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/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": "error",
|
||||
"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",
|
||||
},
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.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",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "module",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"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": "error",
|
||||
"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",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
@@ -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,8 +0,0 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"plugins/*",
|
||||
"test"
|
||||
],
|
||||
"version": "2.0.0-alpha.9"
|
||||
}
|
||||
+64
-29
@@ -1,31 +1,66 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "mocha --check-leaks -R spec --exclude \"**/node_modules/**\" --recursive test plugins/global_chat/test plugins/research_sync/test plugins/statistics_exporter/test plugins/subspace_storage/test plugins/player_auth/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": ">=12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.25.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"jsdoc": "^3.6.6",
|
||||
"lerna": "^4.0.0",
|
||||
"mocha": "^8.4.0",
|
||||
"nyc": "^15.1.0"
|
||||
}
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"files": [],
|
||||
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"watch": "tsc --build --watch",
|
||||
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
|
||||
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
|
||||
"silent-test": "SILENT_TEST=y pnpm test",
|
||||
"fast-test": "FAST_TEST=y pnpm test",
|
||||
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
|
||||
"cover": "c8 pnpm test",
|
||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"clean": "node ./clean.js",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"c8": {
|
||||
"exclude": [
|
||||
"test/**",
|
||||
"plugins/*/test/**",
|
||||
"temp/**"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"plugins/*",
|
||||
"external_plugins/*",
|
||||
"external_plugins/*/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@clusterio/controller": "workspace:^",
|
||||
"@clusterio/ctl": "workspace:^",
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
||||
"@typescript-eslint/parser": "^8.70.0",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"globals": "^17.12.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"typedoc": "^0.28.20",
|
||||
"typescript": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
webpack.config.js
|
||||
dist/browser
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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 is required for operation.
|
||||
See the `config` command for inspecting and modifying the configuration.
|
||||
|
||||
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
|
||||
Alternative to the `config create` command.
|
||||
|
||||
|
||||
### `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 install <package>`
|
||||
|
||||
Install a plugin from npm and add it to the plugin list.
|
||||
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
|
||||
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
|
||||
|
||||
For example, installing the Subspace Storage plugin:
|
||||
|
||||
npx clusteriocontroller plugin install @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 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.
|
||||
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
|
||||
|
||||
For example, adding a plugin checked out in the external_plugins directory:
|
||||
|
||||
npx clusteriocontroller plugin add ./external_plugins/my_plugin
|
||||
|
||||
|
||||
#### `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 create`
|
||||
|
||||
Create a new config file with the default options inserted into if it doesn't already exist.
|
||||
|
||||
See also the `--create-config` option.
|
||||
|
||||
|
||||
#### `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.
|
||||
|
||||
|
||||
### `copy-static [target]`
|
||||
|
||||
Copies the static web assets to the given target folder (which defaults to `./static/`).
|
||||
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
|
||||
|
||||
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
|
||||
|
||||
See also the `controller.public_url` config option for setting where static assets are loaded from.
|
||||
|
||||
|
||||
### `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,545 @@
|
||||
#!/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 "node:fs/promises";
|
||||
|
||||
import crypto from "crypto";
|
||||
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";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Controller from "./src/Controller.js";
|
||||
import UserManager from "./src/UserManager.js";
|
||||
import UserRecord from "./src/UserRecord.js";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "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 (const host of controller.hosts.values()) {
|
||||
gauge.labels({
|
||||
host_id: String(host.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 (const instance of controller.instances.values()) {
|
||||
gauge.labels({
|
||||
instance_id: String(instance.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) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
if (!controller) {
|
||||
return;
|
||||
}
|
||||
gauge.labels("host").set(controller.wsServer.hostConnections.size);
|
||||
gauge.labels("control").set(controller.wsServer.controlConnections.size);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
async function handleBootstrapCommand(
|
||||
args: any,
|
||||
controllerConfig: lib.ControllerConfig,
|
||||
controllerConfigLock: lib.LockFile,
|
||||
): Promise<void> {
|
||||
let subCommand = args._[1];
|
||||
|
||||
const databaseDirectory = controllerConfig.get("controller.database_directory");
|
||||
await UserManager.attemptMigrateLegacyUsersFile(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
).bootstrap());
|
||||
|
||||
const userManager = new UserManager(users, roles, controllerConfig);
|
||||
|
||||
if (subCommand === "create-admin") {
|
||||
await controllerConfigLock.acquire(); // Also needed to write to the database files
|
||||
|
||||
if (!args.name) {
|
||||
logger.error("name cannot be blank");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userManager.getOrCreateUser(args.name);
|
||||
user.roleIds.add(lib.Role.DefaultAdminRoleId);
|
||||
user.isAdmin = true;
|
||||
user.saveRecord();
|
||||
|
||||
await userManager.records.save();
|
||||
await controllerConfigLock.release();
|
||||
|
||||
} else if (subCommand === "generate-user-token") {
|
||||
let user = userManager.getByName(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));
|
||||
|
||||
} else if (subCommand === "generate-host-token") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(jwt.sign(
|
||||
{ aud: "host", host: args.id },
|
||||
// Auth secret is never null after startup
|
||||
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
));
|
||||
|
||||
} else if (subCommand === "create-ctl-config") {
|
||||
let admin = userManager.getByName(args.name);
|
||||
if (!admin) {
|
||||
logger.error(`No user named '${args.name}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
let ctlConfig = new lib.CtlConfig("control");
|
||||
|
||||
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
ctlConfig.set(
|
||||
"ctl.controller_token",
|
||||
userManager.signUserToken(admin),
|
||||
);
|
||||
|
||||
let content = JSON.stringify(ctlConfig, 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, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyStaticCommand(
|
||||
args: any,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
) {
|
||||
await fs.cp(
|
||||
path.join(import.meta.dirname, "..", "web", "static"),
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
logger.info("Copied main web interface files");
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
try {
|
||||
await fs.cp(
|
||||
pluginInfo.webStaticPath,
|
||||
args.target,
|
||||
{
|
||||
recursive: true,
|
||||
mode: fs.constants.COPYFILE_FICLONE,
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
|
||||
} else {
|
||||
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
logger.info(`Copied web interface files for ${pluginInfo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
controllerConfig: lib.ControllerConfig | null;
|
||||
}
|
||||
|
||||
async function initialize(): Promise<InitializeParameters> {
|
||||
// argument parsing
|
||||
const args = await yargs(process.argv.slice(2))
|
||||
.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("create-config", {
|
||||
nargs: 0,
|
||||
describe: "Create config file if it does not exist",
|
||||
default: false,
|
||||
type: "boolean",
|
||||
})
|
||||
.option("plugin-list", {
|
||||
nargs: 1,
|
||||
describe: "File containing list of plugins available with their install path",
|
||||
default: "plugin-list.json",
|
||||
type: "string",
|
||||
})
|
||||
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
|
||||
.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", yargs => {
|
||||
yargs.positional("name", { describe: "Name of the admin user", type: "string" });
|
||||
})
|
||||
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
|
||||
yargs.positional("name", { describe: "Name of the user", type: "string" });
|
||||
})
|
||||
.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-ctl.json",
|
||||
});
|
||||
})
|
||||
.demandCommand(1, "You need to specify a command to run");
|
||||
})
|
||||
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
|
||||
yargs.positional("target", {
|
||||
describe: "Target directory to copy files to",
|
||||
type: "string",
|
||||
default: "static",
|
||||
});
|
||||
})
|
||||
.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("recovery", {
|
||||
type: "boolean", nargs: 0, default: false,
|
||||
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
|
||||
});
|
||||
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
|
||||
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()
|
||||
.parse()
|
||||
;
|
||||
|
||||
// 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 ${packageConfig.version}`);
|
||||
if (args.recovery) {
|
||||
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
|
||||
}
|
||||
shouldRun = true;
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
|
||||
// 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,
|
||||
controllerConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
if (!args.recovery) {
|
||||
await lib.loadPluginMigrations(pluginInfos);
|
||||
}
|
||||
|
||||
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
|
||||
args,
|
||||
lib.ControllerConfig,
|
||||
"controller",
|
||||
);
|
||||
|
||||
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
|
||||
|
||||
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 controllerConfig.save();
|
||||
}
|
||||
|
||||
if (command === "config") {
|
||||
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
|
||||
} else if (command === "bootstrap") {
|
||||
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
|
||||
} else if (command === "copy-static") {
|
||||
await handleCopyStaticCommand(args, pluginInfos);
|
||||
} else if (shouldRun) {
|
||||
await controllerConfigLock.acquire(); // Hold the lock until process exit
|
||||
}
|
||||
|
||||
return {
|
||||
args,
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
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,
|
||||
controllerConfig,
|
||||
} = await initialize();
|
||||
if (!shouldRun || !pluginInfos || !controllerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller = new Controller(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
controllerConfig,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Controller.bootstrap(controllerConfig)
|
||||
);
|
||||
|
||||
// Refuse to start if there are no users loaded
|
||||
if (args.checkUserCount && controller.users.records.size === 0) {
|
||||
logger.fatal(
|
||||
"Cannot start controller, no users loaded.\n" +
|
||||
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Handle interrupts
|
||||
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
|
||||
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
|
||||
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 || err.code === "InstallationError") {
|
||||
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 (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { bootstrap } from "./controller.js";
|
||||
export { default as Controller } from "./src/Controller.js";
|
||||
export { default as ControllerRouter } from "./src/ControllerRouter.js";
|
||||
export { default as ControlConnection } from "./src/ControlConnection.js";
|
||||
export { default as HostConnection } from "./src/HostConnection.js";
|
||||
export { default as InstanceRecord } from "./src/InstanceRecord.js";
|
||||
export { default as InstanceManager } from "./src/InstanceManager.js";
|
||||
export { default as HostRecord } from "./src/HostRecord.js";
|
||||
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
|
||||
export { default as UserManager } from "./src/UserManager.js";
|
||||
export { default as UserRecord } from "./src/UserRecord.js";
|
||||
export { default as User } from "./src/User.js";
|
||||
export { default as WsServer } from "./src/WsServer.js";
|
||||
|
||||
if (import.meta.main) {
|
||||
bootstrap();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "@clusterio/controller",
|
||||
"version": "2.0.0",
|
||||
"description": "Implementation of Clusterio controller",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"license": "MIT",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"busboy": "^1.6.0",
|
||||
"compression": "^1.8.2",
|
||||
"express": "catalog:",
|
||||
"finalhandler": "^2.1.1",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"winston": "catalog:",
|
||||
"winston-daily-rotate-file": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"yargs": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "catalog:",
|
||||
"@types/finalhandler": "catalog:",
|
||||
"@types/jsonwebtoken": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-dev-middleware": "^6.1.3",
|
||||
"webpack-merge": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import path from "path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import * as routes from "./routes.js";
|
||||
|
||||
|
||||
/**
|
||||
* 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.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
|
||||
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
|
||||
|
||||
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
|
||||
this.connector.on("close", () => {
|
||||
this._controller.subscriptions.unsubscribeLink(this);
|
||||
});
|
||||
}
|
||||
|
||||
async disconnect(code: number, reason: string) {
|
||||
try {
|
||||
await this.connector.disconnect(code, reason);
|
||||
} 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: lib.SourceVersion, 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);
|
||||
// Open source file before creating the stream in case it throws.
|
||||
let source = (await fs.open(modPath)).createReadStream();
|
||||
|
||||
let stream = await routes.createProxyStream(this._controller.app);
|
||||
stream.filename = mod.filename;
|
||||
stream.source = source;
|
||||
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);
|
||||
}
|
||||
|
||||
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
|
||||
return await this._controller.factorioVersions.get(request.maxAgeMs);
|
||||
}
|
||||
|
||||
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
|
||||
return await this._controller.latestReleases.get(request.maxAgeMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type InstanceRecord from "./InstanceRecord.js";
|
||||
import type ControlConnection from "./ControlConnection.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
export type ControllerPluginContext = lib.PluginLoadContext<{
|
||||
controller: Controller;
|
||||
metrics: any;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Collection of controller plugin hooks
|
||||
*/
|
||||
export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
constructor(logger: lib.Logger) {
|
||||
super(logger);
|
||||
this.save = this.newHook();
|
||||
this.metrics = this.newHook();
|
||||
this.shutdown = this.newHook();
|
||||
this.instanceStatusChanged = this.newHook();
|
||||
this.instanceConfigFieldChanged = this.newHook();
|
||||
this.controllerConfigFieldChanged = this.newHook();
|
||||
this.controlConnectionEvent = this.newHook();
|
||||
this.hostConnectionEvent = this.newHook();
|
||||
this.prepareHostDisconnect = this.newHook();
|
||||
this.modPacksUpdated = this.newHook();
|
||||
this.modsUpdated = this.newHook();
|
||||
this.rolesUpdated = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
readonly save: lib.AsyncHook<[]>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
readonly controllerConfigFieldChanged: lib.AsyncHook<[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.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, 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.
|
||||
*/
|
||||
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
|
||||
|
||||
/**
|
||||
* Called when the controller is shutting down
|
||||
*/
|
||||
readonly shutdown: lib.AsyncHook<[]>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly hostConnectionEvent: lib.AsyncHook<[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.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
readonly controlConnectionEvent: lib.AsyncHook<[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.
|
||||
*/
|
||||
readonly prepareHostDisconnect: lib.AsyncHook<[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.
|
||||
*/
|
||||
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.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.
|
||||
*/
|
||||
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
|
||||
|
||||
/**
|
||||
* Called when one or more roles stored on the controller are updated
|
||||
*
|
||||
* Invoked when one or more roles have been added, updated or deleted.
|
||||
*
|
||||
* If a role has been deleted its `.isDeleted` property will be true.
|
||||
*
|
||||
* @param roles - Roles that updated.
|
||||
*/
|
||||
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export interface BaseControllerPlugin {
|
||||
onSaveData?(): Promise<void>;
|
||||
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
|
||||
onShutdown?(): Promise<void>;
|
||||
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
|
||||
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
|
||||
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
|
||||
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
|
||||
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
|
||||
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
|
||||
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
export class BaseControllerPlugin {
|
||||
constructor(
|
||||
public info: lib.PluginNodeEnvInfo,
|
||||
public controller: Controller,
|
||||
public metrics: any,
|
||||
public logger: lib.Logger,
|
||||
) {
|
||||
const attach = <Args extends unknown[], Return>(
|
||||
hook: lib.AsyncHook<Args, Return>,
|
||||
fn?: lib.HookHandler<Args, Return>,
|
||||
) => {
|
||||
if (fn) {
|
||||
hook.attach(info.name, fn.bind(this));
|
||||
}
|
||||
};
|
||||
|
||||
attach(controller.hooks.save, this.onSaveData);
|
||||
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
|
||||
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
|
||||
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
|
||||
attach(controller.hooks.metrics, this.onMetrics);
|
||||
attach(controller.hooks.shutdown, this.onShutdown);
|
||||
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
|
||||
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
|
||||
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
|
||||
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
|
||||
attach(controller.hooks.modsUpdated, this.onModsUpdated);
|
||||
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
|
||||
attach(controller.hooks.playerEvent, this.onPlayerEvent);
|
||||
}
|
||||
|
||||
static fromContext(context: ControllerPluginContext) {
|
||||
return new this(context.plugin, context.controller, context.metrics, context.logger);
|
||||
}
|
||||
|
||||
detachHooks() {
|
||||
this.controller.hooks.detachAll(this.info.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called immediately after the class is instantiated
|
||||
*/
|
||||
async init() {}
|
||||
|
||||
/**
|
||||
* 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: lib.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,132 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type BaseConnection from "./BaseConnection.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
|
||||
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,464 @@
|
||||
import type Controller from "./Controller.js";
|
||||
import type WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import BaseConnection from "./BaseConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
import HostRecord from "./HostRecord.js";
|
||||
|
||||
|
||||
// How long log message warnings are suppressed for after one is logged.
|
||||
const logMessageWarnIntervalMs = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Represents the connection to a host
|
||||
*
|
||||
* @extends module:controller/src/BaseConnection
|
||||
* @alias module:controller/src/HostConnection
|
||||
*/
|
||||
export default class HostConnection extends BaseConnection {
|
||||
private info: HostRecord;
|
||||
plugins: Map<string, string>;
|
||||
// Shared by all log message warnings, a misbehaving host could log in a loop.
|
||||
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
|
||||
|
||||
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 HostRecord(
|
||||
registerData.id,
|
||||
previousHostInfo?.name ?? "",
|
||||
registerData.version,
|
||||
this.plugins,
|
||||
true,
|
||||
remoteAddress,
|
||||
previousHostInfo?.publicAddress,
|
||||
previousHostInfo?.tokenValidAfter,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
this._controller.hosts.set(this.info);
|
||||
|
||||
this._checkPluginVersions();
|
||||
|
||||
for (let event of ["connect", "drop", "resume", "close"] as const) {
|
||||
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
|
||||
}
|
||||
|
||||
this.connector.on("close", () => {
|
||||
// Update status to unknown for instances on this host.
|
||||
const instances: InstanceRecord[] = [];
|
||||
for (let instance of this._controller.instances.valuesMutable()) {
|
||||
if (instance.config.get("instance.assigned_host") !== this.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
instances.push(instance);
|
||||
let prev = instance.status;
|
||||
instance.status = "unknown";
|
||||
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
this._controller.instances.records.setMany(instances);
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
});
|
||||
|
||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||
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));
|
||||
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.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 this._controller.hooks.prepareHostDisconnect.invoke(this);
|
||||
return await super.prepareDisconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* ID of the host this connection is connected to
|
||||
*
|
||||
* @returns {number} host ID.
|
||||
*/
|
||||
get id() {
|
||||
return this.info.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote address of the host this connection is connected to
|
||||
*
|
||||
* @returns {string} host remote address.
|
||||
*/
|
||||
get remoteAddress() {
|
||||
return this.info.remoteAddress;
|
||||
}
|
||||
|
||||
async handleGetFactorioCredentialsRequest() {
|
||||
if (this._controller.config.get("controller.share_factorio_credentials_with_hosts")) {
|
||||
return {
|
||||
username: this._controller.config.get("controller.factorio_username") ?? undefined,
|
||||
token: this._controller.config.get("controller.factorio_token") ?? undefined,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
|
||||
this.info.name = event.update.name;
|
||||
this.info.publicAddress = event.update.publicAddress;
|
||||
this._controller.hosts.set(this.info);
|
||||
}
|
||||
|
||||
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
|
||||
let instance = this._controller.instances.getMutable(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;
|
||||
// If the instance has transitioned to running then updated the started at time
|
||||
if (request.status === "running" && prev === "starting") {
|
||||
instance.startedAtMs = Date.now();
|
||||
}
|
||||
|
||||
instance.gamePort = request.gamePort;
|
||||
instance.factorioVersion = request.factorioVersion;
|
||||
this._controller.instances.records.set(instance);
|
||||
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(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: InstanceRecord[] = [];
|
||||
for (let instanceData of request.instances) {
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
instanceConfig.update(instanceData.config, false, "host");
|
||||
|
||||
let controllerInstance = this._controller.instances.getMutable(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.factorioVersion = instanceData.factorioVersion;
|
||||
if (instanceData.status === "running" && prev === "starting") {
|
||||
controllerInstance.startedAtMs = Date.now();
|
||||
}
|
||||
instanceUpdates.push(controllerInstance);
|
||||
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceConfig.set("instance.assigned_host", this.id);
|
||||
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
|
||||
newInstance.factorioVersion = instanceData.factorioVersion;
|
||||
newInstance.gamePort = instanceData.gamePort;
|
||||
newInstance.status = instanceData.status;
|
||||
instanceUpdates.push(newInstance);
|
||||
}
|
||||
|
||||
// We suppress the changes made by createInstance so we must manually make them here
|
||||
this._controller.instances.records.setMany(instanceUpdates);
|
||||
await Promise.all(instanceUpdates.flatMap(newInstance => [
|
||||
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
|
||||
this.send(
|
||||
new lib.InstanceAssignInternalRequest(
|
||||
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
|
||||
)
|
||||
),
|
||||
]));
|
||||
|
||||
// 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.users.values()) {
|
||||
if (user.isAdmin) {
|
||||
adminlist.add(user.id);
|
||||
}
|
||||
if (user.isBanned) {
|
||||
banlist.set(user.id, user.banReason);
|
||||
}
|
||||
if (user.isWhitelisted) {
|
||||
whitelist.add(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
|
||||
}
|
||||
|
||||
// A host may only report on instances assigned to it. Otherwise any host
|
||||
// could speak for an instance on a different host. See
|
||||
// handleInstanceStatusChangedEvent.
|
||||
private _assignedInstance(instanceId: number) {
|
||||
const instance = this._controller.instances.get(instanceId);
|
||||
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
|
||||
}
|
||||
|
||||
// Host connections live for days, so the suppression expires instead of
|
||||
// latching for the life of the connection.
|
||||
private _warnLogMessage(message: string) {
|
||||
const now = Date.now();
|
||||
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
|
||||
return;
|
||||
}
|
||||
this._lastLogMessageWarnMs = now;
|
||||
logger.warn(message);
|
||||
}
|
||||
|
||||
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
|
||||
// Otherwise any host could inject save records for, or wipe the save
|
||||
// list of, an instance on a different host.
|
||||
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
|
||||
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
|
||||
return;
|
||||
}
|
||||
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
|
||||
logger.warn(`Got bogus save updates from host id ${this.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: lib.SaveDetails[] = [];
|
||||
const deletes: lib.SaveDetails[] = [];
|
||||
for (const save of event.updates) {
|
||||
const existingSave = this._controller.saves.get(save.id);
|
||||
if (existingSave && save.equals(existingSave)) {
|
||||
continue;
|
||||
}
|
||||
updates.push(save);
|
||||
}
|
||||
if (event.instanceId !== undefined) {
|
||||
const updatedSaves = new Set(event.updates.map(s => s.id));
|
||||
for (const save of this._controller.saves.values()) {
|
||||
if (save.instanceId === event.instanceId && !updatedSaves.has(save.id)) {
|
||||
deletes.push(save);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Hosts eagerly send updates, which means we may get an update
|
||||
// where nothing actually changed.
|
||||
this._controller.saves.setMany(updates);
|
||||
this._controller.saves.deleteMany(deletes);
|
||||
}
|
||||
|
||||
async handleLogMessageEvent(event: lib.LogMessageEvent) {
|
||||
// Fields beyond level and message survive schema validation, so the
|
||||
// instance a log line claims to come from is whatever the host sent.
|
||||
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
|
||||
if (info.instance_id !== undefined) {
|
||||
const instance = this._assignedInstance(info.instance_id);
|
||||
if (!instance) {
|
||||
// Demote to a host level line instead of dropping it. The host did
|
||||
// log this, it just does not get to pin it on someone else's
|
||||
// instance, and a host racing a reassignment still gets heard.
|
||||
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
|
||||
delete info.instance_id;
|
||||
delete info.instance_name;
|
||||
} else {
|
||||
// The name the host sent may be stale or made up, ours is authoritative.
|
||||
const name = instance.config.get("instance.name");
|
||||
if (info.instance_name !== name) {
|
||||
this._warnLogMessage(
|
||||
`Got log message for instance id ${info.instance_id} named ` +
|
||||
`${info.instance_name} which is named ${name} on the controller`
|
||||
);
|
||||
info.instance_name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clusterLogger.log({
|
||||
...info,
|
||||
host_id: this.id,
|
||||
host_name: this.info.name,
|
||||
});
|
||||
}
|
||||
|
||||
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
|
||||
let instanceId = src.id;
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
|
||||
if (event.type === "join") {
|
||||
user.notifyJoin(instanceId);
|
||||
} else if (event.type === "leave") {
|
||||
user.notifyLeave(instanceId);
|
||||
}
|
||||
|
||||
user.instanceStats.set(instanceId, event.stats);
|
||||
|
||||
user.recalculatePlayerStats();
|
||||
user.saveRecord();
|
||||
|
||||
let instance = this._controller.instances.get(instanceId)!;
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.type,
|
||||
name: event.name,
|
||||
reason: event.reason,
|
||||
stats: event.stats,
|
||||
});
|
||||
}
|
||||
|
||||
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
||||
if (src.type !== lib.Address.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isAdmin", event.admin);
|
||||
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
});
|
||||
}
|
||||
|
||||
async snoopInstanceBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent, src: lib.Address) {
|
||||
if (src.type !== lib.Address.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.isBanned = event.banned;
|
||||
user.banReason = event.reason;
|
||||
user.saveRecord();
|
||||
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
reason: event.reason,
|
||||
});
|
||||
}
|
||||
|
||||
async snoopInstanceWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent, src: lib.Address) {
|
||||
if (src.type !== lib.Address.instance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this._controller.users.getOrCreateUser(event.name);
|
||||
user.set("isWhitelisted", event.whitelisted);
|
||||
|
||||
const instance = this._controller.instances.get(src.id)!;
|
||||
await this._controller.hooks.playerEvent.invoke(instance, {
|
||||
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
|
||||
name: event.name,
|
||||
stats: user.instanceStats.get(src.id)!,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
import { HostDetails } from "@clusterio/lib";
|
||||
|
||||
/** Underlying data class for hosts on the controller */
|
||||
export default class HostRecord {
|
||||
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.Optional(Type.Boolean()),
|
||||
"remote_address": Type.Optional(Type.String()),
|
||||
"public_address": Type.Optional(Type.String()),
|
||||
"token_valid_after": Type.Optional(Type.Number()),
|
||||
"updated_at_ms": Type.Optional(Type.Number()),
|
||||
"is_deleted": Type.Optional(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() {
|
||||
const json = {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
version: this.version,
|
||||
plugins: Object.fromEntries(this.plugins),
|
||||
} as Static<typeof HostRecord.jsonSchema>;
|
||||
|
||||
if (this.connected !== false) {
|
||||
json.connected = this.connected;
|
||||
}
|
||||
if (this.remoteAddress !== "") {
|
||||
json.remote_address = this.remoteAddress;
|
||||
}
|
||||
if (this.publicAddress !== "") {
|
||||
json.public_address = this.publicAddress;
|
||||
}
|
||||
if (this.tokenValidAfter !== 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updated_at_ms = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted !== false) {
|
||||
json.is_deleted = this.isDeleted;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
|
||||
return new HostRecord(
|
||||
details.id,
|
||||
details.name,
|
||||
details.version,
|
||||
plugins,
|
||||
details.connected,
|
||||
details.remoteAddress,
|
||||
details.publicAddress,
|
||||
details.tokenValidAfter,
|
||||
details.updatedAtMs,
|
||||
details.isDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
toHostDetails() {
|
||||
return new HostDetails(
|
||||
this.version,
|
||||
this.name,
|
||||
this.id,
|
||||
this.connected,
|
||||
this.remoteAddress,
|
||||
this.publicAddress,
|
||||
this.tokenValidAfter,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
const http = require("http");
|
||||
const https = require("https");
|
||||
const util = require("util");
|
||||
import type { Socket } from "net";
|
||||
|
||||
import http from "http";
|
||||
import https from "https";
|
||||
|
||||
/**
|
||||
* Gracefully close a Node.js HTTP(S) server
|
||||
@@ -18,36 +18,40 @@ const util = require("util");
|
||||
* // in the shutdown logic
|
||||
* await serverCloser.close();
|
||||
*
|
||||
* @alias module:master/src/HttpCloser
|
||||
* @alias module:controller/src/HttpCloser
|
||||
*/
|
||||
class HttpCloser {
|
||||
export default class HttpCloser {
|
||||
private _server: http.Server | https.Server;
|
||||
private _sockets: Set<Socket>;
|
||||
private _responses: Set<http.ServerResponse>;
|
||||
|
||||
/**
|
||||
* Server to attach this closer to.
|
||||
* @param {https.Server | http.Server} server - HTTP(S) to attach to
|
||||
* Create HttpCloser attached to a server.
|
||||
* @param server - Node.js HTTP(S) server to attach to.
|
||||
*/
|
||||
constructor(server) {
|
||||
constructor(server: http.Server | https.Server) {
|
||||
this._server = server;
|
||||
this._sockets = new Set();
|
||||
this._responses = new Set();
|
||||
|
||||
if (this._server instanceof http.Server) {
|
||||
this._server.on("connection", this._addSocket.bind(this));
|
||||
} else if (this._server instanceof https.Server) {
|
||||
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) {
|
||||
_addSocket(socket:Socket) {
|
||||
this._sockets.add(socket);
|
||||
socket.once("close", () => {
|
||||
this._sockets.delete(socket);
|
||||
});
|
||||
}
|
||||
|
||||
_addRequest(request, response) {
|
||||
_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));
|
||||
@@ -63,16 +67,16 @@ class HttpCloser {
|
||||
* If there's requests in progress that still haven't been completed
|
||||
* after the timeout value passed they will be aborted.
|
||||
*
|
||||
* @param {number} timeout -
|
||||
* @param timeoutMs -
|
||||
* Time in ms to wait for connections to close.
|
||||
*/
|
||||
async close(timeout = 5000) {
|
||||
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;
|
||||
let socket = response.socket!;
|
||||
response.once("finish", () => {
|
||||
socket.end();
|
||||
});
|
||||
@@ -93,7 +97,7 @@ class HttpCloser {
|
||||
// Node.js, see nodejs/node#27428.
|
||||
socket.destroy();
|
||||
}
|
||||
}, timeout);
|
||||
}, timeoutMs);
|
||||
this._server.close(err => {
|
||||
clearTimeout(timer);
|
||||
if (err) {
|
||||
@@ -105,5 +109,3 @@ class HttpCloser {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = HttpCloser;
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import type Controller from "./Controller.js";
|
||||
import type HostConnection from "./HostConnection.js";
|
||||
import InstanceRecord from "./InstanceRecord.js";
|
||||
|
||||
export default class InstanceManager {
|
||||
private static readonly DefaultFactorioSettings = {
|
||||
tags: ["clusterio"],
|
||||
max_players: 0,
|
||||
visibility: { public: true, lan: true },
|
||||
game_password: "",
|
||||
require_user_verification: true,
|
||||
max_upload_in_kilobytes_per_second: 0,
|
||||
max_upload_slots: 5,
|
||||
ignore_player_limit_for_returning_players: false,
|
||||
allow_commands: "admins-only",
|
||||
autosave_interval: 10,
|
||||
autosave_slots: 5,
|
||||
afk_autokick_interval: 0,
|
||||
auto_pause: false,
|
||||
only_admins_can_pause_the_game: true,
|
||||
autosave_only_on_server: true,
|
||||
non_blocking_saving: false,
|
||||
} as const;
|
||||
|
||||
constructor(
|
||||
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
|
||||
private readonly _controller: Controller,
|
||||
) {
|
||||
for (const instance of records.values()) {
|
||||
this.addInstanceHooks(instance);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: InstanceRecord["id"]): boolean {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
|
||||
return this.records.get(id);
|
||||
}
|
||||
|
||||
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
valuesMutable(): IterableIterator<InstanceRecord> {
|
||||
return this.records.values();
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<InstanceRecord>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance by ID for a request.
|
||||
*
|
||||
* Convenience wrapper around {@link getMutable} that throws
|
||||
* if the instance does not exist.
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns The instance record.
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
getForRequest(instanceId: number): InstanceRecord {
|
||||
const instance = this.records.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Internal Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
private async _notifyStatusChanged(
|
||||
instance: InstanceRecord,
|
||||
prev?: lib.InstanceStatus,
|
||||
): Promise<void> {
|
||||
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
|
||||
}
|
||||
|
||||
private async _notifyConfigFieldChanged(
|
||||
instance: InstanceRecord,
|
||||
field: string,
|
||||
curr: unknown,
|
||||
prev: unknown,
|
||||
): Promise<void> {
|
||||
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listeners to an instance record.
|
||||
*
|
||||
* Hooks into configuration field changes and ensures the datastore
|
||||
* is updated and plugins are notified when a config field changes.
|
||||
*
|
||||
* @param instance - The instance record to attach hooks to.
|
||||
* @internal
|
||||
*/
|
||||
addInstanceHooks(instance: InstanceRecord): void {
|
||||
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
|
||||
this.records.set(instance);
|
||||
this._notifyConfigFieldChanged(instance, field, curr, prev);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
*
|
||||
* @example
|
||||
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
|
||||
* const instance = await controller.instances.createInstance(config);
|
||||
*
|
||||
* @param instanceConfig - Config to base the newly created instance on.
|
||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||
* @returns The created instance record.
|
||||
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
|
||||
*/
|
||||
async createInstance(
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
_suppressChanges?: boolean,
|
||||
): Promise<InstanceRecord> {
|
||||
const instanceId = instanceConfig.get("instance.id");
|
||||
if (this.records.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
instanceConfig.set("factorio.settings", {
|
||||
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
|
||||
description: `Clusterio instance for ${controllerName}`,
|
||||
...InstanceManager.DefaultFactorioSettings,
|
||||
...instanceConfig.get("factorio.settings"),
|
||||
});
|
||||
|
||||
const instance = new InstanceRecord(instanceConfig, "unassigned");
|
||||
this.addInstanceHooks(instance);
|
||||
|
||||
if (!_suppressChanges) {
|
||||
this.records.set(instance);
|
||||
await this._notifyStatusChanged(instance);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the assigned host of an instance.
|
||||
*
|
||||
* Unassigns the instance from the currently assigned host (if connected)
|
||||
* and assigns it to the provided host.
|
||||
*
|
||||
* This is the only supported way of modifying the
|
||||
* `instance.assigned_host` configuration entry.
|
||||
*
|
||||
* Note:
|
||||
* - This does NOT transfer any files or saves between hosts.
|
||||
* - If the instance is already assigned to the specified host, no action is taken.
|
||||
*
|
||||
* @param instanceId - ID of instance to assign.
|
||||
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||
* the instance will be unassigned.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
||||
*/
|
||||
async assignInstance(
|
||||
instanceId: number,
|
||||
hostId: number | null,
|
||||
): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostConnections = this._controller.wsServer.hostConnections;
|
||||
|
||||
const currentAssignedHost = instance.config.get("instance.assigned_host");
|
||||
if (currentAssignedHost === hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newHostConnection: HostConnection | undefined;
|
||||
if (hostId !== null) {
|
||||
newHostConnection = hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAssignedHost !== null) {
|
||||
const oldHostConnection = hostConnections.get(currentAssignedHost);
|
||||
if (oldHostConnection && !oldHostConnection.connector.closing) {
|
||||
await oldHostConnection.send(
|
||||
new lib.InstanceUnassignInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
instance.config.set("instance.assigned_host", hostId);
|
||||
|
||||
if (newHostConnection) {
|
||||
try {
|
||||
await newHostConnection.send(
|
||||
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
|
||||
);
|
||||
} catch (err) {
|
||||
// Leave unassigned so the assign can be retried
|
||||
instance.config.set("instance.assigned_host", null);
|
||||
instance.status = "unassigned";
|
||||
this.records.set(instance);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
instance.status = "unassigned";
|
||||
}
|
||||
|
||||
this.records.set(instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign an instance from its current host.
|
||||
*
|
||||
* Convenience wrapper around {@link assignInstance} that removes
|
||||
* the assigned host.
|
||||
*
|
||||
* @param instanceId - ID of instance to unassign.
|
||||
*/
|
||||
async unassignInstance(instanceId: number): Promise<void> {
|
||||
return this.assignInstance(instanceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an instance permanently.
|
||||
*
|
||||
* Sends a delete request to the assigned host (if any),
|
||||
* removes the instance from the datastore,
|
||||
* clears associated saves and user statistics.
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
*/
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
const hostId = instance.config.get("instance.assigned_host");
|
||||
|
||||
if (hostId !== null) {
|
||||
await this._controller.sendTo(
|
||||
{ hostId },
|
||||
new lib.InstanceDeleteInternalRequest(instanceId),
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = instance.status;
|
||||
this.records.delete(instance);
|
||||
this._controller.clearSavesOfInstance(instanceId);
|
||||
this._controller.users.clearStatsOfInstance(instanceId);
|
||||
|
||||
await this._notifyStatusChanged(instance, prevStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { Static, Type } from "@sinclair/typebox";
|
||||
|
||||
/** Underlying data class for instances on the controller */
|
||||
export default class InstanceRecord {
|
||||
constructor(
|
||||
public config: lib.InstanceConfig,
|
||||
public status: lib.InstanceStatus,
|
||||
public gamePort?: number,
|
||||
public factorioVersion?: lib.TargetVersion,
|
||||
public startedAtMs = 0,
|
||||
public updatedAtMs = 0,
|
||||
) {
|
||||
this.config = config;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this.config.get("instance.id");
|
||||
}
|
||||
|
||||
get isDeleted() {
|
||||
return this.status === "deleted";
|
||||
}
|
||||
|
||||
set isDeleted(value: boolean) {
|
||||
if (value === false) {
|
||||
throw new Error("Setting isDeleted to false is not supported, use status instead.");
|
||||
}
|
||||
this.status = "deleted";
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"config": lib.InstanceConfig.jsonSchema,
|
||||
"status": lib.InstanceStatus,
|
||||
"gamePort": Type.Optional(Type.Number()),
|
||||
"startedAtMs": Type.Optional(Type.Number()),
|
||||
"updatedAtMs": Type.Optional(Type.Number()),
|
||||
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
lib.InstanceConfig.fromJSON(json.config, "controller"),
|
||||
json.status,
|
||||
json.gamePort,
|
||||
json.factorioVersion,
|
||||
json.startedAtMs,
|
||||
json.updatedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
|
||||
const json: any = {
|
||||
config: this.config.toJSON(),
|
||||
status: this.status,
|
||||
};
|
||||
|
||||
if (this.gamePort !== undefined) {
|
||||
json.gamePort = this.gamePort;
|
||||
}
|
||||
if (this.factorioVersion !== undefined) {
|
||||
json.factorioVersion = this.factorioVersion;
|
||||
}
|
||||
if (this.startedAtMs !== 0) {
|
||||
json.startedAtMs = this.startedAtMs;
|
||||
}
|
||||
if (this.updatedAtMs !== 0) {
|
||||
json.updatedAtMs = this.updatedAtMs;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
toInstanceDetails() {
|
||||
return new lib.InstanceDetails(
|
||||
this.config.get("instance.name"),
|
||||
this.id,
|
||||
this.config.get("instance.assigned_host") ?? undefined,
|
||||
this.gamePort,
|
||||
this.status,
|
||||
this.factorioVersion,
|
||||
this.startedAtMs,
|
||||
this.updatedAtMs,
|
||||
this.config.get("instance.exclude_from_start_all"),
|
||||
);
|
||||
}
|
||||
|
||||
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
|
||||
return new InstanceRecord(
|
||||
config,
|
||||
details.status,
|
||||
details.gamePort,
|
||||
details.factorioVersion,
|
||||
details.startedAtMs,
|
||||
details.updatedAtMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
|
||||
import {
|
||||
IUser, Role, SubscribableDatastore,
|
||||
PermissionError, PermissionName, permissions,
|
||||
} from "@clusterio/lib";
|
||||
|
||||
import UserRecord from "./UserRecord.js";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
|
||||
/**
|
||||
* Represents a user as viewed from the controller.
|
||||
*
|
||||
* All methods are safe to use when readonly.
|
||||
* Only use mutable when needing to change multiple values.
|
||||
*/
|
||||
export default class User extends UserRecord implements IUser {
|
||||
constructor(
|
||||
private _controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: SubscribableDatastore<Role>,
|
||||
...args: ConstructorParameters<typeof UserRecord>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static fromJSON(
|
||||
json: Static<typeof this.jsonSchema>,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
) {
|
||||
return this.fromUserRecord(
|
||||
UserRecord.fromJSON(json),
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
static fromUserRecord(
|
||||
userRecord: UserRecord,
|
||||
_controllerUsers: SubscribableDatastore<UserRecord>,
|
||||
_controllerRoles: SubscribableDatastore<Role>,
|
||||
): User {
|
||||
return new this(
|
||||
_controllerUsers,
|
||||
_controllerRoles,
|
||||
userRecord.tokenValidAfter,
|
||||
userRecord.name,
|
||||
userRecord.roleIds,
|
||||
userRecord.instances,
|
||||
userRecord.isAdmin,
|
||||
userRecord.isBanned,
|
||||
userRecord.isWhitelisted,
|
||||
userRecord.banReason,
|
||||
userRecord.updatedAtMs,
|
||||
userRecord.isDeleted,
|
||||
userRecord.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/** Roles this user has */
|
||||
get roles(): ReadonlySet<Readonly<Role>> {
|
||||
return new Set([...this.roleIds].map(
|
||||
id => this._controllerRoles.get(id)
|
||||
).filter(
|
||||
(r): r is Role => Boolean(r)
|
||||
));
|
||||
}
|
||||
|
||||
/** Save the underlying user record */
|
||||
saveRecord() {
|
||||
this._controllerUsers.set(this);
|
||||
}
|
||||
|
||||
/** Set a property on the underlying record, then save it. */
|
||||
set<K extends keyof this>(key: K, value: this[K]) {
|
||||
this[key] = value;
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Add a role to this user */
|
||||
addRole(roleId: number) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
throw new Error(`role ${roleId} does not exist`);
|
||||
}
|
||||
this.roleIds.add(roleId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Remove a role from user */
|
||||
removeRole(roleId: number) {
|
||||
if (!this.roleIds.delete(roleId)) {
|
||||
return false;
|
||||
}
|
||||
this.saveRecord();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Set a user as being online on a specific instance */
|
||||
notifyJoin(instanceId: number) {
|
||||
this.instances.add(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Set a user as being offline on a specific instance */
|
||||
notifyLeave(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
/** Clear the stats of a player on a specific instance */
|
||||
clearInstanceStats(instanceId: number) {
|
||||
this.instances.delete(instanceId);
|
||||
this.instanceStats.delete(instanceId);
|
||||
this.recalculatePlayerStats();
|
||||
this.saveRecord();
|
||||
}
|
||||
|
||||
checkPermission(permission: PermissionName) {
|
||||
if (!permissions.has(permission)) {
|
||||
throw new Error(`permission ${permission} does not exist`);
|
||||
}
|
||||
|
||||
for (const roleId of this.roleIds) {
|
||||
const role = this._controllerRoles.get(roleId);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new PermissionError("Permission denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import fs from "node:fs/promises";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import User from "./User.js";
|
||||
import UserRecord from "./UserRecord.js";
|
||||
|
||||
/**
|
||||
* Access layer for users on the controller.
|
||||
*
|
||||
* In performance critical paths the raw records can be accessed directly.
|
||||
* Otherwise this class will wrap data in a view class with a set and save method.
|
||||
*
|
||||
* @alias module:controller/src/UserManager
|
||||
*/
|
||||
export default class UserManager {
|
||||
constructor(
|
||||
public records: lib.SubscribableDatastore<UserRecord>,
|
||||
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
|
||||
private _controllerConfig: lib.ControllerConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
|
||||
* This migration separates them so they can be loaded by datastore.
|
||||
*
|
||||
* @param usersFilePath The src and dst file path for the users json file
|
||||
* @param rolesFilePath The dst for the roles json file
|
||||
*/
|
||||
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
|
||||
try {
|
||||
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
|
||||
if (!("roles" in combinedJson)) {
|
||||
return; // Files are already split, no action required
|
||||
}
|
||||
if (!("users" in combinedJson)) {
|
||||
throw new Error("Legacy users json does not contain users property");
|
||||
}
|
||||
|
||||
// We intentionally write the roles first because if it fails then the old user file will still contain them
|
||||
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
|
||||
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
|
||||
} catch (err: any) {
|
||||
if (err.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Basic Accessors */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
has(id: UserRecord["id"]) {
|
||||
return this.records.has(id);
|
||||
}
|
||||
|
||||
getMutable(id: UserRecord["id"]) {
|
||||
const userRecord = this.records.getMutable(id);
|
||||
if (!userRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return User.fromUserRecord(
|
||||
userRecord,
|
||||
this.records,
|
||||
this._controllerRoles,
|
||||
);
|
||||
}
|
||||
|
||||
get(id: UserRecord["id"]): Readonly<User> | undefined {
|
||||
return this.getMutable(id);
|
||||
}
|
||||
|
||||
hasName(name: UserRecord["name"]) {
|
||||
return this.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByNameMutable(name: UserRecord["name"]) {
|
||||
return this.getMutable(name.toLowerCase());
|
||||
}
|
||||
|
||||
getByName(name: UserRecord["name"]) {
|
||||
return this.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
* valuesMutable(): IterableIterator<User> {
|
||||
const roles = this._controllerRoles;
|
||||
const users = this.records;
|
||||
|
||||
for (const record of users.values()) {
|
||||
yield User.fromUserRecord(record, users, roles);
|
||||
}
|
||||
}
|
||||
|
||||
values(): IterableIterator<Readonly<User>> {
|
||||
return this.valuesMutable();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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): User {
|
||||
if (this.getByName(name)) {
|
||||
throw new Error(`User '${name}' already exists`);
|
||||
}
|
||||
|
||||
let roles = new Set<number>();
|
||||
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
|
||||
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
|
||||
roles.add(defaultRoleId);
|
||||
}
|
||||
|
||||
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
|
||||
const tokenValidAfter = Math.floor(Date.now() / 1000);
|
||||
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
|
||||
this.records.set(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Get or create a user with a given name. */
|
||||
getOrCreateUser(name: string): User {
|
||||
return this.getByNameMutable(name) ?? this.createUser(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an existing user
|
||||
*
|
||||
* @param user - user to delete
|
||||
*/
|
||||
deleteUser(user: UserRecord) {
|
||||
return this.records.delete(user);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Sign access token for the given user
|
||||
*
|
||||
* @param user - user to sign token for
|
||||
* @returns JWT access token for the user.
|
||||
*/
|
||||
signUserToken(user: UserRecord): string {
|
||||
return jwt.sign(
|
||||
{ aud: "user", user: user.id },
|
||||
// auth secret is never null after startup
|
||||
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear all player stats for specific instance */
|
||||
clearStatsOfInstance(instanceId: number) {
|
||||
for (const user of this.values()) {
|
||||
user.clearInstanceStats(instanceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { UserDetails } from "@clusterio/lib";
|
||||
import { Type, Static } from "@sinclair/typebox";
|
||||
|
||||
/** Underlying data class for users on the controller */
|
||||
export default class UserRecord extends UserDetails {
|
||||
constructor(
|
||||
/** Unix time in seconds the user token must be issued after to be valid. */
|
||||
public tokenValidAfter = 0,
|
||||
...args: ConstructorParameters<typeof UserDetails>
|
||||
) {
|
||||
super(...args);
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
token_valid_after: Type.Optional(Type.Number()),
|
||||
...UserDetails.jsonSchema.properties,
|
||||
});
|
||||
|
||||
// _a and _b are a ts workaround for allowing User to have extra arguments
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
|
||||
return this.fromUserDetails(
|
||||
UserDetails.fromJSON(json),
|
||||
json.token_valid_after,
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
|
||||
|
||||
if (this.tokenValidAfter > 0) {
|
||||
json.token_valid_after = this.tokenValidAfter;
|
||||
}
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromUserDetails(
|
||||
userDetails: UserDetails,
|
||||
tokenValidAfter = 0,
|
||||
) {
|
||||
return new this(
|
||||
tokenValidAfter,
|
||||
userDetails.name,
|
||||
userDetails.roleIds,
|
||||
userDetails.instances,
|
||||
userDetails.isAdmin,
|
||||
userDetails.isBanned,
|
||||
userDetails.isWhitelisted,
|
||||
userDetails.banReason,
|
||||
userDetails.updatedAtMs,
|
||||
userDetails.isDeleted,
|
||||
userDetails.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
toUserDetails() {
|
||||
return new UserDetails(
|
||||
this.name,
|
||||
this.roleIds,
|
||||
this.instances,
|
||||
this.isAdmin,
|
||||
this.isBanned,
|
||||
this.isWhitelisted,
|
||||
this.banReason,
|
||||
this.updatedAtMs,
|
||||
this.isDeleted,
|
||||
this.instanceStats,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge another user into this one, must have matching ids, user is not deleted
|
||||
* @param otherUser - User who's details are merged from
|
||||
*/
|
||||
merge(otherUser: UserDetails) {
|
||||
if (this.id !== otherUser.id) {
|
||||
throw new Error("Cannot merge users with different ids");
|
||||
}
|
||||
|
||||
// Merge properties
|
||||
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
|
||||
this.instances = new Set([...this.instances, ...otherUser.instances]);
|
||||
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
|
||||
this.isBanned = this.isBanned || otherUser.isBanned;
|
||||
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
|
||||
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
|
||||
this.updatedAtMs = Date.now();
|
||||
|
||||
// Merge instance stats
|
||||
const thisInstanceStats = this.instanceStats;
|
||||
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
|
||||
if (thisInstanceStats.has(instanceId)) {
|
||||
thisInstanceStats.get(instanceId)!.merge(instanceStats);
|
||||
} else {
|
||||
thisInstanceStats.set(instanceId, instanceStats);
|
||||
}
|
||||
}
|
||||
|
||||
// Player stats must be recalculated after modifying instance stats
|
||||
this.recalculatePlayerStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import type { Duplex } from "stream";
|
||||
import type Controller from "./Controller.js";
|
||||
|
||||
import jwt from "jsonwebtoken";
|
||||
import WebSocket, { WebSocketServer } from "ws";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import ControlConnection from "./ControlConnection.js";
|
||||
import HostConnection from "./HostConnection.js";
|
||||
import WsServerConnector from "./WsServerConnector.js";
|
||||
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "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: WebSocketServer;
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controller = controller;
|
||||
|
||||
this.wss = new WebSocketServer({
|
||||
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(lib.ConnectionClosed.GoingAway, "Server Quit"));
|
||||
}
|
||||
|
||||
for (let hostConnection of this.hostConnections.values()) {
|
||||
disconnectTasks.push(hostConnection.disconnect(lib.ConnectionClosed.GoingAway, "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(lib.ConnectionClosed.GoingAway, "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)}`);
|
||||
|
||||
// ws emits errors like invalid frames on the socket, and an error with
|
||||
// no listener crashes the controller. The connector adds its own
|
||||
// listener, but only once the handshake has completed.
|
||||
socket.on("error", (err: Error) => {
|
||||
logger.verbose(`WsServer | error on connection from ${this.remoteAddr(req)}: ${err.message}`);
|
||||
});
|
||||
|
||||
// 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 (const plugin of this.controller.loadedPlugins) {
|
||||
loadedPlugins[plugin.name] = plugin.version;
|
||||
}
|
||||
|
||||
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, 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(lib.ConnectionClosed.InternalError, "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(lib.ConnectionClosed.ProtocolError, `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(lib.ConnectionClosed.ProtocolError, "Bad handshake");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopAcceptingNewSessions) {
|
||||
logger.verbose(`WsServer | closing ${this.remoteAddr(req)}, server is shutting down`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(lib.ConnectionClosed.GoingAway, "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 }, this.controller.authSecret);
|
||||
let sessionTimeout = this.controller.config.get("controller.session_timeout");
|
||||
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
|
||||
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
|
||||
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
|
||||
this.activeConnectors.set(sessionId, connector);
|
||||
connector.on("close", () => {
|
||||
this.activeConnectors.delete(sessionId);
|
||||
});
|
||||
|
||||
return [connector, sessionToken];
|
||||
}
|
||||
|
||||
async registerHost(data: lib.RegisterHostData, socket: WebSocket, req: IncomingMessage) {
|
||||
if (this.controller.recoveryMode) {
|
||||
logger.warn(`Recovery | rejected host connection attempt for ${this.remoteAddr(req)}`);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(lib.ConnectionClosed.RecoveryMode, "Connection Refused: Controller in recovery mode");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// migrate: allow pre-rename tokens issued to hosts before alpha-14
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { 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(lib.ConnectionClosed.Unauthorized, `Authentication failed: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let connection = this.hostConnections.get(data.id);
|
||||
if (connection) {
|
||||
if (connection.remoteAddress !== this.remoteAddr(req)) {
|
||||
logger.verbose(
|
||||
`WsServer | disallowed duplicate connection from ${this.remoteAddr(req)} for host ${data.id}`
|
||||
);
|
||||
wsRejectedConnectionsCounter.inc();
|
||||
socket.close(lib.ConnectionClosed.PolicyViolation, "Host already connected from another address");
|
||||
return;
|
||||
}
|
||||
logger.verbose(
|
||||
`WsServer | disconnecting duplicate connection from host ${data.id}`
|
||||
);
|
||||
await connection.disconnect(
|
||||
lib.ConnectionClosed.PolicyViolation, "Newer connection for host from same address"
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`WsServer | registered host ${data.id} using version ${data.version}`
|
||||
);
|
||||
if (data.version !== packageConfig.version) {
|
||||
logger.warn(
|
||||
`Host ${data.id} connected using version ${data.version} which does not match the ` +
|
||||
`version of the controller is currently running (${packageConfig.version}). ` +
|
||||
"It may not work as expected."
|
||||
);
|
||||
}
|
||||
|
||||
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
|
||||
|
||||
// Handle close before HostConnection does. This avoids a host
|
||||
// connection event happening between the connection breaking and
|
||||
// it being removed from the host connections list.
|
||||
connector.on("close", () => {
|
||||
if (this.hostConnections.get(data.id) === connection) {
|
||||
this.hostConnections.delete(data.id);
|
||||
} else {
|
||||
logger.warn("Unlisted HostConnection closed");
|
||||
}
|
||||
});
|
||||
|
||||
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
|
||||
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 {
|
||||
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
|
||||
user = this.controller.users.getByNameMutable(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(lib.ConnectionClosed.Unauthorized, `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)}`);
|
||||
connector.on("close", () => {
|
||||
this.controlConnections.delete(id);
|
||||
});
|
||||
let connection = new ControlConnection(data, connector, this.controller, user, 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 {
|
||||
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { 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,230 @@
|
||||
import { type WebSocket } from "ws";
|
||||
|
||||
import events from "events";
|
||||
import assert from "assert/strict";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/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,
|
||||
maxSendBufferSize = 0,
|
||||
) {
|
||||
super(new lib.Address(lib.Address.controller, 0), dst);
|
||||
this.maxSendBufferSize = maxSendBufferSize;
|
||||
|
||||
// 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,
|
||||
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,
|
||||
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);
|
||||
logger.verbose(
|
||||
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
|
||||
);
|
||||
for (let message of this._sendBuffer) {
|
||||
this._socket.send(message.text);
|
||||
}
|
||||
this.emit("resume");
|
||||
}
|
||||
|
||||
_timedOut() {
|
||||
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
|
||||
this._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._close();
|
||||
|
||||
} else {
|
||||
this._state = "resuming";
|
||||
this.emit("drop");
|
||||
this._timeoutId = setTimeout(() => { this._timedOut(); }, this._sessionTimeout * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
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._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._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,732 @@
|
||||
import type { Application, Request, Response } from "express";
|
||||
import Controller from "./Controller.js";
|
||||
|
||||
import busboy from "busboy";
|
||||
import crypto from "crypto";
|
||||
import events from "events";
|
||||
import fs from "node:fs/promises";
|
||||
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";
|
||||
import { logger } from "@clusterio/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,
|
||||
staticRoot: string | null,
|
||||
devPlugins: Map<string, number>,
|
||||
streams: Map<string, ProxyStream>,
|
||||
pendingMetrics?: Promise<string>,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect metrics from the controller, its plugins and all connected hosts
|
||||
async function gatherMetrics(controller: Controller) {
|
||||
let results: lib.CollectorResult[] = [];
|
||||
let pluginResults = await controller.hooks.metrics.collect();
|
||||
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));
|
||||
}
|
||||
|
||||
return await lib.exposition(results);
|
||||
}
|
||||
|
||||
// Prometheus polling endpoint
|
||||
async function getMetrics(req: Request, res: Response) {
|
||||
try {
|
||||
res.locals.user.checkPermission("core.controller.metrics");
|
||||
} catch (err: any) {
|
||||
res.status(403).json({ request_errors: [err.message] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Concurrent scrapes share one gather so hosts see at most one
|
||||
// metrics request at a time regardless of how many clients poll.
|
||||
const locals = req.app.locals;
|
||||
if (!locals.pendingMetrics) {
|
||||
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
|
||||
locals.pendingMetrics = undefined;
|
||||
});
|
||||
}
|
||||
const text = await locals.pendingMetrics;
|
||||
res.set("Content-Type", lib.expositionContentType);
|
||||
res.send(text);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes the static/ prefix webpack adds to bundle paths so they can be
|
||||
* resolved relative to staticRoot.
|
||||
*/
|
||||
export function stripStaticPrefix(bundlePath: string) {
|
||||
return bundlePath.replace(/^static\//, "");
|
||||
}
|
||||
|
||||
function getPlugins(req: Request, res: Response) {
|
||||
let plugins: lib.PluginWebApi[] = [];
|
||||
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
|
||||
let name = pluginInfo.name;
|
||||
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
|
||||
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
|
||||
// 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][0];
|
||||
} 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 if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
if (web.main) {
|
||||
web.main = stripStaticPrefix(web.main);
|
||||
}
|
||||
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
|
||||
}
|
||||
res.send(plugins);
|
||||
}
|
||||
|
||||
function getClusterName(req: Request, res: Response) {
|
||||
const controller: Controller = req.app.locals.controller;
|
||||
const clusterName = controller.config.get("controller.name");
|
||||
res.json({ name: clusterName });
|
||||
}
|
||||
|
||||
// Reads the access token from X-Access-Token or Authorization: Bearer
|
||||
function getAccessToken(req: Request) {
|
||||
const token = req.header("x-access-token");
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
const authorization = req.header("authorization");
|
||||
if (authorization && /^Bearer /i.test(authorization)) {
|
||||
return authorization.slice("Bearer ".length).trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateHostToken(req: Request, res: Response, next: any) {
|
||||
let token = getAccessToken(req);
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { 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 = getAccessToken(req);
|
||||
if (!token) {
|
||||
res.sendStatus(401);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
|
||||
if (typeof tokenPayload === "string") {
|
||||
throw new Error("unexpected JsonWebToken type");
|
||||
}
|
||||
let user = req.app.locals.controller.users.getByName(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.getMutable(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/locale.json",
|
||||
"export/defines.json",
|
||||
"export/spritesheet.png",
|
||||
"export/metadata.json",
|
||||
];
|
||||
|
||||
let assets: any = {};
|
||||
let settingPrototypes: lib.ExportSettings = {};
|
||||
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, new Date().toISOString());
|
||||
modPack.fillDefaultSettings(settingPrototypes, logger);
|
||||
res.app.locals.controller.modPacks.set(modPack);
|
||||
|
||||
res.sendStatus(200);
|
||||
}
|
||||
|
||||
type ProxyStreamEvents = {
|
||||
"error": [ err: any ],
|
||||
"close": [],
|
||||
"timeout": [],
|
||||
"source": [],
|
||||
}
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
source: nodeStream.Readable | null;
|
||||
flowing: boolean;
|
||||
size?: string;
|
||||
mime?: string;
|
||||
filename: string | null;
|
||||
events: events.EventEmitter<ProxyStreamEvents>;
|
||||
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<{ id: string }>, 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<{ id: string }>, 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");
|
||||
const filePath = path.join(modsDirectory, filename);
|
||||
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
|
||||
|
||||
try {
|
||||
stream.pipe(writeStream);
|
||||
await finished(writeStream);
|
||||
|
||||
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
|
||||
checkModName(modInfo.filename);
|
||||
await fs.rename(tempFilePath, 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.
|
||||
writeStream.destroy();
|
||||
|
||||
try {
|
||||
await fs.unlink(tempFilePath);
|
||||
} catch (unlinkErr: any) {
|
||||
if (unlinkErr.code !== "ENOENT") {
|
||||
logger.error(`Error removing ${tempFilePath}: ${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",
|
||||
validateUserToken,
|
||||
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
|
||||
);
|
||||
app.get("/api/plugins", getPlugins);
|
||||
app.get("/api/cluster-name", getClusterName);
|
||||
app.put("/api/upload-export",
|
||||
validateHostToken,
|
||||
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
|
||||
);
|
||||
app.put("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
|
||||
);
|
||||
app.get("/api/stream/:id",
|
||||
(req:Request<{ id: string }>, 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",
|
||||
"/mods/mod-packs/:id/icon-reference",
|
||||
"/users",
|
||||
"/users/:id/view",
|
||||
"/roles",
|
||||
"/roles/:id/view",
|
||||
"/plugins",
|
||||
"/plugins/:name/view",
|
||||
"/error",
|
||||
];
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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": "..",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./tsconfig.browser.json" },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"references": [
|
||||
{ "path": "../lib/tsconfig.node.json" },
|
||||
],
|
||||
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
|
||||
}
|
||||
@@ -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).href;
|
||||
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)
|
||||
;
|
||||
File renamed without changes.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -5,26 +5,26 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
contentBase: "./dist/web",
|
||||
},
|
||||
output: {
|
||||
publicPath: "auto",
|
||||
filename: "bundle.js",
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "master",
|
||||
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 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
+517
-206
@@ -1,26 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
"use strict";
|
||||
const child_process = require("child_process");
|
||||
const events = require("events");
|
||||
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");
|
||||
import child_process from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import inquirer from "inquirer";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import stream from "node:stream";
|
||||
import util from "node:util";
|
||||
import yargs from "yargs";
|
||||
|
||||
const { levels, logger, setLogLevel } = require("./logging");
|
||||
import { levels, logger, setLogLevel } from "./logging.js";
|
||||
import { copyPluginTemplates } from "./template.js";
|
||||
let dev = false;
|
||||
const scriptExt = process.platform === "win32" ? ".cmd" : "";
|
||||
import { escapeArg } from "./escape_arg.js";
|
||||
|
||||
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
|
||||
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
|
||||
// One option is to not call rl.close: SBoudrias/Inquirer.js#767
|
||||
// But this comes with its own issues: SBoudrias/Inquirer.js#899
|
||||
// The solution below was adapted from aws-amplify/amplify-cli#12347
|
||||
if (process.platform === "win32") {
|
||||
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
|
||||
inquirer.ui.Prompt.prototype.close = function() {
|
||||
this.rl.terminal = false;
|
||||
_inquirer_close.call(this);
|
||||
};
|
||||
}
|
||||
|
||||
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: "Player Sync", value: "@clusterio/plugin-player_sync" },
|
||||
{ name: "Research Sync", value: "@clusterio/plugin-research_sync" },
|
||||
{ name: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
|
||||
{ name: "Subspace Storage", value: "@clusterio/plugin-subspace_storage" },
|
||||
@@ -84,18 +95,46 @@ class LineSplitter extends stream.Transform {
|
||||
|
||||
class InstallError extends Error { }
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function safeOutputFile(file, data, options={}) {
|
||||
let { dir, name, ext } = path.parse(file);
|
||||
let temporary = path.join(dir, `${name}.tmp${ext}`);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(temporary, data, { ...options, flush: true });
|
||||
await fs.rename(temporary, file);
|
||||
}
|
||||
|
||||
async function execFile(cmd, args) {
|
||||
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
|
||||
const asyncExec = util.promisify(child_process.execFile);
|
||||
const command = [cmd, ...args.map(escapeArg)].join(" ");
|
||||
logger.verbose(`executing ${command}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
let child = child_process.execFile(
|
||||
command,
|
||||
{
|
||||
shell: true,
|
||||
|
||||
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
|
||||
},
|
||||
(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);
|
||||
@@ -105,21 +144,28 @@ async function execFile(cmd, args) {
|
||||
});
|
||||
}
|
||||
|
||||
async function execMaster(args) {
|
||||
async function execController(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "master"), ...args]);
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriomaster${scriptExt}`), args);
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
|
||||
}
|
||||
|
||||
async function execSlave(args) {
|
||||
async function execHost(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join("packages", "slave"), ...args]);
|
||||
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
|
||||
}
|
||||
return await execFile(path.join("node_modules", ".bin", `clusterioslave${scriptExt}`), args);
|
||||
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
|
||||
}
|
||||
|
||||
function validateSlaveToken(token) {
|
||||
async function execCtl(args) {
|
||||
if (dev) {
|
||||
return await execFile("node", [path.join(import.meta.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");
|
||||
@@ -132,7 +178,7 @@ function validateSlaveToken(token) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
|
||||
if (parsed.aud !== "slave" || !Number.isInteger(parsed.slave)) {
|
||||
if (parsed.aud !== "host" || !Number.isInteger(parsed.host)) {
|
||||
throw new InstallError("Invalid token");
|
||||
}
|
||||
}
|
||||
@@ -156,6 +202,166 @@ async function validateInstallDir() {
|
||||
}
|
||||
}
|
||||
|
||||
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 pathExists(source) && !await pathExists(destination)) {
|
||||
await safeOutputFile(destination, JSON.stringify(
|
||||
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
|
||||
), { mode: 0o600 });
|
||||
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 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 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 = (await fs.open(inputFile)).createReadStream();
|
||||
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 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 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 pathExists(path.join("logs", "cluster"))
|
||||
&& !await 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 pathExists("sharedMods")
|
||||
&& !await 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 pathExists("run-master.sh") || await pathExists("run-master.cmd");
|
||||
const hasRunSlave = await pathExists("run-slave.sh") || await 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.writeFile("package.json", JSON.stringify({
|
||||
@@ -169,11 +375,11 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
|
||||
let components = [];
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
components.push("@clusterio/master");
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
components.push("@clusterio/controller");
|
||||
}
|
||||
if (["standalone", "slave"].includes(mode)) {
|
||||
components.push("@clusterio/slave");
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
components.push("@clusterio/host");
|
||||
}
|
||||
if (mode === "ctl") {
|
||||
components.push("@clusterio/ctl");
|
||||
@@ -200,79 +406,133 @@ async function installClusterio(mode, plugins) {
|
||||
}
|
||||
for (let plugin of plugins) {
|
||||
if (!pluginList.has(plugin)) {
|
||||
// eslint-disable-next-line node/global-require
|
||||
let pluginInfo = require(require.resolve(path.posix.join(plugin, "info"), { paths: [process.cwd()] }));
|
||||
|
||||
let pluginInfo = (await import(plugin)).plugin;
|
||||
pluginList.set(pluginInfo.name, plugin);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.writeFile("plugin-list.json", JSON.stringify([...pluginList], null, 4));
|
||||
await safeOutputFile("plugin-list.json", JSON.stringify([...pluginList], null, "\t"));
|
||||
} catch (err) {
|
||||
throw new InstallError(`Error writing plugin-list.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeScripts(mode) {
|
||||
if (["standalone", "master"].includes(mode)) {
|
||||
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, selfSignedCert) {
|
||||
if (["standalone", "controller"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
"run-master.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusteriomaster.cmd run\n"
|
||||
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 fs.writeFile(
|
||||
"run-master.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusteriomaster run\n",
|
||||
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 fs.outputFile(
|
||||
"systemd/clusteriomaster.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Master
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriocontroller.service",
|
||||
`\
|
||||
[Unit]
|
||||
Description=Clusterio Controller
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=nogroup
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusteriomaster run --log-level=warn
|
||||
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", "slave"].includes(mode)) {
|
||||
if (["standalone", "host"].includes(mode)) {
|
||||
if (process.platform === "win32") {
|
||||
await fs.writeFile(
|
||||
"run-slave.cmd",
|
||||
"@echo off\n.\\node_modules\\.bin\\clusterioslave.cmd run\n"
|
||||
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await safeOutputFile(
|
||||
"run-host.cmd",
|
||||
`\
|
||||
@echo off
|
||||
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
|
||||
${extraCaCertsLine}: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 fs.writeFile(
|
||||
"run-slave.sh",
|
||||
"#!/bin/sh\nexec ./node_modules/.bin/clusterioslave run\n",
|
||||
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
|
||||
await safeOutputFile(
|
||||
"run-host.sh",
|
||||
`\
|
||||
#!/bin/bash
|
||||
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
|
||||
${extraCaCertsLine}while true; do
|
||||
./node_modules/.bin/clusteriohost run --can-restart
|
||||
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
await fs.outputFile(
|
||||
"systemd/clusterioslave.service",
|
||||
`[Unit]
|
||||
Description=Clusterio Slave
|
||||
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
|
||||
await safeOutputFile(
|
||||
"systemd/clusteriohost.service",
|
||||
`\
|
||||
[Unit]
|
||||
Description=Clusterio Host
|
||||
|
||||
[Service]
|
||||
User=${os.userInfo().username}
|
||||
Group=nogroup
|
||||
Group=${await groupIdToName(os.userInfo().gid)}
|
||||
WorkingDirectory=${process.cwd()}
|
||||
KillMode=mixed
|
||||
KillSignal=SIGINT
|
||||
ExecStart=${process.cwd()}/node_modules/.bin/clusterioslave run --log-level=warn
|
||||
Environment=NODE_OPTIONS=--enable-source-maps
|
||||
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=8
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`);
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +541,7 @@ WantedBy=multi-user.target
|
||||
async function inquirerMissingArgs(args) {
|
||||
let answers = {};
|
||||
if (args.mode) { answers.mode = args.mode; }
|
||||
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "list",
|
||||
@@ -288,16 +549,17 @@ async function inquirerMissingArgs(args) {
|
||||
message: "Operating mode to install",
|
||||
default: "standalone",
|
||||
choices: [
|
||||
{ name: "Standalone (install both master and slave on this computer)", value: "standalone" },
|
||||
{ name: "Master only", value: "master" },
|
||||
{ name: "Slave only", value: "slave" },
|
||||
{ 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" },
|
||||
{ name: "Plugin template", value: "plugin-template" },
|
||||
],
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (["standalone", "master"].includes(answers.mode)) {
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
if (args.admin) { answers.admin = args.admin; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -312,51 +574,70 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
if (args.slaveName) { answers.slaveName = args.slaveName; }
|
||||
if (args.httpPort) { answers.httpPort = args.httpPort; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "slaveName",
|
||||
message: "Name of slave",
|
||||
name: "httpPort",
|
||||
message: "HTTP port to listen on",
|
||||
default: 8080,
|
||||
validate: input => {
|
||||
if (!input) {
|
||||
return "May not be empty";
|
||||
}
|
||||
if (!Number.isInteger(Number(input))) {
|
||||
return "Must be a number";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (["slave", "ctl"].includes(answers.mode)) {
|
||||
if (args.masterUrl) { answers.masterUrl = args.masterUrl; }
|
||||
if (answers.mode === "host") {
|
||||
if (args.hostName) { answers.hostName = args.hostName; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "masterUrl",
|
||||
message: "Master server URL",
|
||||
},
|
||||
], answers);
|
||||
|
||||
if (args.masterToken) { answers.masterToken = args.masterToken; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "input",
|
||||
name: "masterToken",
|
||||
message: "Master authentication Token",
|
||||
name: "hostName",
|
||||
message: "Name of host",
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
validateSlaveToken(answers.masterToken);
|
||||
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 (["standalone", "slave"].includes(answers.mode)) {
|
||||
let myIp;
|
||||
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();
|
||||
let result = await fetch("https://api.ipify.org/");
|
||||
myIp = await result.text();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
@@ -372,26 +653,10 @@ async function inquirerMissingArgs(args) {
|
||||
let locations = factorioLocations[process.platform] || [];
|
||||
let foundLocations = [];
|
||||
for (let location of locations) {
|
||||
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
|
||||
if (await 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.downloadHeadless) {
|
||||
answers.factorioDir = "factorio";
|
||||
}
|
||||
}
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
@@ -416,11 +681,60 @@ async function inquirerMissingArgs(args) {
|
||||
},
|
||||
], answers);
|
||||
} else if (answers.factorioDir === "factorio") {
|
||||
await fs.ensureDir("factorio");
|
||||
await fs.mkdir("factorio", { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (dev) { answers.plugins = []; }
|
||||
if (["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "confirm",
|
||||
name: "remoteNpm",
|
||||
message: "Allow remote updates via npm?",
|
||||
default: true,
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (answers.mode === "plugin-template") {
|
||||
if (args["plugin-template"] && args["plugin-template"].length > 0) {
|
||||
answers.pluginTemplate = args["plugin-template"];
|
||||
}
|
||||
if (args["plugin-name"]) {
|
||||
answers.pluginName = args["plugin-name"];
|
||||
}
|
||||
answers.plugins = [];
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
name: "pluginTemplate",
|
||||
message: "Plugin templates to use",
|
||||
choices: [
|
||||
{ name: "Controller", value: "controller" },
|
||||
{ name: "Host", value: "host" },
|
||||
{ name: "Instance", value: "instance" },
|
||||
{ name: "Lua Module", value: "module" },
|
||||
{ name: "Command Line", value: "ctl" },
|
||||
{ name: "Web UI", value: "web" },
|
||||
{ name: "No Typescript", value: "js" },
|
||||
{ name: "No Config", value: "no_config" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "pluginName",
|
||||
message: "Name of your new plugin",
|
||||
default: path.basename(path.resolve()),
|
||||
},
|
||||
], answers);
|
||||
}
|
||||
|
||||
if (dev) {
|
||||
answers.plugins = [];
|
||||
} else if (args.plugins) {
|
||||
answers.plugins = args.plugins;
|
||||
}
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: "checkbox",
|
||||
@@ -434,55 +748,8 @@ async function inquirerMissingArgs(args) {
|
||||
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 events.once(res, "end");
|
||||
|
||||
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
|
||||
let yarg = yargs(process.argv.slice(2))
|
||||
.option("log-level", {
|
||||
nargs: 1, describe: "Log level to print to stdout", default: "info",
|
||||
choices: ["none"].concat(Object.keys(levels)), type: "string",
|
||||
@@ -492,96 +759,140 @@ async function main() {
|
||||
})
|
||||
.option("mode", {
|
||||
nargs: 1, describe: "Operating mode to install",
|
||||
choices: ["standalone", "master", "slave", "ctl", "plugins"],
|
||||
choices: ["standalone", "controller", "host", "ctl", "plugins", "plugin-template"],
|
||||
})
|
||||
.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/master]", type: "string",
|
||||
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
|
||||
})
|
||||
.option("slave-name", {
|
||||
nargs: 1, describe: "Slave name [slave]", type: "string",
|
||||
.option("http-port", {
|
||||
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
|
||||
})
|
||||
.option("master-url", {
|
||||
nargs: 1, describe: "Master URL [slave/ctl]", type: "string",
|
||||
.option("host-name", {
|
||||
nargs: 1, describe: "Host name [host]", type: "string",
|
||||
})
|
||||
.option("master-token", {
|
||||
nargs: 1, describe: "Master authentication token [slave/ctl]", 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/slave]", type: "string",
|
||||
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/slave]", type: "string",
|
||||
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
|
||||
})
|
||||
.option("self-signed-cert", {
|
||||
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
|
||||
})
|
||||
.option("remote-npm", {
|
||||
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
|
||||
})
|
||||
.option("plugins", {
|
||||
array: true, describe: "Plugins to install", type: "string",
|
||||
});
|
||||
})
|
||||
.option("plugin-template", {
|
||||
array: true, describe: "Plugin template to use [plugin-template]", type: "string",
|
||||
})
|
||||
.option("plugin-name", {
|
||||
nargs: 1, describe: "Name of your new plugin [plugin-template]", type: "string",
|
||||
})
|
||||
.strict()
|
||||
;
|
||||
|
||||
if (process.platform === "linux") {
|
||||
args = args
|
||||
.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",
|
||||
yarg = yarg
|
||||
.option("allow-install-as-root", {
|
||||
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
|
||||
})
|
||||
.conflicts("factorio-dir", "download-headless");
|
||||
;
|
||||
}
|
||||
|
||||
args = args.argv;
|
||||
const args = yarg.parse();
|
||||
|
||||
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) {
|
||||
await downloadLinuxServer();
|
||||
}
|
||||
|
||||
logger.verbose(JSON.stringify(answers));
|
||||
|
||||
if (answers.pluginTemplate) {
|
||||
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dev) {
|
||||
await installClusterio(answers.mode, answers.plugins);
|
||||
}
|
||||
|
||||
let adminToken = null;
|
||||
if (["standalone", "master"].includes(answers.mode)) {
|
||||
logger.info("Setting up master");
|
||||
await execMaster(["bootstrap", "create-admin", answers.admin]);
|
||||
let result = await execMaster(["bootstrap", "generate-user-token", answers.admin]);
|
||||
if (["standalone", "controller"].includes(answers.mode)) {
|
||||
logger.info("Setting up controller");
|
||||
await execController(["config", "create"]);
|
||||
await execController(["bootstrap", "create-admin", answers.admin]);
|
||||
await execController(["config", "set", "controller.http_port", answers.httpPort]);
|
||||
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
|
||||
await execController(["config", "set", "controller.allow_plugin_updates", answers.remoteNpm]);
|
||||
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 slave");
|
||||
await execSlave(["config", "set", "slave.name", "local"]);
|
||||
logger.info("Setting up host");
|
||||
await execHost(["config", "create"]);
|
||||
await execHost(["config", "set", "host.name", "local"]);
|
||||
|
||||
let result = await execSlave(["config", "show", "slave.id"]);
|
||||
let slaveId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
||||
let result = await execHost(["config", "show", "host.id"]);
|
||||
let hostId = Number.parseInt(result.stdout.split("\n").slice(-2)[0], 10);
|
||||
|
||||
result = await execMaster(["bootstrap", "generate-slave-token", slaveId]);
|
||||
let slaveToken = result.stdout.split("\n").slice(-2)[0];
|
||||
result = await execController(["bootstrap", "generate-host-token", hostId]);
|
||||
let hostToken = result.stdout.split("\n").slice(-2)[0];
|
||||
|
||||
await execSlave(["config", "set", "slave.master_token", slaveToken]);
|
||||
await execSlave(["config", "set", "slave.factorio_directory", answers.factorioDir]);
|
||||
// Default to localhost on correct port for host in standalone mode
|
||||
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
|
||||
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]);
|
||||
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
||||
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
||||
}
|
||||
|
||||
if (answers.mode === "slave") {
|
||||
logger.info("Setting up slave");
|
||||
let slaveId = JSON.parse(Buffer.from(answers.masterToken.split(".")[1], "base64")).slave;
|
||||
await execSlave(["config", "set", "slave.id", slaveId]);
|
||||
await execSlave(["config", "set", "slave.name", answers.slaveName]);
|
||||
await execSlave(["config", "set", "slave.master_url", answers.masterUrl]);
|
||||
await execSlave(["config", "set", "slave.master_token", answers.masterToken]);
|
||||
await execSlave(["config", "set", "slave.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", "create"]);
|
||||
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]);
|
||||
await execHost(["config", "set", "host.allow_remote_updates", answers.remoteNpm]);
|
||||
await execHost(["config", "set", "host.allow_plugin_updates", answers.remoteNpm]);
|
||||
}
|
||||
|
||||
if (!dev && ["standalone", "master", "slave"].includes(answers.mode)) {
|
||||
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
|
||||
logger.info("Writing run scripts");
|
||||
await writeScripts(answers.mode);
|
||||
await writeScripts(answers.mode, args.selfSignedCert);
|
||||
}
|
||||
|
||||
if (answers.mode === "ctl") {
|
||||
await execCtl(["config", "create"]);
|
||||
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
|
||||
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
@@ -592,7 +903,7 @@ async function main() {
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
if (module === require.main) {
|
||||
if (import.meta.main) {
|
||||
main().catch(err => {
|
||||
if (err instanceof InstallError) {
|
||||
logger.error(err.message);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
export function escapeArg(arg) {
|
||||
// When shell: true is used execFile does not escaping of arguments, they are
|
||||
// simply joined by space and then passed to the shell.
|
||||
|
||||
// For Windows the command is cmd.exe /d /s /c "command arg arg ..."
|
||||
// The parsing and escaping on windows is cursed. The following references are not for mortals:
|
||||
// https://daviddeley.com/autohotkey/parameters/parameters.htm#WIN
|
||||
// https://stackoverflow.com/a/4095133
|
||||
// https://ss64.com/nt/syntax-esc.html
|
||||
if (process.platform === "win32") {
|
||||
if (!/[\t "%&<>\\^|]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
|
||||
const escaped = [];
|
||||
let pos = 0;
|
||||
while (pos < arg.length) {
|
||||
if (arg[pos] === "\\") {
|
||||
// If a backslash is followed by a quote or the end of the string, then
|
||||
// double the number of backslashes because why not!
|
||||
let peek = pos + 1;
|
||||
while (peek !== arg.length && arg[peek] === "\\") {
|
||||
peek += 1;
|
||||
}
|
||||
if (peek === arg.length || arg[peek] === '"') {
|
||||
const count = peek - pos;
|
||||
escaped.push("\\".repeat(count * 2));
|
||||
pos = peek;
|
||||
continue;
|
||||
} else {
|
||||
escaped.push("\\");
|
||||
}
|
||||
} else if (arg[pos] === '"') {
|
||||
escaped.push('""');
|
||||
} else if (arg[pos] === "%") {
|
||||
// Expect a pct environment variable to contain %, because cmd does variable substitutions
|
||||
// before parsing string quotes, and there's no mechanism for escaping % symbols, and this
|
||||
// is a half sane workaround. The less sane workaround is to replace % with %%cd:~,%.
|
||||
escaped.push("%pct%");
|
||||
} else {
|
||||
escaped.push(arg[pos]);
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
return `"${escaped.join("")}"`;
|
||||
}
|
||||
|
||||
// For all other platforms the command is /bin/sh -c <command arg arg ...>
|
||||
// The command and its arguments are passed as a single argument to the shell.
|
||||
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
|
||||
|
||||
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
"use strict";
|
||||
const chalk = require("chalk");
|
||||
import chalk from "chalk";
|
||||
|
||||
const levels = Object.freeze({
|
||||
export const levels = Object.freeze({
|
||||
fatal: 0,
|
||||
error: 1,
|
||||
warn: 2,
|
||||
@@ -11,7 +10,7 @@ const levels = Object.freeze({
|
||||
verbose: 6,
|
||||
});
|
||||
|
||||
const colors = {
|
||||
export const colors = {
|
||||
fatal: "inverse red",
|
||||
error: "bold redBright",
|
||||
warn: "bold yellowBright",
|
||||
@@ -21,7 +20,7 @@ const colors = {
|
||||
};
|
||||
|
||||
let maxLogLevel = levels.info;
|
||||
const logger = {};
|
||||
export const logger = {};
|
||||
for (let [name, level] of Object.entries(levels)) {
|
||||
// eslint-disable-next-line no-loop-func
|
||||
logger[name] = message => {
|
||||
@@ -42,10 +41,6 @@ for (let [name, level] of Object.entries(levels)) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
colors,
|
||||
levels,
|
||||
setLogLevel: (level) => { maxLogLevel = level; },
|
||||
logger,
|
||||
};
|
||||
|
||||
export function setLogLevel(level) {
|
||||
maxLogLevel = level;
|
||||
}
|
||||
@@ -1,28 +1,39 @@
|
||||
{
|
||||
"name": "@clusterio/create",
|
||||
"description": "Installer for clusterio",
|
||||
"author": "Hornwitser",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0-alpha.9",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"keywords": [
|
||||
"factorio",
|
||||
"clusterio"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"inquirer": "^8.1.0",
|
||||
"phin": "^3.5.1",
|
||||
"yargs": "^14.2.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
"name": "@clusterio/create",
|
||||
"version": "2.0.0",
|
||||
"description": "Installer for clusterio",
|
||||
"homepage": "https://github.com/clusterio/clusterio#readme",
|
||||
"author": "Hornwitser <github@hornwitser.no>",
|
||||
"license": "MIT",
|
||||
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"factorio"
|
||||
],
|
||||
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/clusterio/clusterio"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/clusterio/clusterio/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
"type": "module",
|
||||
"main": "create.js",
|
||||
"bin": {
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"chalk": "catalog:",
|
||||
"inquirer": "^8.2.4",
|
||||
"yargs": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { logger } from "./logging.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import _package from "./package.json" with { type: "json" };
|
||||
|
||||
async function copyTemplateFile(src, dst, properties) {
|
||||
logger.verbose(`Writing ${dst} from template ${src}`);
|
||||
|
||||
// Closure, get the property value or throw
|
||||
function getProperty(property) {
|
||||
if (!properties.hasOwnProperty(property)) {
|
||||
throw new Error(`Unknown property (${property}) found in template file ${src}`);
|
||||
}
|
||||
|
||||
return properties[property];
|
||||
}
|
||||
|
||||
// Closure, Get the boolean conjunction of a set of properties
|
||||
function propertyConjunction(props) {
|
||||
for (const property of props) {
|
||||
if (property.startsWith("!")) {
|
||||
if (Boolean(getProperty(property.slice(1)))) {
|
||||
return false;
|
||||
}
|
||||
} else if (!Boolean(getProperty(property))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process the the template file
|
||||
const badChars = /[\(\)-+/*<>=]/;
|
||||
const templateData = await fs.readFile(src, "utf8");
|
||||
const outputData = templateData
|
||||
// match preprocessor comments: //%//
|
||||
.replace(/^\/\/%\/\/.*\n/gm, "")
|
||||
// match preprocessor insert: __property__
|
||||
.replace(/__([a-zA-Z][a-zA-Z0-9_]*?[a-zA-Z0-9]?)__/gm, (_, property) => getProperty(property))
|
||||
// match preprocessor conditionals: //%if <CONDITION> [// COMMENT]\n <CONTENT> \n//%endif [COMMENT]\n
|
||||
.replace(/^\/\/%if ([^\n\/]*)(?:\/\/.*?)?\n((?:.|\n)*?)\/\/%endif.*\n/gm, (_, condition, content) => {
|
||||
let include = false;
|
||||
const conjunctions = condition.split("|");
|
||||
for (const conjunct of conjunctions) {
|
||||
if (badChars.test(conjunct)) {
|
||||
throw new Error("Preprocessor condition can only contain logical operators" +
|
||||
"such as & | or ! it can not have arithmetic, comparisons or parenthesis"
|
||||
);
|
||||
}
|
||||
|
||||
const property_names = conjunct.split("&").map(s => s.trim());
|
||||
if (propertyConjunction(property_names)) {
|
||||
include = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return include ? content : "";
|
||||
});
|
||||
|
||||
// Create folder for output file
|
||||
const { dir } = path.parse(dst);
|
||||
if (dir) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Attempt to write the output file, warn if it already exists
|
||||
try {
|
||||
await fs.writeFile(dst, outputData, { flag: "wx" });
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") {
|
||||
logger.warn(`Could not create file ${dst} because it already exists`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyPluginTemplates(pluginName, templates) {
|
||||
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
|
||||
const files = new Map();
|
||||
const prepare = [];
|
||||
|
||||
// Some basic flags for selecting files to include
|
||||
const javascriptOnly = templates.includes("js");
|
||||
const config = !templates.includes("no_config") && templates.some(value => (
|
||||
["controller", "host", "instance", "ctl"].includes(value)
|
||||
));
|
||||
const webpack = config || templates.includes("controller") || templates.includes("web");
|
||||
|
||||
// A count of the number of isolated contexts the plugin runs under
|
||||
let pluginContexts = 0;
|
||||
|
||||
// Get the file extension and path to the templates
|
||||
const ext = javascriptOnly ? "js" : "ts";
|
||||
const templatePath = path.resolve(
|
||||
import.meta.dirname,
|
||||
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
|
||||
);
|
||||
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
|
||||
|
||||
// Files included in all templates
|
||||
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
|
||||
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
|
||||
files.set("package.json", path.join(commonPath, "package.json"));
|
||||
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
|
||||
|
||||
// Files and dependencies to support typescript
|
||||
if (!javascriptOnly) {
|
||||
prepare.push("tsc --build");
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
}
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
pluginContexts += 1;
|
||||
} else {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/no_plugin.${ext}x`));
|
||||
}
|
||||
}
|
||||
|
||||
// Files for the controller
|
||||
if (templates.includes("controller")) {
|
||||
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for hosts
|
||||
if (templates.includes("host")) {
|
||||
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for instances
|
||||
if (templates.includes("instance")) {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// Files for lua modules
|
||||
if (templates.includes("module")) {
|
||||
files.set("module/module.json", path.join(commonPath, "module/module.json"));
|
||||
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
|
||||
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
|
||||
if (templates.includes("instance")) {
|
||||
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
|
||||
} else {
|
||||
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Files for the control tool
|
||||
if (templates.includes("ctl")) {
|
||||
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
|
||||
pluginContexts += 1;
|
||||
}
|
||||
|
||||
// If there are more than one contexts then include the message file
|
||||
if (pluginContexts > 1) {
|
||||
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
|
||||
}
|
||||
|
||||
// Properties that will control the replacements in the templates
|
||||
const properties = {
|
||||
// Weather this is ts or js
|
||||
typescript: !javascriptOnly,
|
||||
ext: ext,
|
||||
// Which templates where requested
|
||||
multi_context: pluginContexts > 1,
|
||||
controller: templates.includes("controller"),
|
||||
host: templates.includes("host"),
|
||||
instance: templates.includes("instance"),
|
||||
module: templates.includes("module"),
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
node_version: _package.engines.node,
|
||||
prepare: prepare.join(" && "),
|
||||
plugin_name: pluginName,
|
||||
};
|
||||
|
||||
// Write all the template files
|
||||
const writes = [];
|
||||
for (let [dst, src] of files) {
|
||||
writes.push(copyTemplateFile(src, dst, properties));
|
||||
}
|
||||
|
||||
// Wait for writes to complete
|
||||
await Promise.all(writes);
|
||||
logger.info("Successfully wrote all template files");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
--[[
|
||||
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
|
||||
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
|
||||
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
|
||||
Most of your code will be within this one file, but you can create more and require them
|
||||
|
||||
clusterio_api exposes functions to enable communication with your InstancePlugin
|
||||
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
|
||||
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
|
||||
]]
|
||||
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local compat = require("modules/clusterio/compat")
|
||||
|
||||
--- Top level module table, contains event handlers and public methods
|
||||
--- @class __plugin_name__
|
||||
local MyModule = {
|
||||
}
|
||||
|
||||
--[[
|
||||
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
|
||||
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
|
||||
There are edge cases in the following events: pre player left, player left, console command, and config changed
|
||||
If you use any of these events and access `script_data` then make sure to call this function first
|
||||
]]
|
||||
local script_data = {}
|
||||
local function setup_script_data()
|
||||
if compat.script_data["__plugin_name__"] == nil then
|
||||
compat.script_data["__plugin_name__"] = {
|
||||
-- starting values go here
|
||||
}
|
||||
end
|
||||
MyModule.on_load()
|
||||
end
|
||||
|
||||
--- The on_load function is called independently for each client when they first load the map
|
||||
-- It should be used to restore global aliases and metatables not registered with script.register_metatable
|
||||
function MyModule.on_load()
|
||||
script_data = compat.script_data["__plugin_name__"]
|
||||
end
|
||||
|
||||
--- Public methods should be available though your top level module table
|
||||
function MyModule.foo()
|
||||
game.print("foo: " .. (script_data.counter or 0))
|
||||
end
|
||||
|
||||
--- Private methods should be local to the file, this will prevent others from calling it
|
||||
local function bar()
|
||||
script_data.counter = (script_data.counter or 0) + 1
|
||||
game.print("bar: " .. script_data.counter)
|
||||
end
|
||||
|
||||
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
|
||||
--- @param event EventData.on_server_startup
|
||||
local function on_server_startup(event)
|
||||
setup_script_data()
|
||||
game.print(compat.table_to_json(event))
|
||||
end
|
||||
|
||||
--- Triggered every time a player crafts an item, see below for how handlers are registered
|
||||
--- @param event EventData.on_player_crafted_item
|
||||
local function on_player_crafted_item(event)
|
||||
game.print(compat.table_to_json(event))
|
||||
//%if instance
|
||||
local player = assert(game.get_player(event.player_index))
|
||||
clusterio_api.send_json("__plugin_name__-plugin_example_ipc", {
|
||||
tick = game.tick, player_name = player.name
|
||||
})
|
||||
//%endif
|
||||
end
|
||||
|
||||
--- Run bar every 5 seconds, see below for how handlers are registered
|
||||
local function on_nth_tick_300()
|
||||
game.print(game.tick)
|
||||
bar()
|
||||
end
|
||||
|
||||
--- Factorio events are accessible through defines.events, you can have one handler per event per file
|
||||
local e = defines.events
|
||||
local events = {
|
||||
[clusterio_api.events.on_server_startup] = on_server_startup,
|
||||
[e.on_player_crafted_item] = on_player_crafted_item,
|
||||
}
|
||||
|
||||
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
|
||||
local on_nth_tick = {
|
||||
[300] = on_nth_tick_300,
|
||||
}
|
||||
|
||||
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
|
||||
MyModule.events = events --- @package
|
||||
MyModule.on_nth_tick = on_nth_tick --- @package
|
||||
return MyModule
|
||||
@@ -0,0 +1,9 @@
|
||||
--[[
|
||||
It is best practice to not expose any globals because all modules share a global environment
|
||||
However, sometimes you need globals, for example to access functions within rcon commands
|
||||
Therefore, we advise that this should be the only file in your module to expose globals
|
||||
Typically this would be your control file as shown in the example below
|
||||
]]
|
||||
|
||||
-- Access using `/sc __plugin_name__.foo()`
|
||||
__plugin_name__ = require("modules/__plugin_name__/control")
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "__plugin_name__",
|
||||
"load": [
|
||||
"control.lua"
|
||||
],
|
||||
"require": [
|
||||
//%if instance
|
||||
"globals.lua"
|
||||
//%endif
|
||||
],
|
||||
"dependencies": {
|
||||
"clusterio": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
--[[
|
||||
It is typical for a module to have a control file as the entry point, although this is not a requirement
|
||||
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
|
||||
You are not required to specify any default export, but note that a module can still require yours directly
|
||||
]]
|
||||
|
||||
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
|
||||
return require("modules/__plugin_name__/control")
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "__plugin_name__",
|
||||
"version": "0.1.0",
|
||||
"description": "Example Description. Package. Change me in package.json",
|
||||
"type": "module",
|
||||
//%if typescript
|
||||
"main": "dist/node/index.js",
|
||||
//%endif
|
||||
//%if !typescript
|
||||
"main": "index.js",
|
||||
//%endif
|
||||
"scripts": {
|
||||
"prepare": "__prepare__"
|
||||
},
|
||||
"engines": {
|
||||
"node": "__node_version__"
|
||||
},
|
||||
"peerDependencies": {
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
//%if controller | host | ctl
|
||||
"peerDependenciesMeta": {
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller & host | controller & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if host & ctl
|
||||
},
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": {
|
||||
"optional": true
|
||||
//%endif
|
||||
//%if controller | host | ctl
|
||||
}
|
||||
},
|
||||
//%endif
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.13.4",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
"@types/react": "^18.3.31",
|
||||
//%endif
|
||||
//%// Everything else
|
||||
//%if web
|
||||
"antd": "^5.29.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"webpack": "^5.110.3",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-merge": "^5.9.0",
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
"@clusterio/host": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if ctl
|
||||
"@clusterio/ctl": "^__clusterio_version__",
|
||||
//%endif
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.6"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"keywords": [
|
||||
"clusterio",
|
||||
"clusterio-plugin",
|
||||
"factorio"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -0,0 +1,3 @@
|
||||
webpack.config.js
|
||||
dist/browser
|
||||
dist/web
|
||||
+8
-9
@@ -5,30 +5,29 @@ const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}) => merge(common(env), {
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.jsx",
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
publicPath: "auto",
|
||||
filename: "bundle.js",
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "inventory_sync",
|
||||
library: { type: "var", name: "plugin_inventory_sync" },
|
||||
filename: "remoteEntry.js",
|
||||
name: "__plugin_name__",
|
||||
library: { type: "window", name: "plugin___plugin_name__" },
|
||||
exposes: {
|
||||
"./info": "./info.js",
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.jsx",
|
||||
"./web": "./web/index.__ext__x",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
//%if web
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
//%endif
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export default async function loadControllerPlugin(context) {
|
||||
const { controller, logger, plugin } = context;
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
controller.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
controller.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
name: "__plugin_name__", description: "The default description I forgot to change",
|
||||
});
|
||||
|
||||
pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args, ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export default async function loadCtlPlugin(context) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(pluginCommands);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export default async function loadHostPlugin(context) {
|
||||
const { host, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
host.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
host.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
//%endif
|
||||
|
||||
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`host::hostConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
host.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("host::shutdown");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import * as Messages from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export const plugin = {
|
||||
name: "__plugin_name__",
|
||||
title: "__plugin_name__",
|
||||
description: "Example Description. Plugin. Change me in index.js",
|
||||
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if controller
|
||||
controllerEntrypoint: "./dist/node/controller.js",
|
||||
//%endif
|
||||
//%if controller & config
|
||||
controllerConfigFields: {
|
||||
"__plugin_name__.myControllerField": {
|
||||
title: "My Controller Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if host & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if host
|
||||
hostEntrypoint: "./dist/node/host.js",
|
||||
//%endif
|
||||
//%if host & config
|
||||
hostConfigFields: {
|
||||
"__plugin_name__.myHostField": {
|
||||
title: "My Host Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if instance & config | module & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if instance | module // Modules load an empty instance plugin
|
||||
instanceEntrypoint: "./dist/node/instance.js",
|
||||
//%endif
|
||||
//%if instance & config
|
||||
instanceConfigFields: {
|
||||
"__plugin_name__.myInstanceField": {
|
||||
title: "My Instance Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if ctl & config // Blank line for formatting
|
||||
|
||||
//%endif
|
||||
//%if ctl
|
||||
ctlEntrypoint: "./dist/node/ctl.js",
|
||||
//%endif
|
||||
//%if ctl & config
|
||||
ctlConfigFields: {
|
||||
"__plugin_name__.myCtlField": {
|
||||
title: "My Ctl Field",
|
||||
description: "This should be removed from index.js",
|
||||
type: "string",
|
||||
initialValue: "Remove Me",
|
||||
},
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
|
||||
messages: [
|
||||
Messages.PluginExampleEvent,
|
||||
Messages.PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
Messages.ExampleSubscribableUpdate,
|
||||
//%endif
|
||||
//%if multi_context // Subscribing requires multi context
|
||||
],
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
|
||||
permissions: [
|
||||
//%endif
|
||||
//%if multi_context
|
||||
{
|
||||
name: "__plugin_name__.example.permission.event",
|
||||
title: "Example permission event",
|
||||
description: "Example Description. Event. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
{
|
||||
name: "__plugin_name__.example.permission.request",
|
||||
title: "Example permission request",
|
||||
description: "Example Description. Request. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
{
|
||||
name: "__plugin_name__.example.permission.subscribe",
|
||||
title: "Example permission subscribe",
|
||||
description: "Example Description. Subscribe. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if web
|
||||
{
|
||||
name: "__plugin_name__.page.view",
|
||||
title: "Example page view permission",
|
||||
description: "Example Description. View. Change me in index.js",
|
||||
grantByDefault: true,
|
||||
},
|
||||
//%endif
|
||||
//%if multi_context | web
|
||||
],
|
||||
//%endif
|
||||
//%if web // Web content template has an example route which is the plugin name
|
||||
|
||||
webEntrypoint: "./web",
|
||||
routes: [
|
||||
"/__plugin_name__",
|
||||
],
|
||||
//%endif
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if multi_context
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export default async function loadInstancePlugin(context) {
|
||||
const { instance, logger, plugin } = context;
|
||||
//%if multi_context
|
||||
|
||||
instance.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
instance.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
//%endif
|
||||
//%if module
|
||||
|
||||
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
//%endif
|
||||
|
||||
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`instance::instanceConfigFieldChanged ${field}`);
|
||||
});
|
||||
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
// Called once rcon becomes available
|
||||
logger.info("instance::start");
|
||||
});
|
||||
|
||||
instance.hooks.stop.attach(plugin.name, async () => {
|
||||
// Called during normal exits before rcon becomes unavailable
|
||||
logger.info("instance::stop");
|
||||
});
|
||||
|
||||
instance.hooks.exit.attach(plugin.name, () => {
|
||||
// Called during all exits, including crashes and init failures, rcon is not available
|
||||
logger.info("instance::exit");
|
||||
});
|
||||
|
||||
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
|
||||
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
|
||||
//%if module
|
||||
if (instance.status === "running") {
|
||||
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
|
||||
}
|
||||
//%endif
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
|
||||
// This requirement may change in the future to allow for standalone modules
|
||||
export default async function loadInstancePlugin(context) { }
|
||||
@@ -0,0 +1,91 @@
|
||||
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export class PluginExampleEvent {
|
||||
static type = "event";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.event";
|
||||
|
||||
constructor(myString, myNumberArray) {
|
||||
this.myString = myString;
|
||||
this.myNumberArray = myNumberArray;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new PluginExampleEvent(json.myString, json.myNumberArray);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginExampleRequest {
|
||||
static type = "request";
|
||||
static src = ["host", "control"];
|
||||
static dst = ["controller", "host", "instance"];
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.request";
|
||||
|
||||
constructor(myString, myNumberArray) {
|
||||
this.myString = myString;
|
||||
this.myNumberArray = myNumberArray;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"myString": Type.String(),
|
||||
"myNumberArray": Type.Array(Type.Number()),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new PluginExampleRequest(json.myString, json.myNumberArray);
|
||||
}
|
||||
|
||||
static Response = plainJson(Type.Object({
|
||||
"myResponseString": Type.String(),
|
||||
"myResponseNumbers": Type.Array(Type.Number()),
|
||||
}));
|
||||
}
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
export class ExampleSubscribableValue {
|
||||
constructor(id, updatedAtMs, isDeleted) {
|
||||
this.id = id;
|
||||
this.updatedAtMs = updatedAtMs;
|
||||
this.isDeleted = isDeleted;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
updatedAtMs: Type.Number(),
|
||||
isDeleted: Type.Boolean(),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.id, json.updatedAtMs, json.isDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExampleSubscribableUpdate {
|
||||
static type = "event";
|
||||
static src = "controller";
|
||||
static dst = "control";
|
||||
static plugin = "__plugin_name__";
|
||||
static permission = "__plugin_name__.example.permission.subscribe";
|
||||
|
||||
constructor(updates) {
|
||||
this.updates = updates;
|
||||
}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
"updates": Type.Array(ExampleSubscribableValue.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json) {
|
||||
return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
|
||||
}
|
||||
}
|
||||
//%endif
|
||||
File renamed without changes.
@@ -0,0 +1,89 @@
|
||||
import React, {
|
||||
useContext, useEffect, useState,
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
useCallback, useSyncExternalStore,
|
||||
//%endif
|
||||
} from "react";
|
||||
|
||||
// import {
|
||||
//
|
||||
// } from "antd";
|
||||
|
||||
import {
|
||||
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
|
||||
} from "@clusterio/web_ui";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "../messages.js";
|
||||
//%endif
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
|
||||
// Created when the plugin is loaded, before any page is rendered
|
||||
let exampleSubscriber;
|
||||
|
||||
function useSubscribableData() {
|
||||
const control = useContext(ControlContext);
|
||||
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
|
||||
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
|
||||
}
|
||||
//%endif
|
||||
|
||||
function MyTemplatePage() {
|
||||
let control = useContext(ControlContext);
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
const [subscribableData, synced] = useSubscribableData();
|
||||
//%endif
|
||||
|
||||
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
|
||||
<PageHeader title="__plugin_name__" />
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
|
||||
//%endif
|
||||
</PageLayout>;
|
||||
}
|
||||
|
||||
export default async function loadWebPlugin(context) {
|
||||
const { control, logger, plugin } = context;
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
|
||||
//%endif
|
||||
|
||||
control.hooks.pages.attach(plugin.name, () => [
|
||||
{
|
||||
path: "/__plugin_name__",
|
||||
sidebarName: "__plugin_name__",
|
||||
// This permission is client side only, so it must match the permission string of a resource request to be secure
|
||||
// An undefined value means that the page will always be visible
|
||||
//%if controller // Subscribing requires web content and the controller
|
||||
permission: "__plugin_name__.example.permission.subscribe",
|
||||
//%endif
|
||||
//%if !controller
|
||||
permission: "__plugin_name__.page.view",
|
||||
//%endif
|
||||
content: <MyTemplatePage/>,
|
||||
},
|
||||
]);
|
||||
//%if multi_context
|
||||
|
||||
control.handle(PluginExampleEvent, async (event) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
control.handle(PluginExampleRequest, async (request) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
//%endif
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import * as lib from "@clusterio/lib";
|
||||
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
import {
|
||||
PluginExampleEvent, PluginExampleRequest,
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
ExampleSubscribableUpdate, ExampleSubscribableValue,
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
} from "./messages.js";
|
||||
//%endif
|
||||
|
||||
export default async function loadControllerPlugin(context: ControllerPluginContext) {
|
||||
const { controller, logger, plugin } = context;
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
// If needed, replace with loading from database file such as lib.Datastore
|
||||
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
|
||||
//%endif
|
||||
//%if multi_context // Messages requires multi context
|
||||
|
||||
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
|
||||
logger.info(JSON.stringify(event));
|
||||
});
|
||||
|
||||
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
return {
|
||||
myResponseString: request.myString,
|
||||
myResponseNumbers: request.myNumberArray,
|
||||
};
|
||||
});
|
||||
//%endif
|
||||
//%if controller & web // Subscribing requires web content and the controller
|
||||
|
||||
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
|
||||
logger.info(JSON.stringify(request));
|
||||
const values = [...exampleDatabase.values()].filter(
|
||||
value => value.updatedAtMs > request.lastRequestTimeMs,
|
||||
);
|
||||
return values.length ? new ExampleSubscribableUpdate(values) : null;
|
||||
});
|
||||
//%endif
|
||||
|
||||
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
|
||||
logger.info(`controller::controllerConfigFieldChanged ${field}`);
|
||||
});
|
||||
//%if instance
|
||||
|
||||
controller.hooks.instanceConfigFieldChanged.attach(
|
||||
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
|
||||
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
|
||||
}
|
||||
);
|
||||
//%endif
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
logger.info("controller::save");
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
logger.info("controller::shutdown");
|
||||
});
|
||||
|
||||
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
|
||||
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
|
||||
import { CommandTree, Command } from "@clusterio/lib";
|
||||
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
|
||||
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
|
||||
/* eslint-disable no-console */
|
||||
|
||||
const pluginCommands = new CommandTree({
|
||||
name: "__plugin_name__", description: "The default description I forgot to change",
|
||||
});
|
||||
|
||||
pluginCommands.add(new Command({
|
||||
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
|
||||
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
|
||||
}],
|
||||
handler: async function(args: { foo: string }, ctl: Ctl) {
|
||||
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
|
||||
console.log(response);
|
||||
console.log(args);
|
||||
},
|
||||
}));
|
||||
|
||||
/* eslint-enable no-console */
|
||||
export default async function loadCtlPlugin(context: CtlPluginContext) {
|
||||
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
|
||||
rootCommand.add(pluginCommands);
|
||||
});
|
||||
}
|
||||
Loaded 100 of 722 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user