Compare commits
1437 Commits
vault/rele
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e9bb1bbff | ||
|
|
d0a0395337 | ||
|
|
88589e4cb3 | ||
|
|
af74986e66 | ||
|
|
e664bf0e19 | ||
|
|
152c89972b | ||
|
|
25c69c6e9b | ||
|
|
a6370808ae | ||
|
|
6bfd2360d8 | ||
|
|
872d1e03f6 | ||
|
|
a5cacd712d | ||
|
|
f596c862b3 | ||
|
|
5395dee4c6 | ||
|
|
217fe870ef | ||
|
|
26334e9088 | ||
|
|
cc45af2873 | ||
|
|
37bd8c06b5 | ||
|
|
c821a1c59f | ||
|
|
f5d0b3d849 | ||
|
|
0dfd27f08c | ||
|
|
0dfa0266c7 | ||
|
|
9b807ca314 | ||
|
|
de5d84ade4 | ||
|
|
4d539a691d | ||
|
|
ee8e73d7f9 | ||
|
|
32c6bedb98 | ||
|
|
cd9bb18ba1 | ||
|
|
f365035563 | ||
|
|
d9673e33ec | ||
|
|
491df61fbf | ||
|
|
ca1d9dc6a2 | ||
|
|
16b5554f66 | ||
|
|
fcdd6b4510 | ||
|
|
04048c3818 | ||
|
|
1afbc621a4 | ||
|
|
ef807950f1 | ||
|
|
d37f3aa504 | ||
|
|
39b3eb3d64 | ||
|
|
8b21dfc318 | ||
|
|
f87fec6d61 | ||
|
|
391eb22d98 | ||
|
|
0da42c01b6 | ||
|
|
f3f0ca8e21 | ||
|
|
96dc79e253 | ||
|
|
ac3416c5a2 | ||
|
|
ade5b2a3db | ||
|
|
1cd6017df6 | ||
|
|
06caea7b16 | ||
|
|
114779d3af | ||
|
|
96d694b66b | ||
|
|
babb4ffb37 | ||
|
|
0c2f07988a | ||
|
|
d7a1d34be7 | ||
|
|
6a465637d4 | ||
|
|
154c19403a | ||
|
|
c9e1467244 | ||
|
|
1765e41fd4 | ||
|
|
d34ee82754 | ||
|
|
5cdd0023d7 | ||
|
|
df7a4b5d6f | ||
|
|
63eb96859d | ||
|
|
e3e2a3b782 | ||
|
|
eeafa5e0a5 | ||
|
|
7e5e71ae67 | ||
|
|
8daf0bb2a9 | ||
|
|
a779c839b7 | ||
|
|
0da57f8747 | ||
|
|
d01d241af1 | ||
|
|
dd08d09d14 | ||
|
|
0143393a8c | ||
|
|
d2b56efcb4 | ||
|
|
dab0cf48c6 | ||
|
|
916367dccb | ||
|
|
580a9fdfcf | ||
|
|
2ba8b582e2 | ||
|
|
bc81eb7a22 | ||
|
|
a54fc041b0 | ||
|
|
10a2b25527 | ||
|
|
cf476953d6 | ||
|
|
b233453cf7 | ||
|
|
bc5136a197 | ||
|
|
e08ee08fd8 | ||
|
|
eb5ee3bfdb | ||
|
|
86a84c3c6a | ||
|
|
edb348c273 | ||
|
|
ba91b41d36 | ||
|
|
99547044bc | ||
|
|
1fa756372e | ||
|
|
484af3c2c8 | ||
|
|
742551e592 | ||
|
|
50081cbdaa | ||
|
|
61198a0c04 | ||
|
|
67590aa27d | ||
|
|
6c059c41f9 | ||
|
|
f1db82934d | ||
|
|
28dd6b767f | ||
|
|
98b1d7f585 | ||
|
|
f7b8e3d84b | ||
|
|
4b4fa39670 | ||
|
|
ab4626e7de | ||
|
|
7164146626 | ||
|
|
3b4f688223 | ||
|
|
ee2706c5ee | ||
|
|
2d9fc5d8af | ||
|
|
49c9a4fdd3 | ||
|
|
bafdbc8313 | ||
|
|
eca28fd4b5 | ||
|
|
3d09c70e13 | ||
|
|
4cd8c04691 | ||
|
|
f7764cd5cb | ||
|
|
afae689ea9 | ||
|
|
e2d7491bc9 | ||
|
|
4c55508f01 | ||
|
|
064a4304cc | ||
|
|
09c6222ecd | ||
|
|
cad197266d | ||
|
|
5b9976433f | ||
|
|
df48afff17 | ||
|
|
e4e8cf4942 | ||
|
|
c89f34770f | ||
|
|
ca5f695459 | ||
|
|
10e0185c49 | ||
|
|
8cdc2f49d8 | ||
|
|
29db3df98d | ||
|
|
52d9fbc9f2 | ||
|
|
7e80d88bce | ||
|
|
6163008108 | ||
|
|
6945fa4496 | ||
|
|
06ad0b2d78 | ||
|
|
2570a30a15 | ||
|
|
93e5486db3 | ||
|
|
49ef33d9f3 | ||
|
|
ca8201b023 | ||
|
|
2cb94116a3 | ||
|
|
a81b66c6b0 | ||
|
|
c9d24c3684 | ||
|
|
8a22e05284 | ||
|
|
3b0f1eca4b | ||
|
|
a66f114f24 | ||
|
|
2c00f4d40b | ||
|
|
2e88f7a245 | ||
|
|
dd68560ad0 | ||
|
|
d1b702ef37 | ||
|
|
7f3389d6f4 | ||
|
|
d9a415f011 | ||
|
|
edff47fd41 | ||
|
|
b3a9386607 | ||
|
|
300a8abc97 | ||
|
|
2bb2b78e82 | ||
|
|
540c9ba6d5 | ||
|
|
872b824dc6 | ||
|
|
9ecd8d3efb | ||
|
|
080d75acae | ||
|
|
62f4d47ee5 | ||
|
|
c0ac6c56ac | ||
|
|
3e60c2306c | ||
|
|
59614d31f2 | ||
|
|
a117e514e4 | ||
|
|
8d098a2bb9 | ||
|
|
899e4b6f67 | ||
|
|
dba86594e1 | ||
|
|
8885038b7e | ||
|
|
76f525fd38 | ||
|
|
3d741ad58d | ||
|
|
ff169ed356 | ||
|
|
ed7f074380 | ||
|
|
9eb6ebfe9b | ||
|
|
29cfde99ae | ||
|
|
c3b0b9a2e0 | ||
|
|
e7ec69708e | ||
|
|
ff9c10f641 | ||
|
|
0eba817aab | ||
|
|
6cb6f2e9b4 | ||
|
|
6faa0939d8 | ||
|
|
68f93fb281 | ||
|
|
1ea8c1cb4e | ||
|
|
d749d05359 | ||
|
|
b18b4418c8 | ||
|
|
a3935ce445 | ||
|
|
92bbfb8fa3 | ||
|
|
6c097dcf51 | ||
|
|
0688e6bbdd | ||
|
|
c49e682df4 | ||
|
|
538d57fe19 | ||
|
|
3053990411 | ||
|
|
49011d4d03 | ||
|
|
6a30138b3c | ||
|
|
6aac4f38e4 | ||
|
|
bc6c5da2dc | ||
|
|
1c55555ad0 | ||
|
|
3f8fcb3914 | ||
|
|
24a879add6 | ||
|
|
ae1b6b8a71 | ||
|
|
da36002d37 | ||
|
|
a611e12b5c | ||
|
|
d4114c510d | ||
|
|
5eaf145eda | ||
|
|
2c2ec6f6e6 | ||
|
|
39ac164890 | ||
|
|
8140c834ca | ||
|
|
742523de17 | ||
|
|
dd1c1071ce | ||
|
|
b9713f7e9e | ||
|
|
9c0a13a828 | ||
|
|
dc56aae7b8 | ||
|
|
ba11fe920b | ||
|
|
7f2da7811c | ||
|
|
62cf2e42d5 | ||
|
|
64745e70d0 | ||
|
|
f49cd6e932 | ||
|
|
ac1e333dde | ||
|
|
b5bc5f65ad | ||
|
|
463d539194 | ||
|
|
7e544ee449 | ||
|
|
1f320c976f | ||
|
|
825a7669a6 | ||
|
|
f6a72b089c | ||
|
|
73ea33f36c | ||
|
|
744a31a354 | ||
|
|
42c7f10e79 | ||
|
|
3e57bc5aa0 | ||
|
|
4880e61e0f | ||
|
|
79a93cfd01 | ||
|
|
0af7bc2004 | ||
|
|
ada103e910 | ||
|
|
a0e964c27d | ||
|
|
a2624b7467 | ||
|
|
9abd7eaeea | ||
|
|
3502ed0293 | ||
|
|
3101738adc | ||
|
|
0b390dd274 | ||
|
|
9d3f7b710d | ||
|
|
3a8ed40943 | ||
|
|
aef1d982c2 | ||
|
|
b287961758 | ||
|
|
8d5675a7d7 | ||
|
|
544e302fe1 | ||
|
|
b417b04a69 | ||
|
|
6ecb99898d | ||
|
|
236c5e2415 | ||
|
|
2d2b68e867 | ||
|
|
f841ea527a | ||
|
|
169548cc4c | ||
|
|
8f93a1a8cf | ||
|
|
8e85fa9f83 | ||
|
|
181a83a889 | ||
|
|
b78504aa04 | ||
|
|
a21ec9299b | ||
|
|
7708ace1d8 | ||
|
|
218b5d5900 | ||
|
|
2983b94cf7 | ||
|
|
25e082ea63 | ||
|
|
3313376fac | ||
|
|
a96c6efcbd | ||
|
|
4dd6b88cdf | ||
|
|
0d836f1e30 | ||
|
|
ab3e0956a4 | ||
|
|
615fceb4a5 | ||
|
|
68453ebcb8 | ||
|
|
635c49d04d | ||
|
|
886af7d55a | ||
|
|
8f563220df | ||
|
|
def415b6f3 | ||
|
|
c21d043183 | ||
|
|
769ea73cec | ||
|
|
d140726c46 | ||
|
|
1f42559279 | ||
|
|
b6d6c7fd2a | ||
|
|
1298fc629e | ||
|
|
30ca5e298c | ||
|
|
2240d0516c | ||
|
|
b87095dc7a | ||
|
|
d30503a40c | ||
|
|
7fbda4fe54 | ||
|
|
24a2b29f70 | ||
|
|
ca9e197d12 | ||
|
|
51f86eb4c6 | ||
|
|
5aba61cc49 | ||
|
|
fcf9888677 | ||
|
|
9c9caeb57a | ||
|
|
a58ad25533 | ||
|
|
11f5150190 | ||
|
|
1c72dfe5ad | ||
|
|
b49830db8f | ||
|
|
e035c490dc | ||
|
|
0d8544b3ee | ||
|
|
50056bef70 | ||
|
|
e68e14787b | ||
|
|
0ab2c5cf98 | ||
|
|
1ca56fd027 | ||
|
|
c4cc9cf1c7 | ||
|
|
b53684a89e | ||
|
|
d93508a272 | ||
|
|
ad9b9cf5b1 | ||
|
|
ac5fb731bc | ||
|
|
d36799020b | ||
|
|
7aa08053e0 | ||
|
|
61b9bc248f | ||
|
|
e33f9573e8 | ||
|
|
186624d267 | ||
|
|
7c9d4cd7d8 | ||
|
|
541b8df735 | ||
|
|
2900bfa1d6 | ||
|
|
5ea0f682a6 | ||
|
|
019cbfd972 | ||
|
|
792c95b8bb | ||
|
|
4d1f432266 | ||
|
|
1e00a58b57 | ||
|
|
0a26ac0279 | ||
|
|
63b0802ad7 | ||
|
|
a5062dbe35 | ||
|
|
f84e657707 | ||
|
|
cd8a42edaf | ||
|
|
e37f8a5eb9 | ||
|
|
7fc8d3f2b1 | ||
|
|
6f2d1a2b49 | ||
|
|
d5a3e46791 | ||
|
|
1f4724c537 | ||
|
|
e6f8736cae | ||
|
|
54fbe54953 | ||
|
|
3e92a2881a | ||
|
|
bd9c3c1593 | ||
|
|
f199d0882f | ||
|
|
a2fee4fc4c | ||
|
|
5670216d7e | ||
|
|
7569266e46 | ||
|
|
23f6cb8bae | ||
|
|
931c2b3ddb | ||
|
|
8b3edb4e28 | ||
|
|
a0b03d36bd | ||
|
|
df1cd0af2e | ||
|
|
5df7146828 | ||
|
|
bec5d829f1 | ||
|
|
ee0e9f6ff8 | ||
|
|
9c7eef3144 | ||
|
|
3110fe4e74 | ||
|
|
565ac2c15a | ||
|
|
9cba6c7475 | ||
|
|
07b3bdb62d | ||
|
|
ac7ff0fff4 | ||
|
|
0d20839d5f | ||
|
|
13fb3118ee | ||
|
|
364027054c | ||
|
|
31a861394f | ||
|
|
0fccc0357e | ||
|
|
5550a71dea | ||
|
|
0ec6f638a1 | ||
|
|
748b4bcf19 | ||
|
|
33cc29fa3c | ||
|
|
5e2eb667b4 | ||
|
|
1f9c9b082f | ||
|
|
722c1875af | ||
|
|
68471d0225 | ||
|
|
a6900545b0 | ||
|
|
808ceba848 | ||
|
|
a796a03a15 | ||
|
|
5a5dc67209 | ||
|
|
69ae54b523 | ||
|
|
b405227d51 | ||
|
|
44be39a9a4 | ||
|
|
5de0cc199c | ||
|
|
0c9e408eda | ||
|
|
1007f1f740 | ||
|
|
774e3d5948 | ||
|
|
4d866d066a | ||
|
|
da6544e981 | ||
|
|
3af9a7646d | ||
|
|
0e2cf82e3e | ||
|
|
97e69b9887 | ||
|
|
692f91263b | ||
|
|
8b61d8a9d2 | ||
|
|
25d51f9515 | ||
|
|
20b971dc1f | ||
|
|
7a76d749e3 | ||
|
|
123afd9462 | ||
|
|
ad83478b77 | ||
|
|
2ad0a65613 | ||
|
|
1f5762b8c8 | ||
|
|
0370b09ad0 | ||
|
|
5869a8948d | ||
|
|
56a840e207 | ||
|
|
a01dd005fd | ||
|
|
9ad6c16d43 | ||
|
|
9cc3e16db9 | ||
|
|
d02bcdba29 | ||
|
|
c708fe577c | ||
|
|
c92161bb22 | ||
|
|
138aa13fdc | ||
|
|
988a795def | ||
|
|
3f7a3053ff | ||
|
|
0c8c6865be | ||
|
|
2bbcae39b6 | ||
|
|
caf6b2aa0c | ||
|
|
a00f05fe32 | ||
|
|
9fcac1ab4f | ||
|
|
ae24ad4693 | ||
|
|
0f721b60a9 | ||
|
|
e8b49f53e1 | ||
|
|
27531a802b | ||
|
|
4bbf0ce0c0 | ||
|
|
e0c22ea3eb | ||
|
|
b7eb2ba068 | ||
|
|
affdb69568 | ||
|
|
763b7da65c | ||
|
|
42e9165347 | ||
|
|
16dd08a359 | ||
|
|
936494615c | ||
|
|
5769c0b98e | ||
|
|
b7e1caa8c6 | ||
|
|
e02ae6b2fb | ||
|
|
d9f131a2c5 | ||
|
|
ad1f7dbaa5 | ||
|
|
aa6da0f6d3 | ||
|
|
376071e408 | ||
|
|
d3544fb9b3 | ||
|
|
c8497b3944 | ||
|
|
5aa92b8413 | ||
|
|
bccb6694d4 | ||
|
|
506a11c658 | ||
|
|
bdc315a59d | ||
|
|
ec7d3bddfc | ||
|
|
762c1ccf28 | ||
|
|
8e44c8fa06 | ||
|
|
20db102327 | ||
|
|
1643cb8165 | ||
|
|
49e623dfeb | ||
|
|
a1208974ac | ||
|
|
d611087513 | ||
|
|
ac7cb2ee19 | ||
|
|
f866572cbf | ||
|
|
4c6942f60b | ||
|
|
d939897524 | ||
|
|
66c5589fd7 | ||
|
|
379b1d611b | ||
|
|
f16221f385 | ||
|
|
9b82560270 | ||
|
|
7271af03e6 | ||
|
|
4d564bbce2 | ||
|
|
d7afdf214b | ||
|
|
18e445ea02 | ||
|
|
cb70c705a3 | ||
|
|
9a77eb9872 | ||
|
|
ec82f646a0 | ||
|
|
2f0e384240 | ||
|
|
19a1426869 | ||
|
|
cc5cd8db6b | ||
|
|
e384e2edda | ||
|
|
dca044873f | ||
|
|
8aadddcc68 | ||
|
|
2e95229c51 | ||
|
|
8a1d02c23f | ||
|
|
d6bca4ea79 | ||
|
|
7b567a66ed | ||
|
|
2c8126e244 | ||
|
|
1b70fe5770 | ||
|
|
71c000756b | ||
|
|
a2a7ead82a | ||
|
|
ef0f1b10cc | ||
|
|
42bedce9c0 | ||
|
|
afcd44abad | ||
|
|
274830f533 | ||
|
|
9cb139d190 | ||
|
|
d681481ae9 | ||
|
|
5d377e602f | ||
|
|
f535c814d9 | ||
|
|
4f5073cd9e | ||
|
|
9cd2340007 | ||
|
|
9ca036e393 | ||
|
|
5340ecb6df | ||
|
|
1248d52161 | ||
|
|
3e2fdb1891 | ||
|
|
ac8fa7672e | ||
|
|
db57716130 | ||
|
|
b162814bd9 | ||
|
|
a889d57013 | ||
|
|
c6e9cdbf35 | ||
|
|
2a00d90134 | ||
|
|
2676cd7219 | ||
|
|
4f76b1fda4 | ||
|
|
1c56d5c59e | ||
|
|
be44eedeb8 | ||
|
|
36296d2f5d | ||
|
|
b4db75fb55 | ||
|
|
565c36040d | ||
|
|
36e7f821e8 | ||
|
|
009e1e25f5 | ||
|
|
69715ed1c8 | ||
|
|
e8cee12384 | ||
|
|
f2fd2c157c | ||
|
|
3f6cee5ded | ||
|
|
b1cb95c3b0 | ||
|
|
372bc3c97c | ||
|
|
fa684f95e0 | ||
|
|
e8fb8a6f88 | ||
|
|
93901336bb | ||
|
|
660f2095af | ||
|
|
13b27cf77a | ||
|
|
d1eb5a8466 | ||
|
|
5d0aefb07a | ||
|
|
78a23bb722 | ||
|
|
38c42cb47b | ||
|
|
c9c779d5d5 | ||
|
|
dabfd4249e | ||
|
|
e62db5f1d9 | ||
|
|
50c01c97ee | ||
|
|
68600dddf0 | ||
|
|
c80464d072 | ||
|
|
02a083fa02 | ||
|
|
36ff24c301 | ||
|
|
935f3b8754 | ||
|
|
eac9f649cf | ||
|
|
8bcd27e042 | ||
|
|
c3dbf51a16 | ||
|
|
36417a0726 | ||
|
|
20b87f8bb9 | ||
|
|
a1bac5a133 | ||
|
|
177da24e47 | ||
|
|
37ba8d17bf | ||
|
|
ee8b78fd3c | ||
|
|
83bc685e75 | ||
|
|
3781897e39 | ||
|
|
0efed6d8d3 | ||
|
|
8f2c33aec3 | ||
|
|
433b5bc974 | ||
|
|
aef27f475d | ||
|
|
28ccf19874 | ||
|
|
7e54f40033 | ||
|
|
bf8ccbcec6 | ||
|
|
2f5b083c5c | ||
|
|
5640e8c11a | ||
|
|
c239445454 | ||
|
|
a7b7ddbe76 | ||
|
|
d859272d43 | ||
|
|
d59a16a9a1 | ||
|
|
79f524865f | ||
|
|
6d0a09402b | ||
|
|
4bb160b281 | ||
|
|
24d27f421b | ||
|
|
3d0b8ec5f0 | ||
|
|
79e6271041 | ||
|
|
ecac526810 | ||
|
|
ad8d5a8694 | ||
|
|
2406d67bfc | ||
|
|
f0266e9316 | ||
|
|
c08f42315e | ||
|
|
d2649dac90 | ||
|
|
300681055e | ||
|
|
712dbc9396 | ||
|
|
f6b8e8615f | ||
|
|
4826c13848 | ||
|
|
80f497a185 | ||
|
|
d2a9adb4be | ||
|
|
8675086441 | ||
|
|
b79e784764 | ||
|
|
93ba3e700e | ||
|
|
bf6cb8d0b8 | ||
|
|
7010d7bf66 | ||
|
|
1a862157a0 | ||
|
|
532575cab5 | ||
|
|
0794d0f89f | ||
|
|
e227ffd6d8 | ||
|
|
5058b40871 | ||
|
|
5d847b59b2 | ||
|
|
c8d44b9416 | ||
|
|
14d67d1ec7 | ||
|
|
6866faf4fe | ||
|
|
567d628a52 | ||
|
|
a3eab75405 | ||
|
|
566f6b067c | ||
|
|
e73d07281c | ||
|
|
e59d4dea77 | ||
|
|
4ca5370b86 | ||
|
|
e831971dd1 | ||
|
|
99d996dde9 | ||
|
|
712d31b416 | ||
|
|
0394855b2f | ||
|
|
9024b021ee | ||
|
|
8071641179 | ||
|
|
0075374241 | ||
|
|
c35ddc8c76 | ||
|
|
4b4aef7ef8 | ||
|
|
6db4a62e01 | ||
|
|
db394b6145 | ||
|
|
53e7704724 | ||
|
|
f607c7c271 | ||
|
|
48c689e5d6 | ||
|
|
2f2251ff33 | ||
|
|
29254d1a66 | ||
|
|
19cbae1732 | ||
|
|
73ad27640c | ||
|
|
1be96e1bd1 | ||
|
|
a9834be2ff | ||
|
|
d8ab86d86f | ||
|
|
3f1bd8e290 | ||
|
|
34a7d75e10 | ||
|
|
ae53de42df | ||
|
|
b70321a0aa | ||
|
|
0ff39f9a61 | ||
|
|
876ba0fa0f | ||
|
|
c7c65d2f97 | ||
|
|
736f7e198f | ||
|
|
8cb3589fb8 | ||
|
|
56530d8791 | ||
|
|
da6b0e3dcc | ||
|
|
eb02f99cae | ||
|
|
cb0efae81c | ||
|
|
e5f98e6145 | ||
|
|
8a23007ad2 | ||
|
|
592b196848 | ||
|
|
8eb273e54b | ||
|
|
78c7e752f9 | ||
|
|
7c51a3b5ff | ||
|
|
3e77db4cee | ||
|
|
c1c831fea3 | ||
|
|
6734eab555 | ||
|
|
6ecfbf17c0 | ||
|
|
42fe068db7 | ||
|
|
6b3db56ab2 | ||
|
|
eee15d5ff2 | ||
|
|
7a618311d6 | ||
|
|
7dba9ff885 | ||
|
|
4c9c292316 | ||
|
|
00613efbd8 | ||
|
|
b7384874cf | ||
|
|
c8ee2ca4a1 | ||
|
|
f97bb4a439 | ||
|
|
d83b349016 | ||
|
|
657cd04af2 | ||
|
|
24a092836b | ||
|
|
290374f6fc | ||
|
|
2e7acc73d8 | ||
|
|
666d51482e | ||
|
|
eedf37d18a | ||
|
|
16f210966b | ||
|
|
30e70b6327 | ||
|
|
f91a2e3b65 | ||
|
|
fdc405c912 | ||
|
|
2f2e70bb86 | ||
|
|
eef54f4153 | ||
|
|
ad1c015f01 | ||
|
|
326fdcf6ea | ||
|
|
26a0c4e809 | ||
|
|
acb465ae33 | ||
|
|
5418a0bee6 | ||
|
|
a59815264d | ||
|
|
3ac0be4e35 | ||
|
|
feae930293 | ||
|
|
7ebb52ec6d | ||
|
|
8b73ad3b6f | ||
|
|
6fc2a8234d | ||
|
|
e2c2724e36 | ||
|
|
6abfbe8553 | ||
|
|
54f6add45d | ||
|
|
f8ae5368bf | ||
|
|
2ba348551d | ||
|
|
110f88f22d | ||
|
|
c90a15dd0f | ||
|
|
f4335e1e72 | ||
|
|
8d9e1a0ad5 | ||
|
|
48dcfcb08f | ||
|
|
def19be230 | ||
|
|
36154e9d33 | ||
|
|
7cf6bb78d6 | ||
|
|
541f281b29 | ||
|
|
965ef5246b | ||
|
|
9c88057bd1 | ||
|
|
8c52e92705 | ||
|
|
3a727d24ce | ||
|
|
185558a642 | ||
|
|
35aa525bd2 | ||
|
|
2ce8788487 | ||
|
|
ec0e98a64b | ||
|
|
121e9f03a4 | ||
|
|
a0295b1a39 | ||
|
|
30aba86380 | ||
|
|
89f5a20786 | ||
|
|
ef7caa260b | ||
|
|
39d50ef70e | ||
|
|
58a1392480 | ||
|
|
06f6bcc340 | ||
|
|
c9d18b614b | ||
|
|
2035c42c3c | ||
|
|
a760426b87 | ||
|
|
10b129a02e | ||
|
|
129b9d5db9 | ||
|
|
2c08becf6c | ||
|
|
a3bfe7cb0c | ||
|
|
7049a8a2bb | ||
|
|
1197b1dd8d | ||
|
|
7f167ff2fc | ||
|
|
3ade5cdf19 | ||
|
|
5f6fa4d79f | ||
|
|
3ee20863d6 | ||
|
|
8fe5eaee29 | ||
|
|
208534c9d9 | ||
|
|
3f030394c6 | ||
|
|
6ca0085ec8 | ||
|
|
2cf1649c67 | ||
|
|
64ed988169 | ||
|
|
85b7e881eb | ||
|
|
9325cb2872 | ||
|
|
e39dcc458b | ||
|
|
84b4b30f21 | ||
|
|
6c47598cd9 | ||
|
|
d00d71ecbf | ||
|
|
dc273b2d63 | ||
|
|
497b16e942 | ||
|
|
a472de1919 | ||
|
|
d306d7a983 | ||
|
|
163aa57e5c | ||
|
|
3eab294908 | ||
|
|
da30780ac2 | ||
|
|
ef53354193 | ||
|
|
e9ce3d2213 | ||
|
|
a46db61c4c | ||
|
|
5e271fd4a4 | ||
|
|
6481483074 | ||
|
|
7bcb37c761 | ||
|
|
e7d97d7a2b | ||
|
|
1afae99345 | ||
|
|
bdb2e2f417 | ||
|
|
bba3751268 | ||
|
|
60bc04bc33 | ||
|
|
a4cff13531 | ||
|
|
937456596a | ||
|
|
caf382b64c | ||
|
|
55cc250d2e | ||
|
|
eaa2be017d | ||
|
|
4e4c5ffdb6 | ||
|
|
383bcc4113 | ||
|
|
9f906b7417 | ||
|
|
db2e168540 | ||
|
|
2697d6c5d7 | ||
|
|
b6a6ce9aaf | ||
|
|
89f6a94bd8 | ||
|
|
96f2d69ae5 | ||
|
|
b7e906701a | ||
|
|
150d986179 | ||
|
|
ef10ea2a7d | ||
|
|
3bf84e8b0c | ||
|
|
ea4b334c7e | ||
|
|
4d11aa8655 | ||
|
|
302deb8299 | ||
|
|
0c80b1067d | ||
|
|
0a36d4fbfd | ||
|
|
c20a8b5a68 | ||
|
|
8ffe4e284a | ||
|
|
1332f718ae | ||
|
|
f4df51884c | ||
|
|
ce86129478 | ||
|
|
097b125e3a | ||
|
|
5c6b53922a | ||
|
|
e1b9f23f73 | ||
|
|
e1c480d3c3 | ||
|
|
363a62d885 | ||
|
|
c6ee9a5a52 | ||
|
|
cf5990ccba | ||
|
|
b6f3682a62 | ||
|
|
b43f864511 | ||
|
|
0556ffb4a1 | ||
|
|
303047656e | ||
|
|
8d29b5ae71 | ||
|
|
7d7ae24351 | ||
|
|
97838e614d | ||
|
|
c897baad20 | ||
|
|
d51e9205d9 | ||
|
|
e051c86bb5 | ||
|
|
c2b48cd003 | ||
|
|
a7009eb8d5 | ||
|
|
036b87b649 | ||
|
|
f07a3b1875 | ||
|
|
6e89ccc0ae | ||
|
|
cc67612432 | ||
|
|
17ebe221bb | ||
|
|
1963edda66 | ||
|
|
c9e3717ce3 | ||
|
|
9a85246631 | ||
|
|
75f165d1ff | ||
|
|
eaf0deb2f6 | ||
|
|
a9061e5258 | ||
|
|
caac45b834 | ||
|
|
24ff7a7911 | ||
|
|
b767dcb27e | ||
|
|
731afbee46 | ||
|
|
07dfd981a2 | ||
|
|
32ef208278 | ||
|
|
a80b185e10 | ||
|
|
b96328e098 | ||
|
|
45471ce86d | ||
|
|
1bc91d0c7c | ||
|
|
799325d9f8 | ||
|
|
b540709e03 | ||
|
|
44daab04ac | ||
|
|
ee65223ee7 | ||
|
|
d49fcd8f3e | ||
|
|
4ee349bd6b | ||
|
|
dfa32b6755 | ||
|
|
0b69729173 | ||
|
|
3b313b9308 | ||
|
|
1abdf42f99 | ||
|
|
9fdc535d6b | ||
|
|
b9b734ceda | ||
|
|
3b05505527 | ||
|
|
bc29419c17 | ||
|
|
4d4360b86b | ||
|
|
8cc28761d7 | ||
|
|
24b3499c70 | ||
|
|
4e4fd5a4b4 | ||
|
|
1a3df54c04 | ||
|
|
3edacee59b | ||
|
|
f25d31b92b | ||
|
|
c91c8a6467 | ||
|
|
61d6ac035d | ||
|
|
9a9373dd0f | ||
|
|
e319a7a5ae | ||
|
|
342549b546 | ||
|
|
bbe94f55b6 | ||
|
|
6fcf1893d3 | ||
|
|
01afe34df7 | ||
|
|
be3e8e3332 | ||
|
|
cf31700903 | ||
|
|
66dee6fd06 | ||
|
|
bfa55f8c67 | ||
|
|
5a2318d01f | ||
|
|
7de037029f | ||
|
|
730c1115ce | ||
|
|
2c37f32fa6 | ||
|
|
7aa9f8b1c3 | ||
|
|
c331ada086 | ||
|
|
ebc25e45d3 | ||
|
|
f82921d2a1 | ||
|
|
d68fe42918 | ||
|
|
823f2a7991 | ||
|
|
0ca9321db1 | ||
|
|
46eddbe7b9 | ||
|
|
64c796a8c3 | ||
|
|
264ff5457b | ||
|
|
ad89df4d0d | ||
|
|
0f10b8ba2b | ||
|
|
940bf990f9 | ||
|
|
1b8fbbe7d7 | ||
|
|
f6f07f4690 | ||
|
|
3800249921 | ||
|
|
a5d857d5e7 | ||
|
|
4c1e80ff58 | ||
|
|
7e5db1f55e | ||
|
|
1edc56c0ce | ||
|
|
4066a70ea5 | ||
|
|
a0d36cf87a | ||
|
|
1d12011eb5 | ||
|
|
7c01f84a5c | ||
|
|
81c5f4acc3 | ||
|
|
0ebfe047d1 | ||
|
|
e68bd53e30 | ||
|
|
cdd9851f72 | ||
|
|
995c3ef81b | ||
|
|
0dfde1374d | ||
|
|
34235199dd | ||
|
|
5d1cd670e9 | ||
|
|
1d8ea7b0ee | ||
|
|
4b218553c3 | ||
|
|
a61c1004d3 | ||
|
|
5d1b42b314 | ||
|
|
4b992c6f3e | ||
|
|
38562f9560 | ||
|
|
c01f0271fe | ||
|
|
0296998fae | ||
|
|
a67b917bdd | ||
|
|
2791bd123c | ||
|
|
e1f9b69cd5 | ||
|
|
2c05496962 | ||
|
|
66bcf9223a | ||
|
|
993f69db37 | ||
|
|
58317edb6d | ||
|
|
417891675d | ||
|
|
8b7aef883a | ||
|
|
b5961d79f8 | ||
|
|
0d25f3f430 | ||
|
|
798fa2396a | ||
|
|
28b222fffa | ||
|
|
b57855f20d | ||
|
|
438b1f9815 | ||
|
|
2bccb3589e | ||
|
|
52bb06eb7b | ||
|
|
8e6d0e7d42 | ||
|
|
5526fd8296 | ||
|
|
a554a8c49f | ||
|
|
7759d762ab | ||
|
|
dd98097897 | ||
|
|
cc73b7831f | ||
|
|
9c243cc8dd | ||
|
|
5d568a3f32 | ||
|
|
1b83542d41 | ||
|
|
cf95d91db3 | ||
|
|
41c1d88615 | ||
|
|
df8673ba40 | ||
|
|
96b1869a0c | ||
|
|
e45b852c09 | ||
|
|
2d3e5c3499 | ||
|
|
b25bf1e341 | ||
|
|
4bb80d3e3a | ||
|
|
03575186a7 | ||
|
|
935c7dd496 | ||
|
|
1b2dc6a133 | ||
|
|
d4e2b2188e | ||
|
|
9658f757c2 | ||
|
|
371e84d9a5 | ||
|
|
5423a2f1b9 | ||
|
|
7001f8e088 | ||
|
|
678cd54553 | ||
|
|
bc19d6592f | ||
|
|
5af0859f67 | ||
|
|
379711951c | ||
|
|
a50a9c5617 | ||
|
|
c0d30a455f | ||
|
|
9a3f6b21d2 | ||
|
|
9ea41f68bc | ||
|
|
e943aa8f03 | ||
|
|
17a4750d8e | ||
|
|
7d18c22aa1 | ||
|
|
c80cc6e268 | ||
|
|
b30a1b5250 | ||
|
|
b753371700 | ||
|
|
3ca5ab180f | ||
|
|
4971f5510c | ||
|
|
20fa7e508d | ||
|
|
ebffc340d9 | ||
|
|
9a86737caa | ||
|
|
d35d8a7307 | ||
|
|
701ff5d6bc | ||
|
|
9044b25a23 | ||
|
|
7f089fab86 | ||
|
|
a259c28678 | ||
|
|
db48da185a | ||
|
|
cab667c23b | ||
|
|
154ca9f1b1 | ||
|
|
2abe40b786 | ||
|
|
6be2420b32 | ||
|
|
9405cc0e04 | ||
|
|
55c98912ed | ||
|
|
45bd7984b0 | ||
|
|
1ed9a0106e | ||
|
|
f8b2ee8c0d | ||
|
|
d32b0f8b7e | ||
|
|
24fdb1f600 | ||
|
|
4010174f66 | ||
|
|
e2b812a611 | ||
|
|
d72b3a9ba2 | ||
|
|
85f52d2574 | ||
|
|
33ea22c0a9 | ||
|
|
0d52f9dd0e | ||
|
|
3caffe1e85 | ||
|
|
87b8dd61c3 | ||
|
|
ad77cd195c | ||
|
|
eb2a754580 | ||
|
|
9258db58db | ||
|
|
8d1c90f912 | ||
|
|
1c62bd6ca5 | ||
|
|
13317ec43c | ||
|
|
35dcb5ca46 | ||
|
|
4454b6b890 | ||
|
|
117e3500ae | ||
|
|
94fda6a720 | ||
|
|
e1388eff84 | ||
|
|
94d2e32b49 | ||
|
|
069f22afa4 | ||
|
|
52c90d4d0a | ||
|
|
ce7e0d8d60 | ||
|
|
40c7742e46 | ||
|
|
05e872337a | ||
|
|
aac9d001f7 | ||
|
|
d295968948 | ||
|
|
97e7a3c5e2 | ||
|
|
16a1825990 | ||
|
|
441afead10 | ||
|
|
783ab253af | ||
|
|
17648d12fe | ||
|
|
2f4f1be99c | ||
|
|
5d4d3888b8 | ||
|
|
473084e915 | ||
|
|
a8147b9713 | ||
|
|
3c3dc547b2 | ||
|
|
c5accd0f16 | ||
|
|
cb949e443e | ||
|
|
bb6815f681 | ||
|
|
a261f60764 | ||
|
|
d393529026 | ||
|
|
219c9593e0 | ||
|
|
faa6b2b790 | ||
|
|
4046bf7b31 | ||
|
|
4f708309af | ||
|
|
f2e7680bf3 | ||
|
|
5d2689b139 | ||
|
|
145ffeea40 | ||
|
|
13143bc7ea | ||
|
|
ee0dbf2d22 | ||
|
|
4265ae4dae | ||
|
|
821c1fdbef | ||
|
|
fe29d6aee3 | ||
|
|
c0c7144539 | ||
|
|
20e3d3a15b | ||
|
|
07d1eedae3 | ||
|
|
4ad3d70739 | ||
|
|
e6a1c29655 | ||
|
|
333dfe1ebf | ||
|
|
c59872553a | ||
|
|
1a39370f5b | ||
|
|
bc44056815 | ||
|
|
17c92343e0 | ||
|
|
cd6935b07a | ||
|
|
47d428f3eb | ||
|
|
2baae7072f | ||
|
|
2e9e459aa3 | ||
|
|
7444e2c1c7 | ||
|
|
d6469eb33d | ||
|
|
a2da6f1827 | ||
|
|
e6508140f8 | ||
|
|
a7127bc74f | ||
|
|
55aa0c0c5d | ||
|
|
d25de4f459 | ||
|
|
6d31f4876a | ||
|
|
e6577ca269 | ||
|
|
08d77b4333 | ||
|
|
1ead121c9b | ||
|
|
ad19b4a421 | ||
|
|
6bc52dd39c | ||
|
|
fd2b00bf3b | ||
|
|
cd8c6d1ce0 | ||
|
|
e9fc6d5598 | ||
|
|
8ed7cd80cb | ||
|
|
81322664ea | ||
|
|
458d722d47 | ||
|
|
3c0d25f3bd | ||
|
|
ca7e4dd66e | ||
|
|
c1316532eb | ||
|
|
d418784346 | ||
|
|
1061601714 | ||
|
|
2f3d4a5511 | ||
|
|
9ea62bda28 | ||
|
|
94b1d446c0 | ||
|
|
6c57a00a65 | ||
|
|
8808531cd5 | ||
|
|
966fca950b | ||
|
|
e528cff615 | ||
|
|
1d037f2f1f | ||
|
|
b2d67795b3 | ||
|
|
959c527be7 | ||
|
|
cc75167437 | ||
|
|
3114d4b5c5 | ||
|
|
ac293cda1c | ||
|
|
7b88975bcb | ||
|
|
da4b2e3a56 | ||
|
|
369598bc96 | ||
|
|
61c5269353 | ||
|
|
7a35b5b0e4 | ||
|
|
20e9423390 | ||
|
|
cf230a1cbc | ||
|
|
a06a09afcf | ||
|
|
c88382ec1f | ||
|
|
fd0bc652a9 | ||
|
|
57e10dc911 | ||
|
|
1110f745e1 | ||
|
|
811d03a419 | ||
|
|
666c031821 | ||
|
|
4e457d97ad | ||
|
|
364e4f1b4e | ||
|
|
8aae557266 | ||
|
|
2bd880ec29 | ||
|
|
b14438fd99 | ||
|
|
ba96d8a5fb | ||
|
|
db4b1dd024 | ||
|
|
469a4e94c2 | ||
|
|
44d6c0885e | ||
|
|
9ce4ac9c9e | ||
|
|
b40d22dc74 | ||
|
|
a257696c25 | ||
|
|
f742937359 | ||
|
|
c0db48b29d | ||
|
|
ea228c3d6d | ||
|
|
da010f3d08 | ||
|
|
32e94d4e4e | ||
|
|
db616bc8a5 | ||
|
|
b8b46ec129 | ||
|
|
7d0b79a546 | ||
|
|
fd26565b14 | ||
|
|
e0b6f2283a | ||
|
|
d3d3d50569 | ||
|
|
cee997e0b3 | ||
|
|
80f53ed6ec | ||
|
|
6f84317e7a | ||
|
|
3cb484f06a | ||
|
|
61353cbe8a | ||
|
|
d647980c3a | ||
|
|
5740abe31b | ||
|
|
5fd4f52e35 | ||
|
|
dbe7cd16d4 | ||
|
|
2b630ca2dd | ||
|
|
2ede22646b | ||
|
|
994b6bb471 | ||
|
|
92f338e0cd | ||
|
|
7a176cf284 | ||
|
|
80e607ab30 | ||
|
|
6cff21477e | ||
|
|
4bb5a7f480 | ||
|
|
9a88511d00 | ||
|
|
48cd614948 | ||
|
|
2fe252d62b | ||
|
|
8fae7f8438 | ||
|
|
e4e55157e8 | ||
|
|
a5e246cc16 | ||
|
|
d28dc59584 | ||
|
|
5353570721 | ||
|
|
eb3e367ba8 | ||
|
|
3c1441d462 | ||
|
|
33ce841040 | ||
|
|
9797201c2a | ||
|
|
6e14ac583b | ||
|
|
0b37b677c1 | ||
|
|
f59dd34154 | ||
|
|
e8ec648886 | ||
|
|
10767a06df | ||
|
|
59b3375b59 | ||
|
|
4408fd0cd3 | ||
|
|
975a9517b9 | ||
|
|
89c92b7834 | ||
|
|
747cea8084 | ||
|
|
f016b31388 | ||
|
|
8cd53a4b7a | ||
|
|
a39abe61c2 | ||
|
|
054898f821 | ||
|
|
13d9b12a2e | ||
|
|
aaec856282 | ||
|
|
009eec9475 | ||
|
|
8d14535fd5 | ||
|
|
cc7f14951c | ||
|
|
b67ff87f35 | ||
|
|
f55ef6e691 | ||
|
|
560a1a00ca | ||
|
|
3b5ce1b053 | ||
|
|
03e8d05f18 | ||
|
|
bedb7fb255 | ||
|
|
4d586f7a85 | ||
|
|
6486a5d971 | ||
|
|
e3364457c4 | ||
|
|
66119a8b57 | ||
|
|
6eb9e906af | ||
|
|
1900fb695d | ||
|
|
a62aac296b | ||
|
|
5294aa2810 | ||
|
|
31bdb948a8 | ||
|
|
468c12c75b | ||
|
|
220fe28830 | ||
|
|
7fd1a644a6 | ||
|
|
6e7a42727a | ||
|
|
ac4b129195 | ||
|
|
85bc14e470 | ||
|
|
6e791a2cfe | ||
|
|
340830d121 | ||
|
|
faca64442f | ||
|
|
854474478c | ||
|
|
4adce14485 | ||
|
|
dc62604ed8 | ||
|
|
f0d43f941f | ||
|
|
9c4935286f | ||
|
|
e1648425ea | ||
|
|
19fa40286a | ||
|
|
1a3db327c7 | ||
|
|
1170004097 | ||
|
|
d2b0eacbf5 | ||
|
|
ca9f85a1ff | ||
|
|
9ee092aa5e | ||
|
|
39bdfa4512 | ||
|
|
e828615467 | ||
|
|
ba4526985a | ||
|
|
607feb183e | ||
|
|
9994ed157a | ||
|
|
bfa27d9103 | ||
|
|
be9d3285e1 | ||
|
|
0f5988af49 | ||
|
|
a28bd349ae | ||
|
|
51f9977885 | ||
|
|
27865981df | ||
|
|
ac3f1cd5c3 | ||
|
|
7549b6cf3f | ||
|
|
dd372ee122 | ||
|
|
6a8e6734f3 | ||
|
|
4ba16f1b04 | ||
|
|
90a19cec5c | ||
|
|
8e480c9fab | ||
|
|
b0e3afa0b6 | ||
|
|
eb6d251a73 | ||
|
|
62c2bf86aa | ||
|
|
4a7f96caf6 | ||
|
|
9c70a43ac3 | ||
|
|
b7cde35c3d | ||
|
|
02fbdfec36 | ||
|
|
94c91035a7 | ||
|
|
5c6c66f010 | ||
|
|
0c870bf37b | ||
|
|
9e0e0a12fa | ||
|
|
c5a1d7e051 | ||
|
|
aaab2fa9d8 | ||
|
|
ef4beef2ea | ||
|
|
1261887c9e | ||
|
|
84fe3cf2a2 | ||
|
|
50fd7c6286 | ||
|
|
d7b412eccc | ||
|
|
d283c63a33 | ||
|
|
d15e2cdc0c | ||
|
|
9cef912c44 | ||
|
|
659abe553d | ||
|
|
014a590704 | ||
|
|
2669a44d79 | ||
|
|
db8f9c6f6c | ||
|
|
2b01136d03 | ||
|
|
fbbf550730 | ||
|
|
3924d0f081 | ||
|
|
00ab9e949a | ||
|
|
42d9dfba36 | ||
|
|
a808f83e7d | ||
|
|
413b9c3b04 | ||
|
|
7edce528d6 | ||
|
|
836df78181 | ||
|
|
a80aa2b45c | ||
|
|
9dd9ffdb3b | ||
|
|
b6daee2850 | ||
|
|
1ba4b590f4 | ||
|
|
e73b1aa49c | ||
|
|
6b5a402962 | ||
|
|
55667a878a | ||
|
|
a0ab82b866 | ||
|
|
6a51b6b41e | ||
|
|
b4e829e8c6 | ||
|
|
06ef12d0ff | ||
|
|
cd5f342da0 | ||
|
|
27e309754e | ||
|
|
6ae0a972d4 | ||
|
|
014c491205 | ||
|
|
4ef71f4aca | ||
|
|
5a5a10821d | ||
|
|
9685e260ea | ||
|
|
f8871fcd2a | ||
|
|
6d17d8bc64 | ||
|
|
46c6a0700f | ||
|
|
5f8fd99fe8 | ||
|
|
8a81d95253 | ||
|
|
f22aed34b5 | ||
|
|
e75e6cb7f7 | ||
|
|
14a365045d | ||
|
|
9b6779515e | ||
|
|
88ee1b5d19 | ||
|
|
a45ec9a7b4 | ||
|
|
51605c6442 | ||
|
|
2fe213d864 | ||
|
|
439f13af19 | ||
|
|
2b5ecd3a57 | ||
|
|
a9ead542b3 | ||
|
|
7479302043 | ||
|
|
10d20e5963 | ||
|
|
5a2e6d0e50 | ||
|
|
9068cfd892 | ||
|
|
5560a444e5 | ||
|
|
505a2d5523 | ||
|
|
2463648161 | ||
|
|
48cf27a3b8 | ||
|
|
39fce3e29b | ||
|
|
4f4c685085 | ||
|
|
d177a70c54 | ||
|
|
cf8ec631dd | ||
|
|
ea61f36e5d | ||
|
|
ffc66647f8 | ||
|
|
6623475035 | ||
|
|
0dd12a218b | ||
|
|
5f89d70fd8 | ||
|
|
3ccbd40232 | ||
|
|
7e9dd01265 | ||
|
|
0fb3555a70 | ||
|
|
73ce754316 | ||
|
|
d304f330e8 | ||
|
|
7333598dba | ||
|
|
bb61e73464 | ||
|
|
c15789eb73 | ||
|
|
e7a2b6268e | ||
|
|
688fa3aa78 | ||
|
|
48bc7d0d92 | ||
|
|
d9df58e93a | ||
|
|
37bba18c81 | ||
|
|
40498d8ddd | ||
|
|
b265810b95 | ||
|
|
09837769d7 | ||
|
|
cf1fd17626 | ||
|
|
785f021898 | ||
|
|
80cc9f18b5 | ||
|
|
5e7e91dd6d | ||
|
|
1032b462b4 | ||
|
|
104307b2b2 | ||
|
|
f8c66a31d9 | ||
|
|
2100155ab5 | ||
|
|
de473fc10e | ||
|
|
76e49ed9a8 | ||
|
|
e9ebef15a0 | ||
|
|
6ff4fd3db2 | ||
|
|
d38085a560 | ||
|
|
3cad13388c | ||
|
|
0b62456236 | ||
|
|
c22d280491 | ||
|
|
960d18998f | ||
|
|
3f3db75d85 | ||
|
|
48aab77058 | ||
|
|
7e53d01d0f | ||
|
|
bd271ec5a1 | ||
|
|
8913e75484 | ||
|
|
c95ffa9e2d | ||
|
|
ddb89f71b4 | ||
|
|
45be6c2b45 | ||
|
|
a00cb951bc | ||
|
|
f584bf3830 | ||
|
|
9600eb6fa1 | ||
|
|
d88ef03ddb | ||
|
|
dc9d7ae3f1 | ||
|
|
a3c7eb0ce0 | ||
|
|
d1ba484be1 | ||
|
|
521eb5f114 | ||
|
|
66770bebd4 | ||
|
|
86c4b3059e | ||
|
|
e3a8853212 | ||
|
|
194b6e491d | ||
|
|
a439695248 | ||
|
|
86f1b8df6e | ||
|
|
a5faddc56c | ||
|
|
9c68c6c9f3 | ||
|
|
d99486ee72 | ||
|
|
946166319f | ||
|
|
26bb028ace | ||
|
|
da615afc92 | ||
|
|
2b53bebcb3 | ||
|
|
d336a14e50 | ||
|
|
4ca6292805 | ||
|
|
44ef5bb12a | ||
|
|
bf600f8b11 | ||
|
|
d6d7afddbc | ||
|
|
61642b8df6 | ||
|
|
07de1b2c06 | ||
|
|
bd3440bf3c | ||
|
|
573f003226 | ||
|
|
6e169662c2 | ||
|
|
31658d4028 | ||
|
|
bb02c69d14 | ||
|
|
73307e164b | ||
|
|
9ea5efb6ba | ||
|
|
3cd58cac54 | ||
|
|
1303a08f5a | ||
|
|
3b1d853090 | ||
|
|
a2a4c85f2d | ||
|
|
506ee389e3 | ||
|
|
8635bc9b9c | ||
|
|
447f497506 | ||
|
|
71292a60b1 | ||
|
|
51449490fa | ||
|
|
ae4970f0ed | ||
|
|
e96d5c245d | ||
|
|
f8e3d75797 | ||
|
|
27aaf322b2 | ||
|
|
b77132dbb1 | ||
|
|
c35473f308 | ||
|
|
a570073d12 | ||
|
|
0ad4826fab | ||
|
|
6db7d31554 | ||
|
|
21d67a971d | ||
|
|
8dfa5efa71 | ||
|
|
529750fa21 | ||
|
|
96b1d36280 | ||
|
|
31c5a82749 | ||
|
|
82516620e7 | ||
|
|
d26d5840f1 | ||
|
|
ebd26316bf | ||
|
|
18dbad232e | ||
|
|
ebcc98d5c5 | ||
|
|
e919da3771 | ||
|
|
eda2dd20ee | ||
|
|
385fd95779 | ||
|
|
88185d7f6d | ||
|
|
253cda8cef | ||
|
|
b34afba7cd | ||
|
|
6c70049ecc | ||
|
|
42c2a52a6b | ||
|
|
19a6a5c608 | ||
|
|
d8e374fb76 | ||
|
|
84ca6185dc | ||
|
|
5088634a41 | ||
|
|
f6beedf0d5 | ||
|
|
3caf1ddb7d | ||
|
|
c622f6da4e | ||
|
|
9ec7394124 | ||
|
|
af8fde66b0 | ||
|
|
709315dde5 | ||
|
|
8856bae5c6 | ||
|
|
90451bfd47 | ||
|
|
0c05539dee | ||
|
|
a2a2c6cf3e | ||
|
|
76aa086d79 | ||
|
|
76fdfeaafc | ||
|
|
5932c78b88 | ||
|
|
68f5ca249f | ||
|
|
2d87a8d8c3 | ||
|
|
988d4103d4 | ||
|
|
ce3a1b8ba5 | ||
|
|
6c89d3c0c9 | ||
|
|
6b91fbf7f4 | ||
|
|
4f3f5e57b6 | ||
|
|
6b3f30e32f | ||
|
|
bdeedb4018 | ||
|
|
50946e087c | ||
|
|
7b89b04667 | ||
|
|
f5f84c5fa4 | ||
|
|
437831fa80 | ||
|
|
31f5b42962 | ||
|
|
7a6c872948 | ||
|
|
4bf18b1d65 | ||
|
|
2d25bf4afa | ||
|
|
56ae19c5ab | ||
|
|
cdf9197274 | ||
|
|
901549e8dd | ||
|
|
80b1cd19cb | ||
|
|
c4942de89b | ||
|
|
80d02f9cd1 | ||
|
|
671b22b5d6 | ||
|
|
43e56bf1c0 | ||
|
|
a175619623 | ||
|
|
63c11d9310 | ||
|
|
4c00b72ae3 | ||
|
|
f4db09a534 | ||
|
|
01cd64037f | ||
|
|
a93344386c | ||
|
|
a2195caa10 | ||
|
|
9ad78753bc | ||
|
|
517190e28b |
52
.air.toml
Normal file
52
.air.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = ".tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./dist/portainer"
|
||||
cmd = "SKIP_GO_GET=true make build-server"
|
||||
delay = 1000
|
||||
exclude_dir = []
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = "./dist/portainer --log-level=DEBUG"
|
||||
include_dir = ["api"]
|
||||
include_ext = ["go"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -1,3 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
test/
|
||||
127
.eslintrc.yml
127
.eslintrc.yml
@@ -1,127 +0,0 @@
|
||||
env:
|
||||
browser: true
|
||||
jquery: true
|
||||
node: true
|
||||
es6: true
|
||||
|
||||
globals:
|
||||
angular: true
|
||||
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
- import
|
||||
|
||||
parserOptions:
|
||||
ecmaVersion: 2018
|
||||
sourceType: module
|
||||
project: './tsconfig.json'
|
||||
ecmaFeatures:
|
||||
modules: true
|
||||
|
||||
rules:
|
||||
no-console: error
|
||||
no-alert: error
|
||||
no-control-regex: 'off'
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups:
|
||||
[
|
||||
{ pattern: '@@/**', group: 'internal', position: 'after' },
|
||||
{ pattern: '@/**', group: 'internal' },
|
||||
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
|
||||
],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
alias:
|
||||
map:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
- app/**/*.ts{,x}
|
||||
parserOptions:
|
||||
project: './tsconfig.json'
|
||||
parser: '@typescript-eslint/parser'
|
||||
plugins:
|
||||
- '@typescript-eslint'
|
||||
- 'regex'
|
||||
extends:
|
||||
- airbnb
|
||||
- airbnb-typescript
|
||||
- 'plugin:eslint-comments/recommended'
|
||||
- 'plugin:react-hooks/recommended'
|
||||
- 'plugin:react/jsx-runtime'
|
||||
- 'plugin:@typescript-eslint/recommended'
|
||||
- 'plugin:@typescript-eslint/eslint-recommended'
|
||||
- 'plugin:promise/recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- prettier # should be last
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
rules:
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
]
|
||||
no-plusplus: off
|
||||
func-style: [error, 'declaration']
|
||||
import/prefer-default-export: off
|
||||
no-use-before-define: 'off'
|
||||
'@typescript-eslint/no-use-before-define': ['error', { functions: false, 'allowNamedExports': true }]
|
||||
no-shadow: 'off'
|
||||
'@typescript-eslint/no-shadow': off
|
||||
jsx-a11y/no-autofocus: warn
|
||||
react/forbid-prop-types: off
|
||||
react/require-default-props: off
|
||||
react/no-array-index-key: off
|
||||
no-underscore-dangle: off
|
||||
react/jsx-filename-extension: [0]
|
||||
import/no-extraneous-dependencies: ['error', { devDependencies: true }]
|
||||
'@typescript-eslint/explicit-module-boundary-types': off
|
||||
'@typescript-eslint/no-unused-vars': 'error'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either', controlComponents: ['Input', 'Checkbox'] }]
|
||||
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
|
||||
'react/jsx-no-bind': off
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
|
||||
overrides: # allow props spreading for hoc files
|
||||
- files:
|
||||
- app/**/with*.ts{,x}
|
||||
rules:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
env:
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
'no-alert': off
|
||||
6
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
6
.github/DISCUSSION_TEMPLATE/ideas.yaml
vendored
@@ -3,13 +3,13 @@ body:
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
Thanks for suggesting an idea for Portainer!
|
||||
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion cagetory](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
Before opening a new idea or feature request, make sure that we do not have any duplicates already open. You can ensure this by [searching this discussion category](https://github.com/orgs/portainer/discussions/categories/ideas). If there is a duplicate, please add a comment to the existing idea instead.
|
||||
|
||||
Also, be sure to check our [knowledge base](https://portal.portainer.io/knowledge) and [documentation](https://docs.portainer.io) as they may point you toward a solution.
|
||||
|
||||
|
||||
**DO NOT FILE DUPLICATE REQUESTS.**
|
||||
|
||||
- type: textarea
|
||||
|
||||
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
57
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -2,16 +2,17 @@ name: Bug Report
|
||||
description: Create a report to help us improve.
|
||||
labels: kind/bug,bug/need-confirmation
|
||||
body:
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
# Welcome!
|
||||
|
||||
|
||||
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
|
||||
|
||||
|
||||
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
|
||||
|
||||
|
||||
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
|
||||
|
||||
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
|
||||
|
||||
- type: checkboxes
|
||||
@@ -21,7 +22,7 @@ body:
|
||||
options:
|
||||
- label: Yes, I've searched similar issues on [GitHub](https://github.com/portainer/portainer/issues).
|
||||
required: true
|
||||
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io) or [knowledge base](https://portal.portainer.io/knowledge).
|
||||
- label: Yes, I've checked whether this issue is covered in the Portainer [documentation](https://docs.portainer.io).
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
@@ -43,7 +44,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -69,7 +70,7 @@ body:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -90,23 +91,33 @@ body:
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Portainer version
|
||||
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.19.4'
|
||||
- '2.19.3'
|
||||
- '2.19.2'
|
||||
- '2.19.1'
|
||||
- '2.19.0'
|
||||
- '2.18.4'
|
||||
- '2.18.3'
|
||||
- '2.18.2'
|
||||
- '2.18.1'
|
||||
- '2.17.1'
|
||||
- '2.17.0'
|
||||
- '2.16.2'
|
||||
- '2.16.1'
|
||||
- '2.16.0'
|
||||
- '2.42.0'
|
||||
- '2.41.1'
|
||||
- '2.41.0'
|
||||
- '2.40.0'
|
||||
- '2.39.3'
|
||||
- '2.39.2'
|
||||
- '2.39.1'
|
||||
- '2.39.0'
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.8'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
- '2.33.3'
|
||||
- '2.33.2'
|
||||
- '2.33.1'
|
||||
- '2.33.0'
|
||||
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -144,7 +155,7 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser
|
||||
description: |
|
||||
description: |
|
||||
Enter your browser and version. Example: Google Chrome 114.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
86
.github/workflows/build-image.yml
vendored
Normal file
86
.github/workflows/build-image.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Build image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: ['v*']
|
||||
workflow_dispatch: {}
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/vvzvlad/portainer-ce
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install client dependencies
|
||||
# CI forces pnpm into --frozen-lockfile, which fails with
|
||||
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH because the committed lockfile lacks
|
||||
# the pnpmfileChecksum for the configDependencies in package.json.
|
||||
# Reconcile the lockfile explicitly; the later frozen install in
|
||||
# `make client-deps` then finds a matching lockfile. pnpm ignores the
|
||||
# npm_config_frozen_lockfile env var, so an explicit flag is required.
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Build client and server
|
||||
env:
|
||||
SKIP_GO_GET: "true"
|
||||
CONTAINER_IMAGE_TAG: ${{ steps.ver.outputs.version }}
|
||||
BUILDNUMBER: ${{ github.run_number }}
|
||||
# Pin the embedded commit to the full SHA so it matches the image
|
||||
# GIT_COMMIT build-arg and does not depend on the shallow checkout.
|
||||
GIT_COMMIT_HASH: ${{ github.sha }}
|
||||
# ENV=production selects webpack/webpack.production.js (minified bundle),
|
||||
# matching the official CE image; the Makefile default is development.
|
||||
run: make build-all ENV=production
|
||||
|
||||
- name: Ensure storybook directory exists
|
||||
# make build-all does not produce dist/storybook, but alpine.Dockerfile
|
||||
# has `COPY dist/storybook* /storybook/`; without a match the docker build fails.
|
||||
run: mkdir -p dist/storybook
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image (linux/amd64, alpine base)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: build/linux/alpine.Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.ver.outputs.version }}
|
||||
${{ env.IMAGE }}:latest
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ github.sha }}
|
||||
157
.github/workflows/ci.yaml
vendored
157
.github/workflows/ci.yaml
vendored
@@ -1,157 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- '!release/*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'develop'
|
||||
- 'release/*'
|
||||
- 'feat/*'
|
||||
- 'fix/*'
|
||||
- 'refactor/*'
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer
|
||||
NODE_ENV: testing
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
build_images:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: arc-runner-set
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: '[preparation] checkout the current branch'
|
||||
uses: actions/checkout@v3.5.3
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: '[preparation] set up golang'
|
||||
uses: actions/setup-go@v4.0.1
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
- name: '[preparation] cache paths'
|
||||
id: cache-dir-path
|
||||
run: |
|
||||
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
|
||||
- name: '[preparation] cache go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.cache-dir-path.outputs.go-build-dir }}
|
||||
${{ steps.cache-dir-path.outputs.go-mod-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up node.js'
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: ''
|
||||
- name: '[preparation] cache yarn'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up qemu'
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set the container image tag'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
|
||||
- name: '[execution] build linux & windows portainer binaries'
|
||||
run: |
|
||||
export YARN_VERSION=$(yarn --version)
|
||||
export WEBPACK_VERSION=$(yarn list webpack --depth=0 | grep webpack | awk -F@ '{print $2}')
|
||||
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
- name: '[execution] build and push docker images'
|
||||
run: |
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
mv dist/portainer dist/portainer.exe
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} --build-arg OSVERSION=${{ matrix.config.version }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
else
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
fi
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
build_manifests:
|
||||
runs-on: arc-runner-set
|
||||
if: github.event.pull_request.draft == false
|
||||
needs: [build_images]
|
||||
steps:
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker version && docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[execution] build and push manifests'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||
15
.github/workflows/label-conflcts.yaml
vendored
15
.github/workflows/label-conflcts.yaml
vendored
@@ -1,15 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 'release/**'
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mschilde/auto-label-merge-conflicts@master
|
||||
with:
|
||||
CONFLICT_LABEL_NAME: 'has conflicts'
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
MAX_RETRIES: 10
|
||||
WAIT_MS: 60000
|
||||
55
.github/workflows/lint.yml
vendored
55
.github/workflows/lint.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
- uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- run: yarn --frozen-lockfile
|
||||
- name: Run linters
|
||||
uses: wearerequired/lint-action@v1
|
||||
with:
|
||||
eslint: true
|
||||
eslint_extensions: ts,tsx,js,jsx
|
||||
prettier: true
|
||||
prettier_dir: app/
|
||||
gofmt: true
|
||||
gofmt_dir: api/
|
||||
- name: Typecheck
|
||||
uses: icrawl/action-tsc@v1
|
||||
- name: GolangCI-Lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.55.2
|
||||
args: --timeout=10m -c .golangci.yaml
|
||||
252
.github/workflows/nightly-security-scan.yml
vendored
252
.github/workflows/nightly-security-scan.yml
vendored
@@ -1,252 +0,0 @@
|
||||
name: Nightly Code Security Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 20 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
|
||||
jobs:
|
||||
client-dependencies:
|
||||
name: Client Dependency Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >- # only run for develop branch
|
||||
github.ref == 'refs/heads/develop'
|
||||
outputs:
|
||||
js: ${{ steps.set-matrix.outputs.js_result }}
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: scan vulnerabilities by Snyk
|
||||
uses: snyk/actions/node@master
|
||||
continue-on-error: true # To make sure that artifact upload gets called
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
json: true
|
||||
|
||||
- name: upload scan result as develop artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: js-security-scan-develop-result
|
||||
path: snyk.json
|
||||
|
||||
- name: develop scan report export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=table --export --export-filename="/data/js-result")
|
||||
|
||||
- name: upload html file as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-js-result-${{github.run_id}}
|
||||
path: js-result.html
|
||||
|
||||
- name: analyse vulnerabilities
|
||||
id: set-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=matrix)
|
||||
echo "js_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
server-dependencies:
|
||||
name: Server Dependency Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >- # only run for develop branch
|
||||
github.ref == 'refs/heads/develop'
|
||||
outputs:
|
||||
go: ${{ steps.set-matrix.outputs.go_result }}
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: install Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: download Go modules
|
||||
run: cd ./api && go get -t -v -d ./...
|
||||
|
||||
- name: scan vulnerabilities by Snyk
|
||||
continue-on-error: true # To make sure that artifact upload gets called
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
run: |
|
||||
yarn global add snyk
|
||||
snyk test --file=./go.mod --json-file-output=snyk.json 2>/dev/null || :
|
||||
|
||||
- name: upload scan result as develop artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: go-security-scan-develop-result
|
||||
path: snyk.json
|
||||
|
||||
- name: develop scan report export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=table --export --export-filename="/data/go-result")
|
||||
|
||||
- name: upload html file as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-go-result-${{github.run_id}}
|
||||
path: go-result.html
|
||||
|
||||
- name: analyse vulnerabilities
|
||||
id: set-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=snyk --path="/data/snyk.json" --output-type=matrix)
|
||||
echo "go_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
image-vulnerability:
|
||||
name: Image Vulnerability Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.ref == 'refs/heads/develop'
|
||||
outputs:
|
||||
image-trivy: ${{ steps.set-trivy-matrix.outputs.image_trivy_result }}
|
||||
image-docker-scout: ${{ steps.set-docker-scout-matrix.outputs.image_docker_scout_result }}
|
||||
steps:
|
||||
- name: scan vulnerabilities by Trivy
|
||||
uses: docker://docker.io/aquasec/trivy:latest
|
||||
continue-on-error: true
|
||||
with:
|
||||
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress portainerci/portainer:develop
|
||||
|
||||
- name: upload Trivy image security scan result as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: image-security-scan-develop-result
|
||||
path: image-trivy.json
|
||||
|
||||
- name: develop Trivy scan report export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=trivy --path="/data/image-trivy.json" --output-type=table --export --export-filename="/data/image-trivy-result")
|
||||
|
||||
- name: upload html file as Trivy artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-image-result-${{github.run_id}}
|
||||
path: image-trivy-result.html
|
||||
|
||||
- name: analyse vulnerabilities from Trivy
|
||||
id: set-trivy-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=trivy --path="/data/image-trivy.json" --output-type=matrix)
|
||||
echo "image_trivy_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: scan vulnerabilities by Docker Scout
|
||||
uses: docker/scout-action@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
command: cves
|
||||
image: portainerci/portainer:develop
|
||||
sarif-file: image-docker-scout.json
|
||||
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: upload Docker Scout image security scan result as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: image-security-scan-develop-result
|
||||
path: image-docker-scout.json
|
||||
|
||||
- name: develop Docker Scout scan report export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=docker-scout --path="/data/image-docker-scout.json" --output-type=table --export --export-filename="/data/image-docker-scout-result")
|
||||
|
||||
- name: upload html file as Docker Scout artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-image-result-${{github.run_id}}
|
||||
path: image-docker-scout-result.html
|
||||
|
||||
- name: analyse vulnerabilities from Docker Scout
|
||||
id: set-docker-scout-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest summary --report-type=docker-scout --path="/data/image-docker-scout.json" --output-type=matrix)
|
||||
echo "image_docker_scout_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
result-analysis:
|
||||
name: Analyse Scan Results
|
||||
needs: [client-dependencies, server-dependencies, image-vulnerability]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.ref == 'refs/heads/develop'
|
||||
strategy:
|
||||
matrix:
|
||||
js: ${{fromJson(needs.client-dependencies.outputs.js)}}
|
||||
go: ${{fromJson(needs.server-dependencies.outputs.go)}}
|
||||
image-trivy: ${{fromJson(needs.image-vulnerability.outputs.image-trivy)}}
|
||||
image-docker-scout: ${{fromJson(needs.image-vulnerability.outputs.image-docker-scout)}}
|
||||
steps:
|
||||
- name: display the results of js, Go, and image scan
|
||||
run: |
|
||||
echo "${{ matrix.js.status }}"
|
||||
echo "${{ matrix.go.status }}"
|
||||
echo "${{ matrix.image-trivy.status }}"
|
||||
echo "${{ matrix.image-docker-scout.status }}"
|
||||
echo "${{ matrix.js.summary }}"
|
||||
echo "${{ matrix.go.summary }}"
|
||||
echo "${{ matrix.image-trivy.summary }}"
|
||||
echo "${{ matrix.image-docker-scout.summary }}"
|
||||
|
||||
- name: send message to Slack
|
||||
if: >-
|
||||
matrix.js.status == 'failure' ||
|
||||
matrix.go.status == 'failure' ||
|
||||
matrix.image-trivy.status == 'failure' ||
|
||||
matrix.image-docker-scout.status == 'failure'
|
||||
uses: slackapi/slack-github-action@v1.23.0
|
||||
with:
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "Code Scanning Result (*${{ github.repository }}*)\n*<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|GitHub Actions Workflow URL>*"
|
||||
}
|
||||
}
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*JS dependency check*: *${{ matrix.js.status }}*\n${{ matrix.js.summary }}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Go dependency check*: *${{ matrix.go.status }}*\n${{ matrix.go.summary }}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image Trivy vulnerability check*: *${{ matrix.image-trivy.status }}*\n${{ matrix.image-trivy.summary }}\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "*Image Docker Scout vulnerability check*: *${{ matrix.image-docker-scout.status }}*\n${{ matrix.image-docker-scout.summary }}\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SECURITY_SLACK_WEBHOOK_URL }}
|
||||
SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK
|
||||
298
.github/workflows/pr-security.yml
vendored
298
.github/workflows/pr-security.yml
vendored
@@ -1,298 +0,0 @@
|
||||
name: PR Code Security Scan
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types:
|
||||
- submitted
|
||||
- edited
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'go.mod'
|
||||
- 'build/linux/Dockerfile'
|
||||
- 'build/linux/alpine.Dockerfile'
|
||||
- 'build/windows/Dockerfile'
|
||||
- '.github/workflows/pr-security.yml'
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
client-dependencies:
|
||||
name: Client Dependency Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
outputs:
|
||||
jsdiff: ${{ steps.set-diff-matrix.outputs.js_diff_result }}
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: scan vulnerabilities by Snyk
|
||||
uses: snyk/actions/node@master
|
||||
continue-on-error: true # To make sure that artifact upload gets called
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
json: true
|
||||
|
||||
- name: upload scan result as pull-request artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: js-security-scan-feat-result
|
||||
path: snyk.json
|
||||
|
||||
- name: download artifacts from develop branch built by nightly scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mv ./snyk.json ./js-snyk-feature.json
|
||||
(gh run download -n js-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||
if [[ -e ./snyk.json ]]; then
|
||||
mv ./snyk.json ./js-snyk-develop.json
|
||||
else
|
||||
echo "null" > ./js-snyk-develop.json
|
||||
fi
|
||||
|
||||
- name: pr vs develop scan report comparison export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/js-snyk-feature.json" --compare-to="/data/js-snyk-develop.json" --output-type=table --export --export-filename="/data/js-result")
|
||||
|
||||
- name: upload html file as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-js-result-compare-to-develop-${{github.run_id}}
|
||||
path: js-result.html
|
||||
|
||||
- name: analyse different vulnerabilities against develop branch
|
||||
id: set-diff-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/js-snyk-feature.json" --compare-to="/data/js-snyk-develop.json" --output-type=matrix)
|
||||
echo "js_diff_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
server-dependencies:
|
||||
name: Server Dependency Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
outputs:
|
||||
godiff: ${{ steps.set-diff-matrix.outputs.go_diff_result }}
|
||||
steps:
|
||||
- name: checkout repository
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: install Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: download Go modules
|
||||
run: cd ./api && go get -t -v -d ./...
|
||||
|
||||
- name: scan vulnerabilities by Snyk
|
||||
continue-on-error: true # To make sure that artifact upload gets called
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
run: |
|
||||
yarn global add snyk
|
||||
snyk test --file=./go.mod --json-file-output=snyk.json 2>/dev/null || :
|
||||
|
||||
- name: upload scan result as pull-request artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: go-security-scan-feature-result
|
||||
path: snyk.json
|
||||
|
||||
- name: download artifacts from develop branch built by nightly scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mv ./snyk.json ./go-snyk-feature.json
|
||||
(gh run download -n go-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||
if [[ -e ./snyk.json ]]; then
|
||||
mv ./snyk.json ./go-snyk-develop.json
|
||||
else
|
||||
echo "null" > ./go-snyk-develop.json
|
||||
fi
|
||||
|
||||
- name: pr vs develop scan report comparison export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/go-snyk-feature.json" --compare-to="/data/go-snyk-develop.json" --output-type=table --export --export-filename="/data/go-result")
|
||||
|
||||
- name: upload html file as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-go-result-compare-to-develop-${{github.run_id}}
|
||||
path: go-result.html
|
||||
|
||||
- name: analyse different vulnerabilities against develop branch
|
||||
id: set-diff-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=snyk --path="/data/go-snyk-feature.json" --compare-to="/data/go-snyk-develop.json" --output-type=matrix)
|
||||
echo "go_diff_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
image-vulnerability:
|
||||
name: Image Vulnerability Check
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
outputs:
|
||||
imagediff-trivy: ${{ steps.set-diff-trivy-matrix.outputs.image_diff_trivy_result }}
|
||||
imagediff-docker-scout: ${{ steps.set-diff-docker-scout-matrix.outputs.image_diff_docker_scout_result }}
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: install Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install packages
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: build
|
||||
run: make build-all
|
||||
|
||||
- name: set up docker buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: build and compress image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: build/linux/Dockerfile
|
||||
tags: local-portainer:${{ github.sha }}
|
||||
outputs: type=docker,dest=/tmp/local-portainer-image.tar
|
||||
|
||||
- name: load docker image
|
||||
run: |
|
||||
docker load --input /tmp/local-portainer-image.tar
|
||||
|
||||
- name: scan vulnerabilities by Trivy
|
||||
uses: docker://docker.io/aquasec/trivy:latest
|
||||
continue-on-error: true
|
||||
with:
|
||||
args: image --ignore-unfixed=true --vuln-type="os,library" --exit-code=1 --format="json" --output="image-trivy.json" --no-progress local-portainer:${{ github.sha }}
|
||||
|
||||
- name: upload Trivy image security scan result as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: image-security-scan-feature-result
|
||||
path: image-trivy.json
|
||||
|
||||
- name: download Trivy artifacts from develop branch built by nightly scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mv ./image-trivy.json ./image-trivy-feature.json
|
||||
(gh run download -n image-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||
if [[ -e ./image-trivy.json ]]; then
|
||||
mv ./image-trivy.json ./image-trivy-develop.json
|
||||
else
|
||||
echo "null" > ./image-trivy-develop.json
|
||||
fi
|
||||
|
||||
- name: pr vs develop Trivy scan report comparison export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=trivy --path="/data/image-trivy-feature.json" --compare-to="/data/image-trivy-develop.json" --output-type=table --export --export-filename="/data/image-trivy-result")
|
||||
|
||||
- name: upload html file as Trivy artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-image-result-compare-to-develop-${{github.run_id}}
|
||||
path: image-trivy-result.html
|
||||
|
||||
- name: analyse different vulnerabilities against develop branch by Trivy
|
||||
id: set-diff-trivy-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=trivy --path="/data/image-trivy-feature.json" --compare-to="/data/image-trivy-develop.json" --output-type=matrix)
|
||||
echo "image_diff_trivy_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: scan vulnerabilities by Docker Scout
|
||||
uses: docker/scout-action@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
command: cves
|
||||
image: local-portainer:${{ github.sha }}
|
||||
sarif-file: image-docker-scout.json
|
||||
dockerhub-user: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
dockerhub-password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: upload Docker Scout image security scan result as artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: image-security-scan-feature-result
|
||||
path: image-docker-scout.json
|
||||
|
||||
- name: download Docker Scout artifacts from develop branch built by nightly scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mv ./image-docker-scout.json ./image-docker-scout-feature.json
|
||||
(gh run download -n image-security-scan-develop-result -R ${{ github.repository }} 2>&1 >/dev/null) || :
|
||||
if [[ -e ./image-docker-scout.json ]]; then
|
||||
mv ./image-docker-scout.json ./image-docker-scout-develop.json
|
||||
else
|
||||
echo "null" > ./image-docker-scout-develop.json
|
||||
fi
|
||||
|
||||
- name: pr vs develop Docker Scout scan report comparison export to html
|
||||
run: |
|
||||
$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=docker-scout --path="/data/image-docker-scout-feature.json" --compare-to="/data/image-docker-scout-develop.json" --output-type=table --export --export-filename="/data/image-docker-scout-result")
|
||||
|
||||
- name: upload html file as Docker Scout artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: html-image-result-compare-to-develop-${{github.run_id}}
|
||||
path: image-docker-scout-result.html
|
||||
|
||||
- name: analyse different vulnerabilities against develop branch by Docker Scout
|
||||
id: set-diff-docker-scout-matrix
|
||||
run: |
|
||||
result=$(docker run --rm -v ${{ github.workspace }}:/data portainerci/code-security-report:latest diff --report-type=docker-scout --path="/data/image-docker-scout-feature.json" --compare-to="/data/image-docker-scout-develop.json" --output-type=matrix)
|
||||
echo "image_diff_docker_scout_result=${result}" >> $GITHUB_OUTPUT
|
||||
|
||||
result-analysis:
|
||||
name: Analyse Scan Result Against develop Branch
|
||||
needs: [client-dependencies, server-dependencies, image-vulnerability]
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.pull_request &&
|
||||
github.event.review.body == '/scan' &&
|
||||
github.event.pull_request.draft == false
|
||||
strategy:
|
||||
matrix:
|
||||
jsdiff: ${{fromJson(needs.client-dependencies.outputs.jsdiff)}}
|
||||
godiff: ${{fromJson(needs.server-dependencies.outputs.godiff)}}
|
||||
imagediff-trivy: ${{fromJson(needs.image-vulnerability.outputs.imagediff-trivy)}}
|
||||
imagediff-docker-scout: ${{fromJson(needs.image-vulnerability.outputs.imagediff-docker-scout)}}
|
||||
steps:
|
||||
- name: check job status of diff result
|
||||
if: >-
|
||||
matrix.jsdiff.status == 'failure' ||
|
||||
matrix.godiff.status == 'failure' ||
|
||||
matrix.imagediff-trivy.status == 'failure' ||
|
||||
matrix.imagediff-docker-scout.status == 'failure'
|
||||
run: |
|
||||
echo "${{ matrix.jsdiff.status }}"
|
||||
echo "${{ matrix.godiff.status }}"
|
||||
echo "${{ matrix.imagediff-trivy.status }}"
|
||||
echo "${{ matrix.imagediff-docker-scout.status }}"
|
||||
echo "${{ matrix.jsdiff.summary }}"
|
||||
echo "${{ matrix.godiff.summary }}"
|
||||
echo "${{ matrix.imagediff-trivy.summary }}"
|
||||
echo "${{ matrix.imagediff-docker-scout.summary }}"
|
||||
exit 1
|
||||
19
.github/workflows/rebase.yml
vendored
19
.github/workflows/rebase.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Automatic Rebase
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@1.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
28
.github/workflows/stale.yml
vendored
28
.github/workflows/stale.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Close Stale Issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Issue Config
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 7
|
||||
stale-issue-label: 'status/stale'
|
||||
exempt-all-issue-milestones: true # Do not stale issues in a milestone
|
||||
exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss
|
||||
stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.'
|
||||
close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.'
|
||||
|
||||
# Pull Request Config
|
||||
days-before-pr-stale: -1 # Do not stale pull request
|
||||
days-before-pr-close: -1 # Do not close pull request
|
||||
48
.github/workflows/test.yaml
vendored
48
.github/workflows/test.yaml
vendored
@@ -1,48 +0,0 @@
|
||||
name: Test
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
|
||||
jobs:
|
||||
test-client:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
|
||||
test-server:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
- name: Run tests
|
||||
run: make test-server
|
||||
39
.github/workflows/validate-openapi-spec.yaml
vendored
39
.github/workflows/validate-openapi-spec.yaml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Validate OpenAPI specs
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- 'release/*'
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
openapi-spec:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Download golang modules
|
||||
run: cd ./api && go get -t -v -d ./...
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'yarn'
|
||||
- run: yarn --frozen-lockfile
|
||||
|
||||
- name: Validate OpenAPI Spec
|
||||
run: make docs-validate
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,7 @@ dist
|
||||
portainer-checksum.txt
|
||||
api/cmd/portainer/portainer*
|
||||
storybook-static
|
||||
debug-storybook.log
|
||||
.tmp
|
||||
**/.vscode/settings.json
|
||||
**/.vscode/tasks.json
|
||||
@@ -18,3 +19,5 @@ api/docs
|
||||
.env
|
||||
go.work.sum
|
||||
|
||||
.vitest
|
||||
|
||||
|
||||
13
.golangci-forward.yaml
Normal file
13
.golangci-forward.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: '2'
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- forbidigo
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
- pattern: ^(filepath|path)\.Join$
|
||||
msg: Use filesystem.JoinPaths() from github.com/portainer/portainer/api/filesystem to prevent path traversal attacks
|
||||
analyze-types: true
|
||||
156
.golangci.yaml
156
.golangci.yaml
@@ -1,40 +1,126 @@
|
||||
version: '2'
|
||||
|
||||
run:
|
||||
allow-parallel-runners: true
|
||||
linters:
|
||||
# Disable all linters, the defaults don't pass on our code yet
|
||||
disable-all: true
|
||||
|
||||
# Enable these for now
|
||||
default: none
|
||||
enable:
|
||||
- unused
|
||||
- gocritic
|
||||
- bodyclose
|
||||
- copyloopvar
|
||||
- depguard
|
||||
- gosimple
|
||||
- govet
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
|
||||
linters-settings:
|
||||
depguard:
|
||||
- forbidigo
|
||||
- govet
|
||||
- ineffassign
|
||||
- intrange
|
||||
- perfsprint
|
||||
- staticcheck
|
||||
- unused
|
||||
- mirror
|
||||
- durationcheck
|
||||
- errorlint
|
||||
- govet
|
||||
- usetesting
|
||||
- zerologlint
|
||||
- testifylint
|
||||
- modernize
|
||||
- unconvert
|
||||
- unused
|
||||
- zerologlint
|
||||
- exptostd
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ['all', '-ST1003', '-ST1005', '-ST1016', '-SA1019', '-QF1003']
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
files:
|
||||
- '!**/*_test.go'
|
||||
- '!**/base.go'
|
||||
- '!**/base_tx.go'
|
||||
deny:
|
||||
- pkg: encoding/json
|
||||
desc: use github.com/segmentio/encoding/json
|
||||
- pkg: golang.org/x/exp
|
||||
desc: exp is not allowed
|
||||
- pkg: github.com/portainer/libcrypto
|
||||
desc: use github.com/portainer/portainer/pkg/libcrypto
|
||||
- pkg: github.com/portainer/libhttp
|
||||
desc: use github.com/portainer/portainer/pkg/libhttp
|
||||
- pkg: golang.org/x/crypto
|
||||
desc: golang.org/x/crypto is not allowed because of FIPS mode
|
||||
- pkg: github.com/ProtonMail/go-crypto/openpgp
|
||||
desc: github.com/ProtonMail/go-crypto/openpgp is not allowed because of FIPS mode
|
||||
- pkg: github.com/cosi-project/runtime
|
||||
desc: github.com/cosi-project/runtime is not allowed because of FIPS mode
|
||||
- pkg: gopkg.in/yaml.v2
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: gopkg.in/yaml.v3
|
||||
desc: use go.yaml.in/yaml/v3 instead
|
||||
- pkg: github.com/golang-jwt/jwt/v4
|
||||
desc: use github.com/golang-jwt/jwt/v5 instead
|
||||
- pkg: github.com/mitchellh/mapstructure
|
||||
desc: use github.com/go-viper/mapstructure/v2 instead
|
||||
- pkg: gopkg.in/alecthomas/kingpin.v2
|
||||
desc: use github.com/alecthomas/kingpin/v2 instead
|
||||
- pkg: github.com/jcmturner/gokrb5$
|
||||
desc: use github.com/jcmturner/gokrb5/v8 instead
|
||||
- pkg: github.com/gofrs/uuid
|
||||
desc: use github.com/google/uuid
|
||||
- pkg: github.com/Masterminds/semver$
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/blang/semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/coreos/go-semver
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
- pkg: github.com/hashicorp/go-version
|
||||
desc: use github.com/Masterminds/semver/v3
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
- ruleguard
|
||||
settings:
|
||||
ruleguard:
|
||||
rules: './analysis/ssrf.go,./analysis/git.go'
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^tls\.Config$
|
||||
msg: Use crypto.CreateTLSConfiguration() instead
|
||||
- pattern: ^tls\.Config\.(InsecureSkipVerify|MinVersion|MaxVersion|CipherSuites|CurvePreferences)$
|
||||
msg: Do not set this field directly, use crypto.CreateTLSConfiguration() instead
|
||||
- pattern: ^object\.(Commit|Tag)\.Verify$
|
||||
msg: 'Not allowed because of FIPS mode'
|
||||
- pattern: ^(types\.SystemContext\.)?(DockerDaemonInsecureSkipTLSVerify|DockerInsecureSkipTLSVerify|OCIInsecureSkipTLSVerify)$
|
||||
msg: 'Not allowed because of FIPS mode'
|
||||
- pattern: ^git\.PlainClone(Context|WithOptions)?$
|
||||
msg: Use git.CloneContext with NewNoSymlinkFS to prevent symlink traversal attacks
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
rules:
|
||||
main:
|
||||
deny:
|
||||
- pkg: 'encoding/json'
|
||||
desc: 'use github.com/segmentio/encoding/json'
|
||||
- pkg: 'github.com/sirupsen/logrus'
|
||||
desc: 'logging is allowed only by github.com/rs/zerolog'
|
||||
- pkg: 'golang.org/x/exp'
|
||||
desc: 'exp is not allowed'
|
||||
- pkg: 'github.com/portainer/libcrypto'
|
||||
desc: 'use github.com/portainer/portainer/pkg/libcrypto'
|
||||
- pkg: 'github.com/portainer/libhttp'
|
||||
desc: 'use github.com/portainer/portainer/pkg/libhttp'
|
||||
files:
|
||||
- '!**/*_test.go'
|
||||
- '!**/base.go'
|
||||
- '!**/base_tx.go'
|
||||
|
||||
# errorlint is causing a typecheck error for some reason. The go compiler will report these
|
||||
# anyway, so ignore them from the linter
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: ./
|
||||
linters:
|
||||
- typecheck
|
||||
- path: pkg/libhttp/ssrf
|
||||
linters:
|
||||
- gocritic
|
||||
text: ruleguard
|
||||
- path: pkg/libhttp/ssrf/builder\.go
|
||||
linters:
|
||||
- forbidigo
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint-staged
|
||||
cd $(dirname -- "$0") && pnpm lint-staged
|
||||
@@ -1,2 +1,5 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
15
.prettierrc
15
.prettierrc
@@ -5,21 +5,18 @@
|
||||
"trailingComma": "es5",
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"*.html"
|
||||
],
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"*.{j,t}sx",
|
||||
"*.ts"
|
||||
],
|
||||
"files": ["*.{j,t}sx", "*.ts"],
|
||||
"options": {
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["clsx"]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,56 @@
|
||||
// This file has been automatically migrated to valid ESM format by Storybook.
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
import path, { dirname } from 'path';
|
||||
|
||||
import { StorybookConfig } from '@storybook/react-webpack5';
|
||||
|
||||
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
|
||||
import { Configuration } from 'webpack';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../app/**/*.stories.@(ts|tsx)'],
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'@storybook/addon-docs',
|
||||
],
|
||||
webpackFinal: (config) => {
|
||||
const rules = config?.module?.rules || [];
|
||||
@@ -67,12 +93,7 @@ const config: StorybookConfig = {
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
plugins: [
|
||||
...(config.resolve?.plugins || []),
|
||||
new TsconfigPathsPlugin({
|
||||
extensions: config.resolve?.extensions,
|
||||
}),
|
||||
],
|
||||
tsconfig: path.resolve(__dirname, '..', 'tsconfig.json'),
|
||||
},
|
||||
module: {
|
||||
...config.module,
|
||||
@@ -82,12 +103,13 @@ const config: StorybookConfig = {
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
typescript: {
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
reactDocgen: 'react-docgen',
|
||||
},
|
||||
framework: {
|
||||
name: '@storybook/react-webpack5',
|
||||
options: {},
|
||||
},
|
||||
docs: {},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect } from 'react';
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react-webpack5';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
console.log(method, url);
|
||||
if (url.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
|
||||
@@ -22,31 +22,65 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
];
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: 'Portainer color theme',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'paintbrush',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light', icon: 'sun' },
|
||||
{ value: 'dark', title: 'Dark', icon: 'moon' },
|
||||
{ value: 'highcontrast', title: 'High Contrast', icon: 'eye' },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
theme: 'light',
|
||||
},
|
||||
decorators: (Story, context) => {
|
||||
const theme = context.globals.theme;
|
||||
|
||||
export const loaders = [mswLoader];
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.removeAttribute('theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('theme', theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
order: ['Design System', 'Components', '*'],
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker (2.0.11).
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
|
||||
const PACKAGE_VERSION = '2.12.10';
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82';
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
|
||||
const activeClientIds = new Set();
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id;
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id');
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return;
|
||||
@@ -48,7 +48,10 @@ self.addEventListener('message', async function (event) {
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: INTEGRITY_CHECKSUM,
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -58,16 +61,16 @@ self.addEventListener('message', async function (event) {
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: true,
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId);
|
||||
|
||||
@@ -85,72 +88,91 @@ self.addEventListener('message', async function (event) {
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event;
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now();
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
if (event.request.mode === 'navigate') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID();
|
||||
event.respondWith(handleRequest(event, requestId));
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
|
||||
});
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event);
|
||||
const response = await getResponse(event, client, requestId);
|
||||
const requestCloneForEvents = event.request.clone();
|
||||
const response = await getResponse(event, client, requestId, requestInterceptedAt);
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
(async function () {
|
||||
const responseClone = response.clone();
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents);
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone();
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
[responseClone.body]
|
||||
);
|
||||
})();
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : []
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId);
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client;
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client;
|
||||
}
|
||||
@@ -171,20 +193,37 @@ async function resolveMainClient(event) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event;
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone();
|
||||
const requestClone = event.request.clone();
|
||||
|
||||
function passthrough() {
|
||||
const headers = Object.fromEntries(requestClone.headers.entries());
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers);
|
||||
|
||||
// Remove internal MSW request header so the passthrough request
|
||||
// complies with any potential CORS preflight checks on the server.
|
||||
// Some servers forbid unknown request headers.
|
||||
delete headers['x-msw-intention'];
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept');
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim());
|
||||
const filteredValues = values.filter((value) => value !== 'msw/passthrough');
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '));
|
||||
} else {
|
||||
headers.delete('accept');
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers });
|
||||
}
|
||||
@@ -202,37 +241,19 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Bypass requests with the explicit bypass header.
|
||||
// Such requests can be issued by "ctx.fetch()".
|
||||
const mswIntention = request.headers.get('x-msw-intention');
|
||||
if (['bypass', 'passthrough'].includes(mswIntention)) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer();
|
||||
const serializedRequest = await serializeRequest(event.request);
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[requestBuffer]
|
||||
[serializedRequest.body]
|
||||
);
|
||||
|
||||
switch (clientMessage.type) {
|
||||
@@ -240,7 +261,7 @@ async function getResponse(event, client, requestId) {
|
||||
return respondWithMock(clientMessage.data);
|
||||
}
|
||||
|
||||
case 'MOCK_NOT_FOUND': {
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough();
|
||||
}
|
||||
}
|
||||
@@ -248,6 +269,12 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
@@ -260,11 +287,15 @@ function sendToClient(client, message, transferrables = []) {
|
||||
resolve(event.data);
|
||||
};
|
||||
|
||||
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
|
||||
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
|
||||
});
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
@@ -282,3 +313,24 @@ async function respondWithMock(response) {
|
||||
|
||||
return mockedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceRoot}/api/cmd/portainer",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"env": {},
|
||||
"showLog": true,
|
||||
"args": ["--data", "${env:HOME}/portainer-data", "--assets", "${workspaceRoot}/dist"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
{
|
||||
// Place your portainer workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
|
||||
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
|
||||
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
|
||||
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
|
||||
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
|
||||
// Placeholders with the same ids are connected.
|
||||
// Example:
|
||||
// "Print to console": {
|
||||
// "scope": "javascript,typescript",
|
||||
// "prefix": "log",
|
||||
// "body": [
|
||||
// "console.log('$1');",
|
||||
// "$2"
|
||||
// ],
|
||||
// "description": "Log output to console"
|
||||
// }
|
||||
"React Named Export Component": {
|
||||
"prefix": "rnec",
|
||||
"body": [
|
||||
"export function $TM_FILENAME_BASE() {",
|
||||
" return <div>$TM_FILENAME_BASE</div>;",
|
||||
"}"
|
||||
],
|
||||
"description": "React Named Export Component"
|
||||
},
|
||||
"Component": {
|
||||
"scope": "javascript",
|
||||
"prefix": "mycomponent",
|
||||
"description": "Dummy Angularjs Component",
|
||||
"body": [
|
||||
"import angular from 'angular';",
|
||||
"import controller from './${TM_FILENAME_BASE}Controller'",
|
||||
"",
|
||||
"angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').component('$TM_FILENAME_BASE', {",
|
||||
" templateUrl: './$TM_FILENAME_BASE.html',",
|
||||
" controller,",
|
||||
"});",
|
||||
""
|
||||
]
|
||||
},
|
||||
"Controller": {
|
||||
"scope": "javascript",
|
||||
"prefix": "mycontroller",
|
||||
"body": [
|
||||
"class ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/} {",
|
||||
"\t/* @ngInject */",
|
||||
"\tconstructor($0) {",
|
||||
"\t}",
|
||||
"}",
|
||||
"",
|
||||
"export default ${TM_FILENAME_BASE/(.*)/${1:/capitalize}/};"
|
||||
],
|
||||
"description": "Dummy ES6+ controller"
|
||||
},
|
||||
"Service": {
|
||||
"scope": "javascript",
|
||||
"prefix": "myservice",
|
||||
"description": "Dummy ES6+ service",
|
||||
"body": [
|
||||
"import angular from 'angular';",
|
||||
"import PortainerError from 'Portainer/error';",
|
||||
"",
|
||||
"class $1 {",
|
||||
" /* @ngInject */",
|
||||
" constructor(\\$async, $0) {",
|
||||
" this.\\$async = \\$async;",
|
||||
"",
|
||||
" this.getAsync = this.getAsync.bind(this);",
|
||||
" this.getAllAsync = this.getAllAsync.bind(this);",
|
||||
" this.createAsync = this.createAsync.bind(this);",
|
||||
" this.updateAsync = this.updateAsync.bind(this);",
|
||||
" this.deleteAsync = this.deleteAsync.bind(this);",
|
||||
" }",
|
||||
"",
|
||||
" /**",
|
||||
" * GET",
|
||||
" */",
|
||||
" async getAsync() {",
|
||||
" try {",
|
||||
"",
|
||||
" } catch (err) {",
|
||||
" throw new PortainerError('', err);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" async getAllAsync() {",
|
||||
" try {",
|
||||
"",
|
||||
" } catch (err) {",
|
||||
" throw new PortainerError('', err);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" get() {",
|
||||
" if () {",
|
||||
" return this.\\$async(this.getAsync);",
|
||||
" }",
|
||||
" return this.\\$async(this.getAllAsync);",
|
||||
" }",
|
||||
"",
|
||||
" /**",
|
||||
" * CREATE",
|
||||
" */",
|
||||
" async createAsync() {",
|
||||
" try {",
|
||||
"",
|
||||
" } catch (err) {",
|
||||
" throw new PortainerError('', err);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" create() {",
|
||||
" return this.\\$async(this.createAsync);",
|
||||
" }",
|
||||
"",
|
||||
" /**",
|
||||
" * UPDATE",
|
||||
" */",
|
||||
" async updateAsync() {",
|
||||
" try {",
|
||||
"",
|
||||
" } catch (err) {",
|
||||
" throw new PortainerError('', err);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" update() {",
|
||||
" return this.\\$async(this.updateAsync);",
|
||||
" }",
|
||||
"",
|
||||
" /**",
|
||||
" * DELETE",
|
||||
" */",
|
||||
" async deleteAsync() {",
|
||||
" try {",
|
||||
"",
|
||||
" } catch (err) {",
|
||||
" throw new PortainerError('', err);",
|
||||
" }",
|
||||
" }",
|
||||
"",
|
||||
" delete() {",
|
||||
" return this.\\$async(this.deleteAsync);",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"export default $1;",
|
||||
"angular.module('portainer.${TM_DIRECTORY/.*\\/app\\/([^\\/]*)(\\/.*)?$/$1/}').service('$1', $1);"
|
||||
]
|
||||
},
|
||||
"swagger-api-doc": {
|
||||
"prefix": "swapi",
|
||||
"scope": "go",
|
||||
"description": "Snippet for a api doc",
|
||||
"body": [
|
||||
"// @id ",
|
||||
"// @summary ",
|
||||
"// @description ",
|
||||
"// @description **Access policy**: ",
|
||||
"// @tags ",
|
||||
"// @security ApiKeyAuth",
|
||||
"// @security jwt",
|
||||
"// @accept json",
|
||||
"// @produce json",
|
||||
"// @param id path int true \"identifier\"",
|
||||
"// @param body body Object true \"details\"",
|
||||
"// @success 200 {object} portainer. \"Success\"",
|
||||
"// @success 204 \"Success\"",
|
||||
"// @failure 400 \"Invalid request\"",
|
||||
"// @failure 403 \"Permission denied\"",
|
||||
"// @failure 404 \" not found\"",
|
||||
"// @failure 500 \"Server error\"",
|
||||
"// @router /{id} [get]"
|
||||
]
|
||||
},
|
||||
"analytics": {
|
||||
"prefix": "nlt",
|
||||
"body": ["analytics-on", "analytics-category=\"$1\"", "analytics-event=\"$2\""],
|
||||
"description": "analytics"
|
||||
},
|
||||
"analytics-if": {
|
||||
"prefix": "nltf",
|
||||
"body": ["analytics-if=\"$1\""],
|
||||
"description": "analytics"
|
||||
},
|
||||
"analytics-metadata": {
|
||||
"prefix": "nltm",
|
||||
"body": "analytics-properties=\"{ metadata: { $1 } }\""
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast", "-E", "exportloopref"],
|
||||
"gopls": {
|
||||
"build.expandWorkspaceToModule": false
|
||||
},
|
||||
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"]
|
||||
}
|
||||
59
CLAUDE.md
Normal file
59
CLAUDE.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
## Project Structure
|
||||
|
||||
For a detailed breakdown of frontend and backend directory layout, feature locations, and common development tasks, see [docs/guidelines/project-structure.md](../../docs/guidelines/project-structure.md).
|
||||
|
||||
## Frontend Guidelines
|
||||
|
||||
- [docs/guidelines/frontend-conventions.md](../../docs/guidelines/frontend-conventions.md) — component structure, React Query patterns, shared components, forms, theming
|
||||
- [docs/guidelines/typescript-conventions.md](../../docs/guidelines/typescript-conventions.md) — types, anti-patterns, union types, named constants
|
||||
- [docs/guidelines/frontend-unit-testing.md](../../docs/guidelines/frontend-unit-testing.md) — Vitest, React Testing Library
|
||||
|
||||
## Backend Guidelines
|
||||
|
||||
- [docs/guidelines/go-conventions.md](../../docs/guidelines/go-conventions.md) — error handling, naming, testing, code style
|
||||
- [docs/guidelines/server-architecture.md](../../docs/guidelines/server-architecture.md) — Clean Architecture layers, transactions, CE/EE sharing patterns
|
||||
- [docs/guidelines/logging.md](../../docs/guidelines/logging.md) — zerolog usage, log levels, message style
|
||||
- [docs/guidelines/backend-code-reusability.md](../../docs/guidelines/backend-code-reusability.md) — how CE and EE share backend code
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.26.1 (for backend)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
make build # Build both client and server
|
||||
make build-client # Build React/AngularJS frontend
|
||||
make build-server # Build Go binary
|
||||
make build-image # Build Docker image
|
||||
|
||||
# Development
|
||||
make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
# Frontend
|
||||
pnpm dev # Webpack dev server
|
||||
pnpm build # Build frontend with webpack
|
||||
pnpm typecheck # Run typecheck for frontend (with tsc)
|
||||
pnpm lint # lint frontend (with eslint)
|
||||
pnpm test # test frontend (with vitest)
|
||||
pnpm format # format frontend (with prettier)
|
||||
|
||||
# Testing
|
||||
make test # All tests (backend + frontend)
|
||||
make test-server # Backend tests only
|
||||
make lint # Lint all code
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Development Servers
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
@@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
- Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
@@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at contribute@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ The feature request process is similar to the bug report process but has an extr
|
||||
|
||||
## Build and run Portainer locally
|
||||
|
||||
Ensure you have Docker, Node.js, yarn, and Golang installed in the correct versions.
|
||||
Ensure you have Docker, Node.js, pnpm, and Golang installed in the correct versions.
|
||||
|
||||
Install dependencies:
|
||||
|
||||
@@ -147,7 +147,9 @@ When adding a new route to an existing handler use the following as a template (
|
||||
// @router /{id} [get]
|
||||
```
|
||||
|
||||
explanation about each line can be found (here)[https://github.com/swaggo/swag#api-operation]
|
||||
explanation about each line can be found [here](https://github.com/swaggo/swag#api-operation)
|
||||
|
||||
After changing these annotations, regenerate the TypeScript API client and types — see [Generating API types](./README.md#generating-api-types).
|
||||
|
||||
## Licensing
|
||||
|
||||
|
||||
91
Makefile
91
Makefile
@@ -1,57 +1,50 @@
|
||||
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
|
||||
# For a list of valid GOOS and GOARCH values
|
||||
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
|
||||
PLATFORM=$(shell go env GOOS)
|
||||
ARCH=$(shell go env GOARCH)
|
||||
|
||||
# build target, can be one of "production", "testing", "development"
|
||||
ENV=development
|
||||
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
|
||||
TAG=local
|
||||
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@latest
|
||||
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
GOTESTSUM_VERSION?=v1.13.0
|
||||
GOTESTSUM=go run gotest.tools/gotestsum@$(GOTESTSUM_VERSION)
|
||||
GOLANGCI_LINT_VERSION := $(shell cat $(shell git rev-parse --show-toplevel)/.golangci-version)
|
||||
|
||||
# Don't change anything below this line unless you know what you're doing
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
|
||||
##@ Building
|
||||
.PHONY: init-dist build-storybook build build-client build-server build-image devops
|
||||
.PHONY: all init-dist build-storybook build build-client build-server build-image devops
|
||||
init-dist:
|
||||
@mkdir -p dist
|
||||
|
||||
build-all: deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||
all: tidy deps build-server build-client ## Build the client, server and download external dependancies (doesn't build an image)
|
||||
|
||||
build-all: all ## Alias for the 'all' target (used by CI)
|
||||
|
||||
build-client: init-dist ## Build the client
|
||||
export NODE_ENV=$(ENV) && yarn build --config $(WEBPACK_CONFIG)
|
||||
export NODE_ENV=$(ENV) && pnpm run build --config $(WEBPACK_CONFIG)
|
||||
|
||||
build-server: init-dist ## Build the server binary
|
||||
./build/build_binary.sh "$(PLATFORM)" "$(ARCH)"
|
||||
|
||||
build-image: build-all ## Build the Portainer image locally
|
||||
docker buildx build --load -t portainerci/portainer:$(TAG) -f build/linux/Dockerfile .
|
||||
docker buildx build --load -t portainerci/portainer-ce:$(TAG) -f build/linux/Dockerfile .
|
||||
|
||||
build-storybook: ## Build and serve the storybook files
|
||||
yarn storybook:build
|
||||
|
||||
devops: clean deps build-client ## Build the everything target specifically for CI
|
||||
echo "Building the devops binary..."
|
||||
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
|
||||
pnpm run storybook:build
|
||||
|
||||
##@ Build dependencies
|
||||
.PHONY: deps server-deps client-deps tidy
|
||||
deps: server-deps client-deps ## Download all client and server build dependancies
|
||||
|
||||
## This is empty because the pipeline requires it but ce has no server deps
|
||||
server-deps: init-dist ## Download dependant server binaries
|
||||
@./build/download_binaries.sh $(PLATFORM) $(ARCH)
|
||||
|
||||
client-deps: ## Install client dependencies
|
||||
yarn
|
||||
pnpm install
|
||||
|
||||
tidy: ## Tidy up the go.mod file
|
||||
cd api && go mod tidy
|
||||
|
||||
@go mod tidy
|
||||
|
||||
##@ Cleanup
|
||||
.PHONY: clean
|
||||
@@ -59,29 +52,32 @@ clean: ## Remove all build and download artifacts
|
||||
@echo "Clearing the dist directory..."
|
||||
@rm -rf dist/*
|
||||
|
||||
|
||||
##@ Testing
|
||||
.PHONY: test test-client test-server
|
||||
test: test-server test-client ## Run all tests
|
||||
|
||||
test-client: ## Run client tests
|
||||
yarn test $(ARGS)
|
||||
pnpm run test $(ARGS) --coverage
|
||||
|
||||
TEST_PACKAGES?=./...
|
||||
|
||||
test-server: ## Run server tests
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover ./...
|
||||
$(GOTESTSUM) --format pkgname-and-test-fails --format-hide-empty-pkg --hide-summary skipped -- -cover -covermode=atomic -coverprofile=coverage.out $(TEST_PACKAGES)
|
||||
|
||||
##@ Dev
|
||||
.PHONY: dev dev-client dev-server
|
||||
dev: ## Run both the client and server in development mode
|
||||
dev: ## Run both the client and server in development mode
|
||||
make dev-server
|
||||
make dev-client
|
||||
|
||||
dev-client: ## Run the client in development mode
|
||||
yarn dev
|
||||
dev-client: ## Run the client in development mode
|
||||
pnpm install && pnpm run dev
|
||||
|
||||
dev-server: build-server ## Run the server in development mode
|
||||
@./dev/run_container.sh
|
||||
|
||||
dev-server-podman: build-server ## Run the server in development mode
|
||||
@./dev/run_container_podman.sh
|
||||
|
||||
##@ Format
|
||||
.PHONY: format format-client format-server
|
||||
@@ -89,36 +85,59 @@ dev-server: build-server ## Run the server in development mode
|
||||
format: format-client format-server ## Format all code
|
||||
|
||||
format-client: ## Format client code
|
||||
yarn format
|
||||
pnpm run format
|
||||
|
||||
format-server: ## Format server code
|
||||
go fmt ./...
|
||||
|
||||
##@ Lint
|
||||
.PHONY: lint lint-client lint-server
|
||||
.PHONY: lint lint-client lint-server check-lint-version
|
||||
lint: lint-client lint-server ## Lint all code
|
||||
|
||||
lint-client: ## Lint client code
|
||||
yarn lint
|
||||
pnpm run lint
|
||||
|
||||
lint-server: ## Lint server code
|
||||
check-lint-version:
|
||||
@installed=v$$(golangci-lint --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
|
||||
if [ "$$installed" = "v" ]; then \
|
||||
echo "ERROR: golangci-lint not found, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
elif [ "$$installed" != "$(GOLANGCI_LINT_VERSION)" ]; then \
|
||||
echo "ERROR: golangci-lint $$installed installed, need $(GOLANGCI_LINT_VERSION)"; \
|
||||
echo "Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
lint-server: tidy check-lint-version ## Lint server code
|
||||
golangci-lint run --timeout=10m -c .golangci.yaml
|
||||
|
||||
golangci-lint run --timeout=10m --new-from-rev=HEAD~ -c .golangci-forward.yaml
|
||||
|
||||
##@ Extension
|
||||
.PHONY: dev-extension
|
||||
dev-extension: build-server build-client ## Run the extension in development mode
|
||||
make local -f build/docker-extension/Makefile
|
||||
|
||||
|
||||
##@ Docs
|
||||
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
|
||||
docs-build: init-dist ## Build docs
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
|
||||
go mod download
|
||||
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./ --overridesFile .swaggo
|
||||
|
||||
docs-validate: docs-build ## Validate docs
|
||||
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
yarn swagger-cli validate dist/docs/openapi.yaml
|
||||
pnpm swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
|
||||
pnpm swagger-cli validate dist/docs/openapi.yaml
|
||||
|
||||
.PHONY: docs-serve
|
||||
docs-serve: docs-build ## Serve docs locally with Swagger UI on port 8080
|
||||
docker run -p 8080:8080 \
|
||||
-e SWAGGER_JSON=/foo/swagger.yaml \
|
||||
-v $(PWD)/dist/docs:/foo \
|
||||
swaggerapi/swagger-ui
|
||||
|
||||
.PHONY: generate-api
|
||||
generate-api: docs-validate ## Generate API client and types from OpenAPI spec
|
||||
pnpm generate-api
|
||||
|
||||
##@ Helpers
|
||||
.PHONY: help
|
||||
|
||||
45
README.md
45
README.md
@@ -8,9 +8,9 @@ Portainer consists of a single container that can run on any cluster. It can be
|
||||
|
||||
**Portainer Business Edition** builds on the open-source base and includes a range of advanced features and functions (like RBAC and Support) that are specific to the needs of business users.
|
||||
|
||||
- [Compare Portainer CE and Compare Portainer BE](https://portainer.io/products)
|
||||
- [Compare Portainer CE and Compare Portainer BE](https://www.portainer.io/features)
|
||||
- [Take3 – get 3 free nodes of Portainer Business for as long as you want them](https://www.portainer.io/take-3)
|
||||
- [Portainer BE install guide](https://install.portainer.io)
|
||||
- [Portainer BE install guide](https://academy.portainer.io/install/)
|
||||
|
||||
## Latest Version
|
||||
|
||||
@@ -20,22 +20,19 @@ Portainer CE is updated regularly. We aim to do an update release every couple o
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Deploy Portainer](https://docs.portainer.io/start/install)
|
||||
- [Deploy Portainer](https://docs.portainer.io/start/install-ce)
|
||||
- [Documentation](https://docs.portainer.io)
|
||||
- [Contribute to the project](https://docs.portainer.io/contribute/contribute)
|
||||
|
||||
## Features & Functions
|
||||
|
||||
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||
|
||||
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
|
||||
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
|
||||
View [this](https://www.portainer.io/features) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||
|
||||
## Getting help
|
||||
|
||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||
|
||||
Learn more about Portainer's community support channels [here.](https://www.portainer.io/get-support-for-portainer)
|
||||
Learn more about Portainer's community support channels [here.](https://www.portainer.io/resources/get-help/get-support)
|
||||
|
||||
- Issues: https://github.com/portainer/portainer/issues
|
||||
- Slack (chat): [https://portainer.io/slack](https://portainer.io/slack)
|
||||
@@ -47,19 +44,45 @@ You can join the Portainer Community by visiting [https://www.portainer.io/join-
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
- Want to help us build **_portainer_**? Follow our [contribution guidelines](https://docs.portainer.io/contribute/contribute) to build it locally and make a pull request.
|
||||
|
||||
## Generating API types
|
||||
|
||||
The frontend consumes a TypeScript API client (SDK functions and request/response types) that is generated from the Go API's Swagger annotations. Regenerate it after any API change — a new endpoint, a changed request/response shape, or a removed endpoint:
|
||||
|
||||
```bash
|
||||
make generate-api
|
||||
```
|
||||
|
||||
This runs the following pipeline:
|
||||
|
||||
```
|
||||
Go Swagger annotations
|
||||
→ dist/docs/swagger.yaml (make docs-build, via swaggo/swag)
|
||||
→ dist/docs/openapi.yaml (swagger2openapi + validation)
|
||||
→ app/react/portainer/generated-api/portainer/ (hey-api/openapi-ts)
|
||||
```
|
||||
|
||||
The generator is configured in [`openapi-ts.config.ts`](./openapi-ts.config.ts), which controls the output path, plugins, and tag filters (for example, `deprecated` endpoints and `edge_agent`-tagged routes are excluded).
|
||||
|
||||
The generated files live in `app/react/portainer/generated-api/portainer/` and must **not** be edited by hand — your changes would be overwritten on the next run. Import the generated SDK functions and types instead of writing direct HTTP calls:
|
||||
|
||||
- `@api/sdk.gen` — SDK functions
|
||||
- `@api/types.gen` — request/response types
|
||||
|
||||
See [Adding api docs](./CONTRIBUTING.md#adding-api-docs) for how to annotate handlers so they are picked up by the generator.
|
||||
|
||||
## Security
|
||||
|
||||
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
|
||||
For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md).
|
||||
|
||||
## Work for us
|
||||
|
||||
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and/or visit our [careers page](https://portainer.io/careers).
|
||||
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to success@portainer.io with your details and/or visit our [careers page](https://apply.workable.com/portainer/).
|
||||
|
||||
## Privacy
|
||||
|
||||
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
||||
|
||||
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
||||
When Portainer first starts, you are given the option to DISABLE analytics. If you **don't** choose to disable it, we collect anonymous usage as per [our privacy policy](https://www.portainer.io/legal/privacy-policy). **Please note**, there is no personally identifiable information sent or stored at any time and we only use the data to help us improve Portainer.
|
||||
|
||||
## Limitations
|
||||
|
||||
|
||||
60
SECURITY.md
Normal file
60
SECURITY.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Portainer maintains both Short-Term Support (STS) and Long-Term Support (LTS) versions in accordance with our official [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
||||
|
||||
| Version Type | Support Status |
|
||||
| ------------------------ | ------------------------------------------- |
|
||||
| LTS (Long-Term Support) | Supported for critical security fixes |
|
||||
| STS (Short-Term Support) | Supported until the next STS or LTS release |
|
||||
| Legacy / EOL | Not supported |
|
||||
|
||||
For a detailed breakdown of current versions and their specific End of Life (EOL) dates,
|
||||
please refer to the [Portainer Lifecycle Policy](https://docs.portainer.io/start/lifecycle).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Portainer team takes the security of our products seriously. If you believe you have found a security vulnerability in any Portainer-owned repository, please report it to us responsibly.
|
||||
|
||||
**Please do not report security vulnerabilities via public GitHub issues.**
|
||||
|
||||
### Disclosure Process
|
||||
|
||||
1. **Report**: You can report in one of two ways:
|
||||
|
||||
- **GitHub**: Use the **Report a vulnerability** button on the **Security** tab of this repository.
|
||||
|
||||
- **Email**: Send your findings to security@portainer.io.
|
||||
|
||||
2. **Details**: To help us verify the issue, please include:
|
||||
|
||||
- A description of the vulnerability and its potential impact.
|
||||
|
||||
- Step-by-step instructions to reproduce the issue (e.g. proof-of-concept code, scripts, or screenshots).
|
||||
|
||||
- The version of the software and the environment in which it was found.
|
||||
|
||||
3. **Acknowledge**: We will acknowledge receipt of your report and provide an initial assessment.
|
||||
|
||||
4. **Resolution**: We will work to resolve the issue as quickly as possible. We request that you do not disclose the vulnerability publicly until we have released a fix and notified affected users.
|
||||
|
||||
## Our Commitment
|
||||
|
||||
If you follow the responsible disclosure process, we will:
|
||||
|
||||
- Respond to your report in a timely manner.
|
||||
|
||||
- Provide an estimated timeline for remediation.
|
||||
|
||||
- Notify you when the vulnerability has been patched.
|
||||
|
||||
- Give credit for the discovery (if desired) once the fix is public.
|
||||
|
||||
We will make every effort to promptly address any security weaknesses. Security advisories and fixes will be published through GitHub Security Advisories and other channels as needed.
|
||||
|
||||
Thank you for helping keep Portainer and our community secure.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Contributing to Portainer](https://docs.portainer.io/contribute/contribute#contributing-to-the-portainer-ce-codebase)
|
||||
118
__mocks__/@reach/menu-button.tsx
Normal file
118
__mocks__/@reach/menu-button.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Children,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type MenuCtxType = {
|
||||
isOpen: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
menuRef: React.RefObject<HTMLDivElement>;
|
||||
label: string;
|
||||
setLabel: (v: string) => void;
|
||||
};
|
||||
|
||||
const MenuCtx = createContext<MenuCtxType | null>(null);
|
||||
|
||||
export function Menu({ children }: { children?: ReactNode }) {
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState('');
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleDocDown(e: MouseEvent) {
|
||||
const target = e.target as Node | null;
|
||||
if (
|
||||
isOpen &&
|
||||
menuRef.current &&
|
||||
target &&
|
||||
!menuRef.current.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleDocDown);
|
||||
return () => document.removeEventListener('mousedown', handleDocDown);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<MenuCtx.Provider value={{ isOpen, setOpen, menuRef, label, setLabel }}>
|
||||
<div ref={menuRef}>{children}</div>
|
||||
</MenuCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuButton({
|
||||
children,
|
||||
onClick: externalOnClick,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
[key: string]: unknown;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
|
||||
useEffect(() => {
|
||||
const firstText = Children.toArray(children).find(
|
||||
(c) => typeof c === 'string'
|
||||
);
|
||||
if (firstText) ctx?.setLabel(firstText as string);
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
externalOnClick?.();
|
||||
ctx?.setOpen(!ctx.isOpen);
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuList({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
if (!ctx?.isOpen) return null;
|
||||
return (
|
||||
<div role="menu" aria-label={ctx.label || undefined} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuItem({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useContext(MenuCtx);
|
||||
|
||||
function handleClick() {
|
||||
onSelect?.();
|
||||
ctx?.setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
|
||||
<div role="menuitem" onClick={handleClick} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
analysis/git.go
Normal file
18
analysis/git.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// inMemoryCloneWithWorktree flags git clone calls that use memory.NewStorage() as
|
||||
// the storer while also writing files to a real worktree. This holds all git objects
|
||||
// in heap for the duration of the clone, which is unbounded for user-supplied repos.
|
||||
func inMemoryCloneWithWorktree(m dsl.Matcher) {
|
||||
m.Match(`git.CloneContext($_, memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.CloneContext with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
|
||||
m.Match(`git.Clone(memory.NewStorage(), $wt, $_)`).
|
||||
Where(m["wt"].Text != "nil").
|
||||
Report(`git.Clone with memory.NewStorage() holds all git objects in heap; use gogitfs.NewStorage with a filesystem storer instead`)
|
||||
}
|
||||
75
analysis/ssrf.go
Normal file
75
analysis/ssrf.go
Normal file
@@ -0,0 +1,75 @@
|
||||
//go:build ignore
|
||||
|
||||
package gorules
|
||||
|
||||
import "github.com/quasilyte/go-ruleguard/dsl"
|
||||
|
||||
// unwrappedHTTPTransport flags any bare http.Transport composite literal.
|
||||
// All transports must be created via ssrf.NewTransport or ssrf.NewInternalTransport,
|
||||
// which clone http.DefaultTransport and handle SSRF protection internally.
|
||||
func unwrappedHTTPTransport(m dsl.Matcher) {
|
||||
m.Match(`$f(&http.Transport{$*_})`).
|
||||
Report(`$f receives a bare *http.Transport; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_ := &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport variable; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
|
||||
m.Match(`$_.Transport = &http.Transport{$*_}`).
|
||||
Report(`bare *http.Transport field assignment; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// helmGetterTransport flags getter.WithTransport calls that receive a bare *http.Transport.
|
||||
// Helm v4 installs its own transport and bypasses http.DefaultTransport, so the transport
|
||||
// passed here must be created via ssrf.NewTransport.
|
||||
func helmGetterTransport(m dsl.Matcher) {
|
||||
m.Match(`getter.WithTransport(&http.Transport{$*_})`).
|
||||
Report(`getter.WithTransport called with a bare *http.Transport; use ssrf.NewTransport(tlsConfig) as Helm v4 bypasses http.DefaultTransport`)
|
||||
}
|
||||
|
||||
// cloneDefaultTransport flags direct clones of *http.Transport outside main.go.
|
||||
// The one legitimate clone is in main.go where http.DefaultTransport is globally
|
||||
// wrapped with SSRF protection at server startup.
|
||||
func cloneDefaultTransport(m dsl.Matcher) {
|
||||
m.Match(`$_.(*http.Transport).Clone()`).
|
||||
Where(!m.File().Name.Matches(`^main\.go$`)).
|
||||
Report(`cloning *http.Transport directly is forbidden; use ssrf.NewTransport(tlsConfig) or ssrf.NewInternalTransport(tlsConfig) instead`)
|
||||
}
|
||||
|
||||
// internalTransportMisuse flags calls to NewInternalTransport outside the proxy
|
||||
// factory files where Chisel-tunnel and in-cluster K8s destinations are valid exemptions.
|
||||
func internalTransportMisuse(m dsl.Matcher) {
|
||||
m.Match(`ssrf.NewInternalTransport($*_)`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker|agent|local_transport|edge_transport|docker_unix|docker_windows)\.go$`))).
|
||||
Report(`NewInternalTransport bypasses SSRF validation; only valid in the proxy factory files for local sockets and internally-routed endpoints`)
|
||||
}
|
||||
|
||||
// dialerOverride flags direct assignments to any of the dialer fields on a transport.
|
||||
// The only valid assignments are in docker_unix.go and docker_windows.go where a
|
||||
// custom dialer is required for unix sockets and named pipes.
|
||||
func dialerOverride(m dsl.Matcher) {
|
||||
m.Match(`$_.DialContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.Dial = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct Dial assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLSContext = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLSContext assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
|
||||
m.Match(`$_.DialTLS = $*_`).
|
||||
Where(
|
||||
!(m.File().PkgPath.Matches(`proxy/factory`) &&
|
||||
m.File().Name.Matches(`^(docker_unix|docker_windows)\.go$`))).
|
||||
Report(`direct DialTLS assignment replaces the transport dialer; use ssrf.NewTransport or ssrf.NewInternalTransport instead`)
|
||||
}
|
||||
5
analysis/tools.go
Normal file
5
analysis/tools.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build tools
|
||||
|
||||
package gorules
|
||||
|
||||
import _ "github.com/quasilyte/go-ruleguard/dsl"
|
||||
1
api/.swaggo
Normal file
1
api/.swaggo
Normal file
@@ -0,0 +1 @@
|
||||
replace k8s.io/apimachinery/pkg/apis/meta/v1.Duration string
|
||||
@@ -19,24 +19,22 @@ const RedirectReasonAdminInitTimeout string = "AdminInitTimeout"
|
||||
type Monitor struct {
|
||||
timeout time.Duration
|
||||
datastore dataservices.DataStore
|
||||
shutdownCtx context.Context
|
||||
cancellationFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
adminInitDisabled bool
|
||||
}
|
||||
|
||||
// New creates a monitor that when started will wait for the timeout duration and then shutdown the application unless it has been initialized.
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore, shutdownCtx context.Context) *Monitor {
|
||||
func New(timeout time.Duration, datastore dataservices.DataStore) *Monitor {
|
||||
return &Monitor{
|
||||
timeout: timeout,
|
||||
datastore: datastore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
adminInitDisabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Starts starts the monitor. Active monitor could be stopped or shuttted down by cancelling the shutdown context.
|
||||
func (m *Monitor) Start() {
|
||||
// Start starts the monitor. The monitor will stop when ctx is cancelled, or when Stop is called.
|
||||
func (m *Monitor) Start(ctx context.Context) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -44,7 +42,7 @@ func (m *Monitor) Start() {
|
||||
return
|
||||
}
|
||||
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(context.Background())
|
||||
cancellationCtx, cancellationFunc := context.WithCancel(ctx)
|
||||
m.cancellationFunc = cancellationFunc
|
||||
|
||||
go func() {
|
||||
@@ -69,8 +67,6 @@ func (m *Monitor) Start() {
|
||||
}
|
||||
case <-cancellationCtx.Done():
|
||||
log.Debug().Msg("canceling initialization monitor")
|
||||
case <-m.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down initialization monitor")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package adminmonitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -11,21 +11,28 @@ import (
|
||||
)
|
||||
|
||||
func Test_stopWithoutStarting(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_stopCouldBeCalledMultipleTimes(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, nil)
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Stop()
|
||||
monitor.Stop()
|
||||
}
|
||||
|
||||
func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
t.Parallel()
|
||||
synctest.Test(t, test_startOrStopCouldBeCalledMultipleTimesConcurrently)
|
||||
}
|
||||
|
||||
go monitor.Start()
|
||||
monitor.Start()
|
||||
func test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil)
|
||||
|
||||
go monitor.Start(t.Context())
|
||||
monitor.Start(t.Context())
|
||||
|
||||
go monitor.Stop()
|
||||
monitor.Stop()
|
||||
@@ -34,8 +41,9 @@ func Test_startOrStopCouldBeCalledMultipleTimesConcurrently(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_canStopStartedMonitor(t *testing.T) {
|
||||
monitor := New(1*time.Minute, nil, context.Background())
|
||||
monitor.Start()
|
||||
t.Parallel()
|
||||
monitor := New(1*time.Minute, nil)
|
||||
monitor.Start(t.Context())
|
||||
assert.NotNil(t, monitor.cancellationFunc, "cancellation function is missing in started monitor")
|
||||
|
||||
monitor.Stop()
|
||||
@@ -43,11 +51,12 @@ func Test_canStopStartedMonitor(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_start_shouldDisableInstanceAfterTimeout_ifNotInitialized(t *testing.T) {
|
||||
t.Parallel()
|
||||
timeout := 10 * time.Millisecond
|
||||
|
||||
datastore := i.NewDatastore(i.WithUsers([]portainer.User{}))
|
||||
monitor := New(timeout, datastore, context.Background())
|
||||
monitor.Start()
|
||||
monitor := New(timeout, datastore)
|
||||
monitor.Start(t.Context())
|
||||
|
||||
<-time.After(20 * timeout)
|
||||
assert.True(t, monitor.WasInstanceDisabled(), "monitor should have been timeout and instance is disabled")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -10,21 +11,24 @@ import (
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/url"
|
||||
"github.com/portainer/portainer/api/url"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// GetAgentVersionAndPlatform returns the agent version and platform
|
||||
//
|
||||
// it sends a ping to the agent and parses the version and platform from the headers
|
||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) {
|
||||
httpCli := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) { //nolint:forbidigo
|
||||
if err := ssrf.CheckURL(context.Background(), endpointUrl); err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
httpCli := &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
if tlsConfig != nil {
|
||||
httpCli.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
httpCli.Transport = ssrf.NewTransport(tlsConfig)
|
||||
}
|
||||
|
||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||
@@ -44,8 +48,10 @@ func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (port
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||
|
||||
119
api/agent/version_test.go
Normal file
119
api/agent/version_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func tlsServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(handler)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
platform, version, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, portainer.AgentPlatformDocker, platform)
|
||||
require.Equal(t, "2.19.0", version)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_NonOKStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_MissingVersionHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_MissingPlatformHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_InvalidPlatformZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "0")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_NonNumericPlatform(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, "docker")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetAgentVersionAndPlatform_PingPathAppended(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotPath string
|
||||
srv := tlsServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.Header().Set(portainer.PortainerAgentHeader, "2.19.0")
|
||||
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformKubernetes)))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
tlsCfg := srv.Client().Transport.(*http.Transport).TLSClientConfig
|
||||
_, _, err := GetAgentVersionAndPlatform(srv.URL, tlsCfg)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/ping", gotPath)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
|
||||
Examples are available at https://documentation.portainer.io/api/api-examples/
|
||||
You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
|
||||
|
||||
# Authentication
|
||||
|
||||
Most of the API environments(endpoints) require to be authenticated as well as some level of authorization to be used.
|
||||
Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
|
||||
with the **Bearer** authentication mechanism.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
Each API environment(endpoint) has an associated access policy, it is documented in the description of each environment(endpoint).
|
||||
|
||||
Different access policies are available:
|
||||
|
||||
- Public access
|
||||
- Authenticated access
|
||||
- Restricted access
|
||||
- Administrator access
|
||||
|
||||
### Public access
|
||||
|
||||
No authentication is required to access the environments(endpoints) with this access policy.
|
||||
|
||||
### Authenticated access
|
||||
|
||||
Authentication is required to access the environments(endpoints) with this access policy.
|
||||
|
||||
### Restricted access
|
||||
|
||||
Authentication is required to access the environments(endpoints) with this access policy.
|
||||
Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
|
||||
|
||||
### Administrator access
|
||||
|
||||
Authentication as well as an administrator role are required to access the environments(endpoints) with this access policy.
|
||||
|
||||
# Execute Docker requests
|
||||
|
||||
Portainer **DO NOT** expose specific environments(endpoints) to manage your Docker resources (create a container, remove a volume, etc...).
|
||||
|
||||
Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
|
||||
|
||||
To do so, you can use the `/endpoints/{id}/docker` Portainer API environment(endpoint) (which is not documented below due to Swagger limitations). This environment(endpoint) has a restricted access policy so you still need to be authenticated to be able to query this environment(endpoint). Any query on this environment(endpoint) will be proxied to the Docker API of the associated environment(endpoint) (requests and responses objects are the same as documented in the Docker API).
|
||||
|
||||
# Private Registry
|
||||
|
||||
Using private registry, you will need to pass a based64 encoded JSON string ‘{"registryId":\<registryID value\>}’ inside the Request Header. The parameter name is "X-Registry-Auth".
|
||||
\<registryID value\> - The registry ID where the repository was created.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
eyJyZWdpc3RyeUlkIjoxfQ==
|
||||
```
|
||||
|
||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/).
|
||||
61
api/api.md
Normal file
61
api/api.md
Normal file
@@ -0,0 +1,61 @@
|
||||
The Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI, and anything you can do in the UI can also be done via the HTTP API.
|
||||
|
||||
API examples are available in the [Portainer documentation](https://documentation.portainer.io/api/api-examples/)
|
||||
|
||||
You can find out more about Portainer [on our website](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
|
||||
|
||||
# Authentication
|
||||
|
||||
Most of the API endpoints require authentication, as well as some level of authorization.
|
||||
Portainer uses JSON Web Tokens to manage authentication. You must provide a token in the **Authorization** header of each request using the **Bearer** scheme.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
Each API endpoint has an associated access policy, documented in its description.
|
||||
|
||||
The following policies are available:
|
||||
|
||||
- Public access
|
||||
- Authenticated access
|
||||
- Restricted access
|
||||
- Administrator access
|
||||
|
||||
### Public access
|
||||
|
||||
No authentication is required.
|
||||
|
||||
### Authenticated access
|
||||
|
||||
Authentication is required.
|
||||
|
||||
### Restricted access
|
||||
|
||||
Authentication is required. Additional checks may apply to verify access to the resource, and returned data may be filtered.
|
||||
|
||||
### Administrator access
|
||||
|
||||
Authentication and an administrator role are both required.
|
||||
|
||||
# Execute Docker requests
|
||||
|
||||
Portainer does not expose dedicated endpoints for managing Docker resources (create a container, remove a volume, etc).
|
||||
|
||||
Instead, it acts as a reverse-proxy to the Docker HTTP API, allowing you to execute Docker requests via the Portainer HTTP API.
|
||||
|
||||
To do so, use the `/endpoints/{id}/docker` endpoint. Note that this endpoint is not documented below due to Swagger limitations. It has a restricted access policy, so authentication is still required. Any request made to this endpoint is proxied to the Docker API of the associated environment - request and response objects are identical to those in the [Docker official documentation](https://docs.docker.com/engine/api).
|
||||
|
||||
# Private Registry
|
||||
|
||||
When using a private registry, include a Base64-encoded JSON string in the request header. The header parameter name is `X-Registry-Auth` and the value should encode the following structure: ‘{"registryId":\<registryId\>}’ where `<registryId>` is the ID of the registry where the repository was created.
|
||||
|
||||
Example encoded value:
|
||||
|
||||
```
|
||||
eyJyZWdpc3RyeUlkIjoxfQ==
|
||||
```
|
||||
@@ -3,48 +3,50 @@ package apikey
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_generateRandomKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantLenth int
|
||||
name string
|
||||
wantLength int
|
||||
}{
|
||||
{
|
||||
name: "Generate a random key of length 16",
|
||||
wantLenth: 16,
|
||||
name: "Generate a random key of length 16",
|
||||
wantLength: 16,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 32",
|
||||
wantLenth: 32,
|
||||
name: "Generate a random key of length 32",
|
||||
wantLength: 32,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 64",
|
||||
wantLenth: 64,
|
||||
name: "Generate a random key of length 64",
|
||||
wantLength: 64,
|
||||
},
|
||||
{
|
||||
name: "Generate a random key of length 128",
|
||||
wantLenth: 128,
|
||||
name: "Generate a random key of length 128",
|
||||
wantLength: 128,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := securecookie.GenerateRandomKey(tt.wantLenth)
|
||||
is.Equal(tt.wantLenth, len(got))
|
||||
got := GenerateRandomKey(tt.wantLength)
|
||||
is.Len(got, tt.wantLength)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Generated keys are unique", func(t *testing.T) {
|
||||
keys := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
key := securecookie.GenerateRandomKey(8)
|
||||
|
||||
for range 100 {
|
||||
key := GenerateRandomKey(8)
|
||||
_, ok := keys[string(key)]
|
||||
is.False(ok)
|
||||
|
||||
keys[string(key)] = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,69 +1,79 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
)
|
||||
|
||||
const defaultAPIKeyCacheSize = 1024
|
||||
const DefaultAPIKeyCacheSize = 1024
|
||||
|
||||
// entry is a tuple containing the user and API key associated to an API key digest
|
||||
type entry struct {
|
||||
user portainer.User
|
||||
type entry[T any] struct {
|
||||
user T
|
||||
apiKey portainer.APIKey
|
||||
}
|
||||
|
||||
// apiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips.
|
||||
type UserCompareFn[T any] func(T, portainer.UserID) bool
|
||||
|
||||
// ApiKeyCache is a concurrency-safe, in-memory cache which primarily exists for to reduce database roundtrips.
|
||||
// We store the api-key digest (keys) and the associated user and key-data (values) in the cache.
|
||||
// This is required because HTTP requests will contain only the api-key digest in the x-api-key request header;
|
||||
// digest value must be mapped to a portainer user (and respective key data) for validation.
|
||||
// This cache is used to avoid multiple database queries to retrieve these user/key associated to the digest.
|
||||
type apiKeyCache struct {
|
||||
type ApiKeyCache[T any] struct {
|
||||
// cache type [string]entry cache (key: string(digest), value: user/key entry)
|
||||
// note: []byte keys are not supported by golang-lru Cache
|
||||
cache *lru.Cache
|
||||
cache *lru.Cache
|
||||
userCmpFn UserCompareFn[T]
|
||||
}
|
||||
|
||||
// NewAPIKeyCache creates a new cache for API keys
|
||||
func NewAPIKeyCache(cacheSize int) *apiKeyCache {
|
||||
func NewAPIKeyCache[T any](cacheSize int, userCompareFn UserCompareFn[T]) *ApiKeyCache[T] {
|
||||
cache, _ := lru.New(cacheSize)
|
||||
return &apiKeyCache{cache: cache}
|
||||
|
||||
return &ApiKeyCache[T]{cache: cache, userCmpFn: userCompareFn}
|
||||
}
|
||||
|
||||
// Get returns the user/key associated to an api-key's digest
|
||||
// This is required because HTTP requests will contain the digest of the API key in header,
|
||||
// the digest value must be mapped to a portainer user.
|
||||
func (c *apiKeyCache) Get(digest string) (portainer.User, portainer.APIKey, bool) {
|
||||
func (c *ApiKeyCache[T]) Get(digest string) (T, portainer.APIKey, bool) {
|
||||
val, ok := c.cache.Get(digest)
|
||||
if !ok {
|
||||
return portainer.User{}, portainer.APIKey{}, false
|
||||
var t T
|
||||
|
||||
return t, portainer.APIKey{}, false
|
||||
}
|
||||
tuple := val.(entry)
|
||||
|
||||
tuple := val.(entry[T])
|
||||
|
||||
return tuple.user, tuple.apiKey, true
|
||||
}
|
||||
|
||||
// Set persists a user/key entry to the cache
|
||||
func (c *apiKeyCache) Set(digest string, user portainer.User, apiKey portainer.APIKey) {
|
||||
c.cache.Add(digest, entry{
|
||||
func (c *ApiKeyCache[T]) Set(digest string, user T, apiKey portainer.APIKey) {
|
||||
c.cache.Add(digest, entry[T]{
|
||||
user: user,
|
||||
apiKey: apiKey,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete evicts a digest's user/key entry key from the cache
|
||||
func (c *apiKeyCache) Delete(digest string) {
|
||||
func (c *ApiKeyCache[T]) Delete(digest string) {
|
||||
c.cache.Remove(digest)
|
||||
}
|
||||
|
||||
// InvalidateUserKeyCache loops through all the api-keys associated to a user and removes them from the cache
|
||||
func (c *apiKeyCache) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||
func (c *ApiKeyCache[T]) InvalidateUserKeyCache(userId portainer.UserID) bool {
|
||||
present := false
|
||||
|
||||
for _, k := range c.cache.Keys() {
|
||||
user, _, _ := c.Get(k.(string))
|
||||
if user.ID == userId {
|
||||
present = c.cache.Remove(k)
|
||||
if c.userCmpFn(user, userId) {
|
||||
present = c.cache.Remove(k) || present
|
||||
}
|
||||
}
|
||||
|
||||
return present
|
||||
}
|
||||
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
)
|
||||
|
||||
func Test_apiKeyCacheGet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10)
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
// pre-populate cache
|
||||
keyCache.cache.Add(string("foo"), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string(""), entry{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string(""), entry[portainer.User]{user: portainer.User{}, apiKey: portainer.APIKey{}})
|
||||
|
||||
tests := []struct {
|
||||
digest string
|
||||
@@ -35,7 +36,7 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(string(test.digest), func(t *testing.T) {
|
||||
t.Run(test.digest, func(t *testing.T) {
|
||||
_, _, found := keyCache.Get(test.digest)
|
||||
is.Equal(test.found, found)
|
||||
})
|
||||
@@ -43,9 +44,10 @@ func Test_apiKeyCacheGet(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10)
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
// pre-populate cache
|
||||
keyCache.Set("bar", portainer.User{ID: 2}, portainer.APIKey{})
|
||||
@@ -57,23 +59,24 @@ func Test_apiKeyCacheSet(t *testing.T) {
|
||||
val, ok := keyCache.cache.Get(string("bar"))
|
||||
is.True(ok)
|
||||
|
||||
tuple := val.(entry)
|
||||
tuple := val.(entry[portainer.User])
|
||||
is.Equal(portainer.User{ID: 2}, tuple.user)
|
||||
|
||||
val, ok = keyCache.cache.Get(string("foo"))
|
||||
is.True(ok)
|
||||
|
||||
tuple = val.(entry)
|
||||
tuple = val.(entry[portainer.User])
|
||||
is.Equal(portainer.User{ID: 3}, tuple.user)
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10)
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
t.Run("Delete an existing entry", func(t *testing.T) {
|
||||
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.Delete("foo")
|
||||
|
||||
_, ok := keyCache.cache.Get(string("foo"))
|
||||
@@ -87,6 +90,7 @@ func Test_apiKeyCacheDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
@@ -128,7 +132,7 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
keyCache := NewAPIKeyCache(test.cacheLen)
|
||||
keyCache := NewAPIKeyCache(test.cacheLen, compareUser)
|
||||
|
||||
for _, key := range test.key {
|
||||
keyCache.Set(key, portainer.User{ID: 1}, portainer.APIKey{})
|
||||
@@ -148,12 +152,13 @@ func Test_apiKeyCacheLRU(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
keyCache := NewAPIKeyCache(10)
|
||||
keyCache := NewAPIKeyCache(10, compareUser)
|
||||
|
||||
t.Run("Removes users keys from cache", func(t *testing.T) {
|
||||
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
|
||||
ok := keyCache.InvalidateUserKeyCache(1)
|
||||
is.True(ok)
|
||||
@@ -163,8 +168,8 @@ func Test_apiKeyCacheInvalidateUserKeyCache(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("Does not affect other keys", func(t *testing.T) {
|
||||
keyCache.cache.Add(string("foo"), entry{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("bar"), entry{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("foo"), entry[portainer.User]{user: portainer.User{ID: 1}, apiKey: portainer.APIKey{}})
|
||||
keyCache.cache.Add(string("bar"), entry[portainer.User]{user: portainer.User{ID: 2}, apiKey: portainer.APIKey{}})
|
||||
|
||||
ok := keyCache.InvalidateUserKeyCache(1)
|
||||
is.True(ok)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -20,30 +21,45 @@ var ErrInvalidAPIKey = errors.New("Invalid API key")
|
||||
type apiKeyService struct {
|
||||
apiKeyRepository dataservices.APIKeyRepository
|
||||
userRepository dataservices.UserService
|
||||
cache *apiKeyCache
|
||||
cache *ApiKeyCache[portainer.User]
|
||||
}
|
||||
|
||||
// GenerateRandomKey generates a random key of specified length
|
||||
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
|
||||
func GenerateRandomKey(length int) []byte {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return k
|
||||
}
|
||||
|
||||
func compareUser(u portainer.User, id portainer.UserID) bool {
|
||||
return u.ID == id
|
||||
}
|
||||
|
||||
func NewAPIKeyService(apiKeyRepository dataservices.APIKeyRepository, userRepository dataservices.UserService) *apiKeyService {
|
||||
return &apiKeyService{
|
||||
apiKeyRepository: apiKeyRepository,
|
||||
userRepository: userRepository,
|
||||
cache: NewAPIKeyCache(defaultAPIKeyCacheSize),
|
||||
cache: NewAPIKeyCache(DefaultAPIKeyCacheSize, compareUser),
|
||||
}
|
||||
}
|
||||
|
||||
// HashRaw computes a hash digest of provided raw API key.
|
||||
func (a *apiKeyService) HashRaw(rawKey string) string {
|
||||
hashDigest := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
return base64.StdEncoding.EncodeToString(hashDigest[:])
|
||||
}
|
||||
|
||||
// GenerateApiKey generates a raw API key for a user (for one-time display).
|
||||
// The generated API key is stored in the cache and database.
|
||||
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
|
||||
randKey := securecookie.GenerateRandomKey(32)
|
||||
randKey := GenerateRandomKey(32)
|
||||
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
|
||||
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
|
||||
|
||||
hashDigest := a.HashRaw(prefixedAPIKey)
|
||||
|
||||
apiKey := &portainer.APIKey{
|
||||
@@ -54,8 +70,7 @@ func (a *apiKeyService) GenerateApiKey(user portainer.User, description string)
|
||||
Digest: hashDigest,
|
||||
}
|
||||
|
||||
err := a.apiKeyRepository.Create(apiKey)
|
||||
if err != nil {
|
||||
if err := a.apiKeyRepository.Create(apiKey); err != nil {
|
||||
return "", nil, errors.Wrap(err, "Unable to create API key")
|
||||
}
|
||||
|
||||
@@ -78,7 +93,6 @@ func (a *apiKeyService) GetAPIKeys(userID portainer.UserID) ([]portainer.APIKey,
|
||||
// GetDigestUserAndKey returns the user and api-key associated to a specified hash digest.
|
||||
// A cache lookup is performed first; if the user/api-key is not found in the cache, respective database lookups are performed.
|
||||
func (a *apiKeyService) GetDigestUserAndKey(digest string) (portainer.User, portainer.APIKey, error) {
|
||||
// get api key from cache if possible
|
||||
cachedUser, cachedKey, ok := a.cache.Get(digest)
|
||||
if ok {
|
||||
return cachedUser, cachedKey, nil
|
||||
@@ -106,20 +120,21 @@ func (a *apiKeyService) UpdateAPIKey(apiKey *portainer.APIKey) error {
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to retrieve API key")
|
||||
}
|
||||
|
||||
a.cache.Set(apiKey.Digest, user, *apiKey)
|
||||
|
||||
return a.apiKeyRepository.Update(apiKey.ID, apiKey)
|
||||
}
|
||||
|
||||
// DeleteAPIKey deletes an API key and removes the digest/api-key entry from the cache.
|
||||
func (a *apiKeyService) DeleteAPIKey(apiKeyID portainer.APIKeyID) error {
|
||||
// get api-key digest to remove from cache
|
||||
apiKey, err := a.apiKeyRepository.Read(apiKeyID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("Unable to retrieve API key: %d", apiKeyID))
|
||||
}
|
||||
|
||||
// delete the user/api-key from cache
|
||||
a.cache.Delete(apiKey.Digest)
|
||||
|
||||
return a.apiKeyRepository.Delete(apiKeyID)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,17 +10,20 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_SatisfiesAPIKeyServiceInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
is.Implements((*APIKeyService)(nil), NewAPIKeyService(nil, nil))
|
||||
}
|
||||
|
||||
func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -30,7 +33,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully generates API key", func(t *testing.T) {
|
||||
desc := "test-1"
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, desc)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.NotEmpty(rawKey)
|
||||
is.NotEmpty(apiKey)
|
||||
is.Equal(desc, apiKey.Description)
|
||||
@@ -38,7 +41,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Api key prefix is 7 chars", func(t *testing.T) {
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(rawKey[:7], apiKey.Prefix)
|
||||
is.Len(apiKey.Prefix, 7)
|
||||
@@ -46,7 +49,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Api key has 'ptr_' as prefix", func(t *testing.T) {
|
||||
rawKey, _, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(portainerAPIKeyPrefix, "ptr_")
|
||||
is.True(strings.HasPrefix(rawKey, "ptr_"))
|
||||
@@ -55,7 +58,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
t.Run("Successfully caches API key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-3")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userFromCache, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -65,7 +68,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
|
||||
t.Run("Decoded raw api-key digest matches generated digest", func(t *testing.T) {
|
||||
rawKey, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-4")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
generatedDigest := sha256.Sum256([]byte(rawKey))
|
||||
|
||||
@@ -74,6 +77,7 @@ func Test_GenerateApiKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_GetAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -83,16 +87,17 @@ func Test_GetAPIKey(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKeyGot, err := service.GetAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
is.Equal(apiKey, apiKeyGot)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -102,17 +107,18 @@ func Test_GetAPIKeys(t *testing.T) {
|
||||
t.Run("Successfully returns all API keys", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, _, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
_, _, err = service.GenerateApiKey(user, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
keys, err := service.GetAPIKeys(user.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Len(keys, 2)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -122,10 +128,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully returns user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
})
|
||||
@@ -133,10 +139,10 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
t.Run("Successfully caches user and api key associated to digest", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
userGot, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(user, userGot)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
@@ -148,6 +154,7 @@ func Test_GetDigestUserAndKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_UpdateAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -156,16 +163,19 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
|
||||
t.Run("Successfully updates the api-key LastUsed time", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
store.User().Create(&user)
|
||||
|
||||
err := store.User().Create(&user)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-x")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
apiKey.LastUsed = time.Now().UTC().Unix()
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
log.Debug().Str("wanted", fmt.Sprintf("%+v", apiKey)).Str("got", fmt.Sprintf("%+v", apiKeyGot)).Msg("")
|
||||
|
||||
@@ -174,7 +184,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
|
||||
t.Run("Successfully updates api-key in cache upon api-key update", func(t *testing.T) {
|
||||
_, apiKey, err := service.GenerateApiKey(portainer.User{ID: 1}, "test-x2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -184,7 +194,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
is.NotEqual(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.UpdateAPIKey(apiKey)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, updatedAPIKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
@@ -193,6 +203,7 @@ func Test_UpdateAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -202,30 +213,30 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
t.Run("Successfully updates the api-key", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyGot, err := service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(*apiKey, apiKeyGot)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, err = service.GetDigestUserAndKey(apiKey.Digest)
|
||||
is.Error(err)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("Successfully removes api-key from cache upon deletion", func(t *testing.T) {
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey.Digest)
|
||||
is.True(ok)
|
||||
is.Equal(*apiKey, apiKeyFromCache)
|
||||
|
||||
err = service.DeleteAPIKey(apiKey.ID)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _, ok = service.cache.Get(apiKey.Digest)
|
||||
is.False(ok)
|
||||
@@ -233,6 +244,7 @@ func Test_DeleteAPIKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
@@ -243,10 +255,10 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate api keys
|
||||
user := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKey2, err := service.GenerateApiKey(user, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify api keys are present in cache
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||
@@ -273,11 +285,11 @@ func Test_InvalidateUserKeyCache(t *testing.T) {
|
||||
// generate keys for 2 users
|
||||
user1 := portainer.User{ID: 1}
|
||||
_, apiKey1, err := service.GenerateApiKey(user1, "test-1")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
user2 := portainer.User{ID: 2}
|
||||
_, apiKey2, err := service.GenerateApiKey(user2, "test-2")
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify keys in cache
|
||||
_, apiKeyFromCache, ok := service.cache.Get(apiKey1.Digest)
|
||||
|
||||
@@ -17,18 +17,15 @@ func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, e
|
||||
Size: int64(len(fileContent)),
|
||||
}
|
||||
|
||||
err := tarWriter.WriteHeader(header)
|
||||
if err != nil {
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tarWriter.Write(fileContent)
|
||||
if err != nil {
|
||||
if _, err := tarWriter.Write(fileContent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tarWriter.Close()
|
||||
if err != nil {
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -43,10 +40,7 @@ type tarFileInBuffer struct {
|
||||
|
||||
func NewTarFileInBuffer() *tarFileInBuffer {
|
||||
var b bytes.Buffer
|
||||
return &tarFileInBuffer{
|
||||
b: &b,
|
||||
w: tar.NewWriter(&b),
|
||||
}
|
||||
return &tarFileInBuffer{b: &b, w: tar.NewWriter(&b)}
|
||||
}
|
||||
|
||||
// Put puts a single file to tar archive buffer.
|
||||
@@ -61,11 +55,9 @@ func (t *tarFileInBuffer) Put(fileContent []byte, fileName string, mode int64) e
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := t.w.Write(fileContent); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := t.w.Write(fileContent)
|
||||
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
|
||||
// Bytes returns the archive as a byte array.
|
||||
|
||||
@@ -9,23 +9,27 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
// TarGzDir creates a tar.gz archive and returns it's path.
|
||||
// abosolutePath should be an absolute path to a directory.
|
||||
// Archive name will be <directoryName>.tar.gz and will be placed next to the directory.
|
||||
func TarGzDir(absolutePath string) (string, error) {
|
||||
targzPath := filepath.Join(absolutePath, fmt.Sprintf("%s.tar.gz", filepath.Base(absolutePath)))
|
||||
targzPath := filepath.Join(absolutePath, filepath.Base(absolutePath)+".tar.gz")
|
||||
outFile, err := os.Create(targzPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer logs.CloseAndLogErr(outFile)
|
||||
|
||||
zipWriter := gzip.NewWriter(outFile)
|
||||
defer zipWriter.Close()
|
||||
defer logs.CloseAndLogErr(zipWriter)
|
||||
|
||||
tarWriter := tar.NewWriter(zipWriter)
|
||||
defer tarWriter.Close()
|
||||
defer logs.CloseAndLogErr(tarWriter)
|
||||
|
||||
err = filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
@@ -86,7 +90,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zipReader.Close()
|
||||
defer logs.CloseAndLogErr(zipReader)
|
||||
|
||||
tarReader := tar.NewReader(zipReader)
|
||||
|
||||
@@ -105,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
@@ -116,7 +120,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
if _, err := io.Copy(outFile, tarReader); err != nil {
|
||||
return fmt.Errorf("Failed to extract file %s", header.Name)
|
||||
}
|
||||
outFile.Close()
|
||||
logs.CloseAndLogErr(outFile)
|
||||
default:
|
||||
return fmt.Errorf("tar: unknown type: %v in %s",
|
||||
header.Typeflag,
|
||||
|
||||
@@ -1,53 +1,70 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func listFiles(dir string) []string {
|
||||
items := make([]string, 0)
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
|
||||
if err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
|
||||
items = append(items, path)
|
||||
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to list files in directory")
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func Test_shouldCreateArhive(t *testing.T) {
|
||||
func Test_shouldCreateArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
cmd := exec.Command("tar", "-xzf", gzPath, "-C", extractionDir)
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatal("Failed to extract archive: ", err)
|
||||
}
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
copyContent, err := os.ReadFile(fullpath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
@@ -56,28 +73,36 @@ func Test_shouldCreateArhive(t *testing.T) {
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func Test_shouldCreateArhiveXXXXX(t *testing.T) {
|
||||
func Test_shouldCreateArchive2(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmpdir := t.TempDir()
|
||||
content := []byte("content")
|
||||
os.WriteFile(path.Join(tmpdir, "outer"), content, 0600)
|
||||
os.MkdirAll(path.Join(tmpdir, "dir"), 0700)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
os.WriteFile(path.Join(tmpdir, "dir", "inner"), content, 0600)
|
||||
|
||||
err := os.WriteFile(filesystem.JoinPaths(tmpdir, "outer"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.MkdirAll(filesystem.JoinPaths(tmpdir, "dir"), 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", ".dotfile"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(tmpdir, "dir", "inner"), content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzPath, err := TarGzDir(tmpdir)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, filepath.Join(tmpdir, fmt.Sprintf("%s.tar.gz", filepath.Base(tmpdir))), gzPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filesystem.JoinPaths(tmpdir, filepath.Base(tmpdir)+".tar.gz"), gzPath)
|
||||
|
||||
extractionDir := t.TempDir()
|
||||
r, _ := os.Open(gzPath)
|
||||
ExtractTarGz(r, extractionDir)
|
||||
if err != nil {
|
||||
if err := ExtractTarGz(r, extractionDir); err != nil {
|
||||
t.Fatal("Failed to extract archive: ", err)
|
||||
}
|
||||
extractedFiles := listFiles(extractionDir)
|
||||
|
||||
wasExtracted := func(p string) {
|
||||
fullpath := path.Join(extractionDir, p)
|
||||
fullpath := filesystem.JoinPaths(extractionDir, p)
|
||||
assert.Contains(t, extractedFiles, fullpath)
|
||||
copyContent, _ := os.ReadFile(fullpath)
|
||||
assert.Equal(t, content, copyContent)
|
||||
@@ -87,3 +112,57 @@ func Test_shouldCreateArhiveXXXXX(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
t.Parallel()
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -2,60 +2,17 @@ package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||
func UnzipArchive(archiveData []byte, dest string) error {
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, zipFile := range zipReader.File {
|
||||
err := extractFileFromArchive(zipFile, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFileFromArchive(file *zip.File, dest string) error {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fpath := filepath.Join(dest, file.Name)
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return outFile.Close()
|
||||
}
|
||||
|
||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||
func UnzipFile(src string, dest string) error {
|
||||
@@ -63,7 +20,7 @@ func UnzipFile(src string, dest string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
defer logs.CloseAndLogErr(r)
|
||||
|
||||
for _, f := range r.File {
|
||||
p := filepath.Join(dest, f.Name)
|
||||
@@ -75,12 +32,14 @@ func UnzipFile(src string, dest string) error {
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
os.MkdirAll(p, os.ModePerm)
|
||||
if err := os.MkdirAll(p, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
err = unzipFile(f, p)
|
||||
if err != nil {
|
||||
if err := unzipFile(f, p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -93,20 +52,20 @@ func unzipFile(f *zip.File, p string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer logs.CloseAndLogErr(outFile)
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
defer logs.CloseAndLogErr(rc)
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
|
||||
if err != nil {
|
||||
if _, err = io.Copy(outFile, rc); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
/*
|
||||
Archive structure.
|
||||
@@ -20,10 +23,10 @@ func TestUnzipFile(t *testing.T) {
|
||||
|
||||
err := UnzipFile("./testdata/sample_archive.zip", dir)
|
||||
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1", "2.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filesystem.JoinPaths(archiveDir, "0", "1", "2.txt"))
|
||||
|
||||
}
|
||||
|
||||
@@ -3,19 +3,19 @@ package ecr
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(context.TODO(), nil)
|
||||
func (s *Service) GetEncodedAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
getAuthorizationTokenOutput, err := s.client.GetAuthorizationToken(ctx, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(getAuthorizationTokenOutput.AuthorizationData) == 0 {
|
||||
err = fmt.Errorf("AuthorizationData is empty")
|
||||
err = errors.New("AuthorizationData is empty")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func (s *Service) GetEncodedAuthorizationToken() (token *string, expiry *time.Ti
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Service) GetAuthorizationToken() (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken()
|
||||
func (s *Service) GetAuthorizationToken(ctx context.Context) (token *string, expiry *time.Time, err error) {
|
||||
tokenEncodedStr, expiry, err := s.GetEncodedAuthorizationToken(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (s *Service) ParseAuthorizationToken(token string) (username string, passwo
|
||||
|
||||
splitToken := strings.Split(token, ":")
|
||||
if len(splitToken) < 2 {
|
||||
err = fmt.Errorf("invalid ECR authorization token")
|
||||
err = errors.New("invalid ECR authorization token")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,15 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/service/ecr"
|
||||
)
|
||||
|
||||
// Registry represents an ECR registry endpoint information.
|
||||
// This struct is used to parse and validate ECR endpoint URLs.
|
||||
type Registry struct {
|
||||
ID string // AWS account ID (empty for accountless endpoints like "ecr-fips.us-west-1.amazonaws.com")
|
||||
FIPS bool // Whether this is a FIPS endpoint (contains "-fips" in the URL)
|
||||
Region string // AWS region (e.g., "us-east-1", "us-gov-west-1")
|
||||
Public bool // Whether this is ecr-public.aws.com
|
||||
}
|
||||
|
||||
type (
|
||||
Service struct {
|
||||
accessKey string
|
||||
|
||||
70
api/aws/ecr/parse_endpoints.go
Normal file
70
api/aws/ecr/parse_endpoints.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package ecr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ecrEndpointPattern matches all valid ECR endpoints including account-prefixed and accountless formats.
|
||||
// Based on AWS ECR credential helper regex but extended to support accountless endpoints.
|
||||
//
|
||||
// Supported formats:
|
||||
// - Account-prefixed: 123456789012.dkr.ecr-fips.us-east-1.amazonaws.com
|
||||
// - Account-prefixed (hyphen): 123456789012.dkr-ecr-fips.us-west-1.on.aws
|
||||
// - Accountless service: ecr-fips.us-west-1.amazonaws.com
|
||||
// - Accountless API: ecr-fips.us-east-1.api.aws
|
||||
// - Non-FIPS variants: All formats above without "-fips"
|
||||
//
|
||||
// Regex groups:
|
||||
// - Group 1: Full account prefix (optional) - e.g., "123456789012.dkr." or "123456789012.dkr-"
|
||||
// - Group 2: Account ID (optional) - e.g., "123456789012"
|
||||
// - Group 3: FIPS flag (optional) - either "-fips" or empty string
|
||||
// - Group 4: Region - e.g., "us-east-1", "us-gov-west-1"
|
||||
// - Group 5: Domain suffix - e.g., "amazonaws.com", "api.aws"
|
||||
var ecrEndpointPattern = regexp.MustCompile(
|
||||
`^((\d{12})\.dkr[\.\-])?ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.(amazonaws\.(?:com(?:\.cn)?|eu)|api\.aws|on\.(?:aws|amazonwebservices\.com\.cn)|sc2s\.sgov\.gov|c2s\.ic\.gov|cloud\.adc-e\.uk|csp\.hci\.ic\.gov)$`,
|
||||
)
|
||||
|
||||
// ParseECREndpoint parses an ECR registry URL and extracts registry information.
|
||||
|
||||
// This function replaces the AWS ECR credential helper library's ExtractRegistry function,
|
||||
// which only supports account-prefixed endpoints.
|
||||
//
|
||||
// Reference: https://docs.aws.amazon.com/general/latest/gr/ecr.html
|
||||
func ParseECREndpoint(urlStr string) (*Registry, error) {
|
||||
// Normalize URL by adding https:// prefix if not present
|
||||
if !strings.HasPrefix(urlStr, "https://") && !strings.HasPrefix(urlStr, "http://") {
|
||||
urlStr = "https://" + urlStr
|
||||
}
|
||||
|
||||
u, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
|
||||
// Special case: ECR Public
|
||||
// ECR Public uses a different domain and doesn't have FIPS variant
|
||||
if hostname == "ecr-public.aws.com" {
|
||||
return &Registry{
|
||||
FIPS: false,
|
||||
Public: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Parse standard ECR endpoints using regex
|
||||
matches := ecrEndpointPattern.FindStringSubmatch(hostname)
|
||||
if len(matches) == 0 {
|
||||
return nil, fmt.Errorf("not a valid ECR endpoint: %s", hostname)
|
||||
}
|
||||
|
||||
return &Registry{
|
||||
ID: matches[2], // Account ID (may be empty for accountless endpoints)
|
||||
FIPS: matches[3] == "-fips", // Check if "-fips" is present
|
||||
Region: matches[4], // AWS region
|
||||
Public: false,
|
||||
}, nil
|
||||
}
|
||||
254
api/aws/ecr/parse_endpoints_test.go
Normal file
254
api/aws/ecr/parse_endpoints_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package ecr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseECREndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want *Registry
|
||||
wantError bool
|
||||
}{
|
||||
// Standard AWS Commercial - Account-prefixed FIPS
|
||||
{
|
||||
name: "account-prefixed FIPS us-east-1",
|
||||
url: "123456789012.dkr.ecr-fips.us-east-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-east-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account-prefixed FIPS us-west-2",
|
||||
url: "123456789012.dkr.ecr-fips.us-west-2.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-west-2",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Accountless FIPS service endpoints
|
||||
{
|
||||
name: "accountless FIPS us-west-1",
|
||||
url: "ecr-fips.us-west-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless FIPS us-east-2",
|
||||
url: "ecr-fips.us-east-2.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-east-2",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Accountless FIPS API endpoints
|
||||
{
|
||||
name: "accountless FIPS API us-west-1",
|
||||
url: "ecr-fips.us-west-1.api.aws",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless FIPS API us-east-1",
|
||||
url: "ecr-fips.us-east-1.api.aws",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-east-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// on.aws domain with hyphen separator
|
||||
{
|
||||
name: "account-prefixed FIPS hyphen us-west-1",
|
||||
url: "123456789012.dkr-ecr-fips.us-west-1.on.aws",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account-prefixed FIPS hyphen us-east-2",
|
||||
url: "123456789012.dkr-ecr-fips.us-east-2.on.aws",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-east-2",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// AWS GovCloud
|
||||
{
|
||||
name: "account-prefixed FIPS us-gov-east-1",
|
||||
url: "123456789012.dkr.ecr-fips.us-gov-east-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-gov-east-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "account-prefixed FIPS us-gov-west-1",
|
||||
url: "123456789012.dkr.ecr-fips.us-gov-west-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: true,
|
||||
Region: "us-gov-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless FIPS us-gov-west-1",
|
||||
url: "ecr-fips.us-gov-west-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-gov-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless FIPS API us-gov-east-1",
|
||||
url: "ecr-fips.us-gov-east-1.api.aws",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-gov-east-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// ECR Public
|
||||
{
|
||||
name: "ecr-public",
|
||||
url: "ecr-public.aws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: false,
|
||||
Region: "",
|
||||
Public: true,
|
||||
},
|
||||
},
|
||||
|
||||
// Non-FIPS endpoints (valid ECR but FIPS=false)
|
||||
{
|
||||
name: "account-prefixed non-FIPS us-east-1",
|
||||
url: "123456789012.dkr.ecr.us-east-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "123456789012",
|
||||
FIPS: false,
|
||||
Region: "us-east-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless non-FIPS us-west-1",
|
||||
url: "ecr.us-west-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: false,
|
||||
Region: "us-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "accountless non-FIPS API us-east-2",
|
||||
url: "ecr.us-east-2.api.aws",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: false,
|
||||
Region: "us-east-2",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// URLs with https:// prefix
|
||||
{
|
||||
name: "with https prefix",
|
||||
url: "https://ecr-fips.us-west-1.amazonaws.com",
|
||||
want: &Registry{
|
||||
ID: "",
|
||||
FIPS: true,
|
||||
Region: "us-west-1",
|
||||
Public: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Invalid endpoints
|
||||
{
|
||||
name: "not an ECR URL",
|
||||
url: "not-an-ecr-url.com",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid account ID length",
|
||||
url: "123.dkr.ecr-fips.us-east-1.amazonaws.com",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
url: "",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "docker hub",
|
||||
url: "docker.io",
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseECREndpoint(tt.url)
|
||||
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("ParseECREndpoint() expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseECREndpoint() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if got.ID != tt.want.ID {
|
||||
t.Errorf("ParseECREndpoint() ID = %v, want %v", got.ID, tt.want.ID)
|
||||
}
|
||||
if got.FIPS != tt.want.FIPS {
|
||||
t.Errorf("ParseECREndpoint() FIPS = %v, want %v", got.FIPS, tt.want.FIPS)
|
||||
}
|
||||
if got.Region != tt.want.Region {
|
||||
t.Errorf("ParseECREndpoint() Region = %v, want %v", got.Region, tt.want.Region)
|
||||
}
|
||||
if got.Public != tt.want.Public {
|
||||
t.Errorf("ParseECREndpoint() Public = %v, want %v", got.Public, tt.want.Public)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -21,6 +22,7 @@ const rwxr__r__ os.FileMode = 0o744
|
||||
|
||||
var filesToBackup = []string{
|
||||
"certs",
|
||||
"chisel",
|
||||
"compose",
|
||||
"config.json",
|
||||
"custom_templates",
|
||||
@@ -30,40 +32,13 @@ var filesToBackup = []string{
|
||||
"portainer.key",
|
||||
"portainer.pub",
|
||||
"tls",
|
||||
"chisel",
|
||||
}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||
}
|
||||
|
||||
{
|
||||
// new export
|
||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||
|
||||
err := datastore.Export(exportFilename)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||
} else {
|
||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||
}
|
||||
}
|
||||
|
||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to backup database")
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
backupDirPath, err := backupDatabaseAndFilesystem(gate, datastore, filestorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
archivePath, err := archive.TarGzDir(backupDirPath)
|
||||
@@ -81,8 +56,40 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
return archivePath, nil
|
||||
}
|
||||
|
||||
func backupDatabaseAndFilesystem(gate *offlinegate.OfflineGate, datastore dataservices.DataStore, filestorePath string) (string, error) {
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
backupDirPath := filepath.Join(filestorePath, "backup", time.Now().Format("2006-01-02_15-04-05"))
|
||||
if err := os.MkdirAll(backupDirPath, rwxr__r__); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup dir")
|
||||
}
|
||||
|
||||
// new export
|
||||
exportFilename := path.Join(backupDirPath, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||
|
||||
if err := datastore.Export(exportFilename); err != nil {
|
||||
log.Error().Err(err).Str("filename", exportFilename).Msg("failed to export")
|
||||
} else {
|
||||
log.Debug().Str("filename", exportFilename).Msg("file exported")
|
||||
}
|
||||
|
||||
if err := backupDb(backupDirPath, datastore); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to backup database")
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
if err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath); err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
}
|
||||
|
||||
return backupDirPath, nil
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
|
||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
||||
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -91,15 +98,13 @@ func encrypt(path string, passphrase string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
defer logs.CloseAndLogErr(in)
|
||||
|
||||
outFileName := fmt.Sprintf("%s.encrypted", path)
|
||||
outFileName := path + ".encrypted"
|
||||
out, err := os.Create(outFileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||
|
||||
return outFileName, err
|
||||
return outFileName, crypto.AesEncrypt(in, out, []byte(passphrase))
|
||||
}
|
||||
|
||||
274
api/backup/backup_test.go
Normal file
274
api/backup/backup_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_DBAtRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.db"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_EncryptedDBAtRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "portainer.edb"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_DBInSubdirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
sub := filesystem.JoinPaths(dir, "backup-2024-01-01")
|
||||
err := os.Mkdir(sub, 0o700)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = os.WriteFile(filesystem.JoinPaths(sub, "portainer.db"), []byte("db"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sub, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_NoDBFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
err := os.WriteFile(filesystem.JoinPaths(dir, "other.file"), []byte("data"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestGetRestoreSourcePath_EmptyDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
result, err := getRestoreSourcePath(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, dir, result)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
plaintext := []byte("sensitive portainer backup data")
|
||||
|
||||
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
|
||||
err := os.WriteFile(srcPath, plaintext, 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedPath, err := encrypt(srcPath, "mysecretpassword")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, srcPath+".encrypted", encryptedPath)
|
||||
|
||||
encryptedData, err := os.ReadFile(encryptedPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("mysecretpassword"))
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := io.ReadAll(decryptedReader)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_WrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
srcPath := filesystem.JoinPaths(dir, "archive.tar.gz")
|
||||
err := os.WriteFile(srcPath, []byte("data"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedPath, err := encrypt(srcPath, "correctpassword")
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedData, err := os.ReadFile(encryptedPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("wrongpassword"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestCreateBackupArchive_NoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
storePath := store.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("", gate, store, storePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := os.Open(archivePath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := f.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
extractDir := t.TempDir()
|
||||
err = archive.ExtractTarGz(f, extractDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFound := false
|
||||
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Name() == "portainer.db" {
|
||||
dbFound = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, dbFound, "archive should contain portainer.db")
|
||||
}
|
||||
|
||||
func TestCreateBackupArchive_WithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
storePath := store.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("backup-secret", gate, store, storePath)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, archivePath, ".encrypted")
|
||||
|
||||
encryptedData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedReader, err := crypto.AesDecrypt(bytes.NewReader(encryptedData), []byte("backup-secret"))
|
||||
require.NoError(t, err)
|
||||
|
||||
extractDir := t.TempDir()
|
||||
err = archive.ExtractTarGz(decryptedReader, extractDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbFound := false
|
||||
err = filepath.Walk(extractDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Name() == "portainer.db" {
|
||||
dbFound = true
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, dbFound, "decrypted archive should contain portainer.db")
|
||||
}
|
||||
|
||||
func TestRestoreArchive_NoPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "", storePath2, gate, store2, cancel)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
||||
|
||||
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRestoreArchive_WithPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("restore-secret", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "restore-secret", storePath2, gate, store2, cancel)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.ErrorIs(t, ctx.Err(), context.Canceled)
|
||||
|
||||
_, err = os.Stat(filesystem.JoinPaths(storePath2, "portainer.db"))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRestoreArchive_WrongPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store1 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath1 := store1.GetConnection().GetStorePath()
|
||||
gate := offlinegate.NewOfflineGate()
|
||||
|
||||
archivePath, err := CreateBackupArchive("correct-password", gate, store1, storePath1)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveData, err := os.ReadFile(archivePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, store2 := datastore.MustNewTestStore(t, true, false)
|
||||
storePath2 := store2.GetConnection().GetStorePath()
|
||||
|
||||
_, cancel := context.WithCancel(t.Context())
|
||||
err = RestoreArchive(bytes.NewReader(archiveData), "wrong-password", storePath2, gate, store2, cancel)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var filesToRestore = append(filesToBackup, "portainer.db")
|
||||
@@ -26,22 +28,25 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
||||
if password != "" {
|
||||
archive, err = decrypt(archive, password)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to decrypt the archive")
|
||||
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
}
|
||||
|
||||
restorePath := filepath.Join(filestorePath, "restore", time.Now().Format("20060102150405"))
|
||||
defer os.RemoveAll(filepath.Dir(restorePath))
|
||||
defer func() {
|
||||
if err := os.RemoveAll(filepath.Dir(restorePath)); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to clean up restore files")
|
||||
}
|
||||
}()
|
||||
|
||||
err = extractArchive(archive, restorePath)
|
||||
if err != nil {
|
||||
if err := extractArchive(archive, restorePath); err != nil {
|
||||
return errors.Wrap(err, "cannot extract files from the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
|
||||
unlock := gate.Lock()
|
||||
defer unlock()
|
||||
|
||||
if err = datastore.Close(); err != nil {
|
||||
if err := datastore.Close(); err != nil {
|
||||
return errors.Wrap(err, "Failed to stop db")
|
||||
}
|
||||
|
||||
@@ -51,7 +56,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
||||
return errors.Wrap(err, "failed to restore from backup. Portainer database missing from backup file")
|
||||
}
|
||||
|
||||
if err = restoreFiles(restorePath, filestorePath); err != nil {
|
||||
if err := restoreFiles(restorePath, filestorePath); err != nil {
|
||||
return errors.Wrap(err, "failed to restore the system state")
|
||||
}
|
||||
|
||||
@@ -89,8 +94,7 @@ func getRestoreSourcePath(dir string) (string, error) {
|
||||
|
||||
func restoreFiles(srcDir string, destinationDir string) error {
|
||||
for _, filename := range filesToRestore {
|
||||
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||
if err != nil {
|
||||
if err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -98,14 +102,18 @@ func restoreFiles(srcDir string, destinationDir string) error {
|
||||
// TODO: This is very boltdb module specific once again due to the filename. Move to bolt module? Refactor for another day
|
||||
|
||||
// Prevent the possibility of having both databases. Remove any default new instance
|
||||
os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName))
|
||||
os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName))
|
||||
if err := os.Remove(filepath.Join(destinationDir, boltdb.DatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Remove(filepath.Join(destinationDir, boltdb.EncryptedDatabaseFileName)); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Now copy the database. It'll be either portainer.db or portainer.edb
|
||||
|
||||
// Note: CopyPath does not return an error if the source file doesn't exist
|
||||
err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir)
|
||||
if err != nil {
|
||||
if err := filesystem.CopyPath(filepath.Join(srcDir, boltdb.EncryptedDatabaseFileName), destinationDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package build
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Variables to be set during the build time
|
||||
var BuildNumber string
|
||||
var ImageTag string
|
||||
var NodejsVersion string
|
||||
var YarnVersion string
|
||||
var WebpackVersion string
|
||||
var GoVersion string = runtime.Version()
|
||||
var GitCommit string
|
||||
@@ -54,8 +54,8 @@ func ecdsaGenerateKey(c elliptic.Curve, rand io.Reader) (*ecdsa.PrivateKey, erro
|
||||
}
|
||||
|
||||
priv := new(ecdsa.PrivateKey)
|
||||
priv.PublicKey.Curve = c
|
||||
priv.Curve = c
|
||||
priv.D = k
|
||||
priv.PublicKey.X, priv.PublicKey.Y = c.ScalarBaseMult(k.Bytes())
|
||||
priv.X, priv.Y = c.ScalarBaseMult(k.Bytes())
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGenerateGo119CompatibleKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
type args struct {
|
||||
seed string
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
)
|
||||
|
||||
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint).
|
||||
func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portainer.EdgeJob) {
|
||||
if endpoint.Edge.AsyncMode {
|
||||
return
|
||||
}
|
||||
|
||||
service.mu.Lock()
|
||||
tunnel := service.getTunnelDetails(endpoint.ID)
|
||||
|
||||
existingJobIndex := -1
|
||||
for idx, existingJob := range tunnel.Jobs {
|
||||
if existingJob.ID == edgeJob.ID {
|
||||
existingJobIndex = idx
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existingJobIndex == -1 {
|
||||
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
|
||||
} else {
|
||||
tunnel.Jobs[existingJobIndex] = *edgeJob
|
||||
}
|
||||
|
||||
cache.Del(endpoint.ID)
|
||||
|
||||
service.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
|
||||
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
|
||||
service.mu.Lock()
|
||||
|
||||
for endpointID, tunnel := range service.tunnelDetailsMap {
|
||||
n := 0
|
||||
for _, edgeJob := range tunnel.Jobs {
|
||||
if edgeJob.ID != edgeJobID {
|
||||
tunnel.Jobs[n] = edgeJob
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
tunnel.Jobs = tunnel.Jobs[:n]
|
||||
|
||||
cache.Del(endpointID)
|
||||
}
|
||||
|
||||
service.mu.Unlock()
|
||||
}
|
||||
|
||||
func (service *Service) RemoveEdgeJobFromEndpoint(endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID) {
|
||||
service.mu.Lock()
|
||||
tunnel := service.getTunnelDetails(endpointID)
|
||||
|
||||
n := 0
|
||||
for _, edgeJob := range tunnel.Jobs {
|
||||
if edgeJob.ID != edgeJobID {
|
||||
tunnel.Jobs[n] = edgeJob
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
tunnel.Jobs = tunnel.Jobs[:n]
|
||||
|
||||
cache.Del(endpointID)
|
||||
|
||||
service.mu.Unlock()
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/pkg/schedule"
|
||||
|
||||
chserver "github.com/jpillora/chisel/server"
|
||||
"github.com/jpillora/chisel/share/ccrypto"
|
||||
@@ -19,7 +20,6 @@ import (
|
||||
|
||||
const (
|
||||
tunnelCleanupInterval = 10 * time.Second
|
||||
requiredTimeout = 15 * time.Second
|
||||
activeTimeout = 4*time.Minute + 30*time.Second
|
||||
pingTimeout = 3 * time.Second
|
||||
)
|
||||
@@ -28,32 +28,54 @@ const (
|
||||
// It is used to start a reverse tunnel server and to manage the connection status of each tunnel
|
||||
// connected to the tunnel server.
|
||||
type Service struct {
|
||||
serverFingerprint string
|
||||
serverPort string
|
||||
tunnelDetailsMap map[portainer.EndpointID]*portainer.TunnelDetails
|
||||
dataStore dataservices.DataStore
|
||||
snapshotService portainer.SnapshotService
|
||||
chiselServer *chserver.Server
|
||||
shutdownCtx context.Context
|
||||
ProxyManager *proxy.Manager
|
||||
mu sync.Mutex
|
||||
fileService portainer.FileService
|
||||
serverFingerprint string
|
||||
serverPort string
|
||||
activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
||||
edgeJobs map[portainer.EndpointID][]portainer.EdgeJob
|
||||
dataStore dataservices.DataStore
|
||||
snapshotService portainer.SnapshotService
|
||||
chiselServer *chserver.Server
|
||||
shutdownCtx context.Context
|
||||
ProxyManager *proxy.Manager
|
||||
mu sync.RWMutex
|
||||
fileService portainer.FileService
|
||||
defaultCheckinInterval int
|
||||
}
|
||||
|
||||
// NewService returns a pointer to a new instance of Service
|
||||
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
|
||||
defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds
|
||||
|
||||
settings, err := dataStore.Settings().Settings()
|
||||
if err == nil {
|
||||
defaultCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
} else {
|
||||
log.Error().Err(err).Msg("unable to retrieve the settings from the database")
|
||||
}
|
||||
|
||||
return &Service{
|
||||
tunnelDetailsMap: make(map[portainer.EndpointID]*portainer.TunnelDetails),
|
||||
dataStore: dataStore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
fileService: fileService,
|
||||
activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails),
|
||||
edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob),
|
||||
dataStore: dataStore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
fileService: fileService,
|
||||
defaultCheckinInterval: defaultCheckinInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// pingAgent ping the given agent so that the agent can keep the tunnel alive
|
||||
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
||||
tunnel := service.GetTunnelDetails(endpointID)
|
||||
requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port)
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tunnelAddr, err := service.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr)
|
||||
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -68,55 +90,55 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
|
||||
return err
|
||||
}
|
||||
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return resp.Body.Close()
|
||||
}
|
||||
|
||||
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
|
||||
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
|
||||
go func() {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("max_alive_minutes", maxAlive.Minutes()).
|
||||
Msg("KeepTunnelAlive: start")
|
||||
go service.keepTunnelAlive(endpointID, ctx, maxAlive)
|
||||
}
|
||||
|
||||
maxAliveTicker := time.NewTicker(maxAlive)
|
||||
defer maxAliveTicker.Stop()
|
||||
func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("max_alive_minutes", maxAlive.Minutes()).
|
||||
Msg("KeepTunnelAlive: start")
|
||||
|
||||
pingTicker := time.NewTicker(tunnelCleanupInterval)
|
||||
defer pingTicker.Stop()
|
||||
maxAliveTicker := time.NewTicker(maxAlive)
|
||||
defer maxAliveTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-pingTicker.C:
|
||||
service.SetTunnelStatusToActive(endpointID)
|
||||
err := service.pingAgent(endpointID)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("KeepTunnelAlive: ping agent")
|
||||
}
|
||||
case <-maxAliveTicker.C:
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("timeout_minutes", maxAlive.Minutes()).
|
||||
Msg("KeepTunnelAlive: tunnel keep alive timeout")
|
||||
pingTicker := time.NewTicker(tunnelCleanupInterval)
|
||||
defer pingTicker.Stop()
|
||||
|
||||
return
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
for {
|
||||
select {
|
||||
case <-pingTicker.C:
|
||||
service.UpdateLastActivity(endpointID)
|
||||
|
||||
if err := service.pingAgent(endpointID); err != nil {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("KeepTunnelAlive: tunnel stop")
|
||||
|
||||
return
|
||||
Msg("KeepTunnelAlive: ping agent")
|
||||
}
|
||||
case <-maxAliveTicker.C:
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("timeout_minutes", maxAlive.Minutes()).
|
||||
Msg("KeepTunnelAlive: tunnel keep alive timeout")
|
||||
|
||||
return
|
||||
case <-ctx.Done():
|
||||
err := ctx.Err()
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("KeepTunnelAlive: tunnel stop")
|
||||
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// StartTunnelServer starts a tunnel server on the specified addr and port.
|
||||
@@ -126,7 +148,6 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
|
||||
// The snapshotter is used in the tunnel status verification process.
|
||||
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
|
||||
privateKeyFile, err := service.retrievePrivateKeyFile()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -144,21 +165,21 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
|
||||
service.serverFingerprint = chiselServer.GetFingerprint()
|
||||
service.serverPort = port
|
||||
|
||||
err = chiselServer.Start(addr, port)
|
||||
if err != nil {
|
||||
if err := chiselServer.Start(addr, port); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.chiselServer = chiselServer
|
||||
|
||||
// TODO: work-around Chisel default behavior.
|
||||
// By default, Chisel will allow anyone to connect if no user exists.
|
||||
username, password := generateRandomCredentials()
|
||||
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
|
||||
if err != nil {
|
||||
if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
service.snapshotService = snapshotService
|
||||
|
||||
go service.startTunnelVerificationLoop()
|
||||
|
||||
return nil
|
||||
@@ -172,37 +193,39 @@ func (service *Service) StopTunnelServer() error {
|
||||
func (service *Service) retrievePrivateKeyFile() (string, error) {
|
||||
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
|
||||
|
||||
exist, _ := service.fileService.FileExists(privateKeyFile)
|
||||
if !exist {
|
||||
log.Debug().
|
||||
Str("private-key", privateKeyFile).
|
||||
Msg("Chisel private key file does not exist")
|
||||
|
||||
privateKey, err := ccrypto.GenerateKey("")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("Failed to generate chisel private key")
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = service.fileService.StoreChiselPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("Failed to save Chisel private key to disk")
|
||||
return "", err
|
||||
} else {
|
||||
log.Info().
|
||||
Str("private-key", privateKeyFile).
|
||||
Msg("Generated a new Chisel private key file")
|
||||
}
|
||||
} else {
|
||||
if exists, _ := service.fileService.FileExists(privateKeyFile); exists {
|
||||
log.Info().
|
||||
Str("private-key", privateKeyFile).
|
||||
Msg("Found Chisel private key file on disk")
|
||||
Msg("found Chisel private key file on disk")
|
||||
|
||||
return privateKeyFile, nil
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("private-key", privateKeyFile).
|
||||
Msg("chisel private key file does not exist")
|
||||
|
||||
privateKey, err := ccrypto.GenerateKey("")
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to generate chisel private key")
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to save Chisel private key to disk")
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("private-key", privateKeyFile).
|
||||
Msg("generated a new Chisel private key file")
|
||||
|
||||
return privateKeyFile, nil
|
||||
}
|
||||
|
||||
@@ -211,81 +234,94 @@ func (service *Service) startTunnelVerificationLoop() {
|
||||
Float64("check_interval_seconds", tunnelCleanupInterval.Seconds()).
|
||||
Msg("starting tunnel management process")
|
||||
|
||||
ticker := time.NewTicker(tunnelCleanupInterval)
|
||||
schedule.RunOnInterval(service.shutdownCtx, tunnelCleanupInterval, service.checkTunnels, func() {
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.checkTunnels()
|
||||
case <-service.shutdownCtx.Done():
|
||||
log.Debug().Msg("shutting down tunnel service")
|
||||
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
|
||||
ticker.Stop()
|
||||
return
|
||||
if err := service.StopTunnelServer(); err != nil {
|
||||
log.Debug().Err(err).Msg("stopped tunnel service")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// checkTunnels finds tunnels that need snapshots and processes them one at a time.
|
||||
// For active tunnels missing an initial snapshot, it takes one without closing the tunnel.
|
||||
// For tunnels idle past activeTimeout, it snapshots and closes them.
|
||||
func (service *Service) checkTunnels() {
|
||||
tunnels := make(map[portainer.EndpointID]portainer.TunnelDetails)
|
||||
service.mu.RLock()
|
||||
|
||||
service.mu.Lock()
|
||||
for key, tunnel := range service.tunnelDetailsMap {
|
||||
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
|
||||
continue
|
||||
}
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && time.Since(tunnel.LastActivity) < requiredTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentActive && time.Since(tunnel.LastActivity) < activeTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
tunnels[key] = *tunnel
|
||||
}
|
||||
service.mu.Unlock()
|
||||
|
||||
for endpointID, tunnel := range tunnels {
|
||||
for endpointID, tunnel := range service.activeTunnels {
|
||||
elapsed := time.Since(tunnel.LastActivity)
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Str("status", tunnel.Status).
|
||||
Float64("status_time_seconds", elapsed.Seconds()).
|
||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
||||
Msg("environment tunnel monitoring")
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed > requiredTimeout {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Str("status", tunnel.Status).
|
||||
Float64("status_time_seconds", elapsed.Seconds()).
|
||||
Float64("timeout_seconds", requiredTimeout.Seconds()).
|
||||
Msg("REQUIRED state timeout exceeded")
|
||||
}
|
||||
tunnelPort := tunnel.Port
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentActive && elapsed > activeTimeout {
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Str("status", tunnel.Status).
|
||||
Float64("status_time_seconds", elapsed.Seconds()).
|
||||
Float64("timeout_seconds", activeTimeout.Seconds()).
|
||||
Msg("ACTIVE state timeout exceeded")
|
||||
if !tunnel.HasSnapshot && elapsed < activeTimeout {
|
||||
service.mu.RUnlock()
|
||||
|
||||
err := service.snapshotEnvironment(endpointID, tunnel.Port)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
if endpointHasSnapshot(service.dataStore, endpointID) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Msg("taking initial snapshot for active Edge environment")
|
||||
|
||||
if service.snapshotAndLog(endpointID, tunnelPort) {
|
||||
service.markSnapshotTaken(endpointID)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
|
||||
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
|
||||
continue
|
||||
}
|
||||
|
||||
service.mu.RUnlock()
|
||||
|
||||
log.Debug().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Float64("last_activity_seconds", elapsed.Seconds()).
|
||||
Float64("timeout_seconds", activeTimeout.Seconds()).
|
||||
Msg("last activity timeout exceeded")
|
||||
|
||||
service.snapshotAndLog(endpointID, tunnelPort)
|
||||
service.close(endpointID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
service.mu.RUnlock()
|
||||
}
|
||||
|
||||
func (service *Service) snapshotAndLog(endpointID portainer.EndpointID, tunnelPort int) bool {
|
||||
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
|
||||
log.Error().
|
||||
Int("endpoint_id", int(endpointID)).
|
||||
Err(err).
|
||||
Msg("unable to snapshot Edge environment")
|
||||
|
||||
if service.dataStore.IsErrObjectNotFound(err) {
|
||||
service.close(endpointID)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (service *Service) markSnapshotTaken(endpointID portainer.EndpointID) {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if tun, ok := service.activeTunnels[endpointID]; ok {
|
||||
tun.HasSnapshot = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,58 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
endpointID := portainer.EndpointID(1)
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
s := NewService(nil, nil, nil)
|
||||
type mockSnapshotService struct {
|
||||
snapshotFn func(endpoint *portainer.Endpoint) error
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) Start(_ context.Context) {}
|
||||
|
||||
func (m *mockSnapshotService) SetSnapshotInterval(_ string) error { return nil }
|
||||
|
||||
func (m *mockSnapshotService) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
|
||||
if m.snapshotFn != nil {
|
||||
return m.snapshotFn(endpoint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSnapshotService) FillSnapshotData(_ *portainer.Endpoint, _ bool) error { return nil }
|
||||
|
||||
func newEdgeEndpoint(id portainer.EndpointID) *portainer.Endpoint {
|
||||
return &portainer.Endpoint{
|
||||
ID: id,
|
||||
EdgeID: "test-edge-id",
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
UserTrusted: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingAgentPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
defer func() {
|
||||
require.Nil(t, recover())
|
||||
@@ -28,12 +66,173 @@ func TestPingAgentPanic(t *testing.T) {
|
||||
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
require.NoError(t, http.Serve(ln, mux))
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
|
||||
s.getTunnelDetails(endpointID)
|
||||
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
err = s.Open(endpoint)
|
||||
require.NoError(t, err)
|
||||
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
require.Error(t, s.pingAgent(endpointID))
|
||||
require.Error(t, s.pingAgent(endpoint.ID))
|
||||
require.NoError(t, srv.Shutdown(t.Context()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
}
|
||||
|
||||
func TestOpenDefaultsHasSnapshotToFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(1)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
|
||||
err := s.Open(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSetsHasSnapshotWhenSnapshotExists(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(2)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snap := &portainer.Snapshot{
|
||||
EndpointID: endpoint.ID,
|
||||
Docker: &portainer.DockerSnapshot{},
|
||||
}
|
||||
err = store.Snapshot().Create(snap)
|
||||
require.NoError(t, err)
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50003,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsSnapshotsActiveEnvironmentAndKeepsTunnelAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(3)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50000,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after snapshot")
|
||||
require.True(t, s.activeTunnels[endpoint.ID].HasSnapshot)
|
||||
}
|
||||
|
||||
func TestCheckTunnelsKeepsHasSnapshotFalseOnSnapshotFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(4)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
return errors.New("snapshot failed")
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50001,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.NotNil(t, s.activeTunnels[endpoint.ID], "tunnel must remain open after failed snapshot")
|
||||
require.False(t, s.activeTunnels[endpoint.ID].HasSnapshot, "HasSnapshot must stay false after failure")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesStaleEntryForDeletedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
// Endpoint is not created in the store, simulates deletion while tunnel stays open.
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels[1] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50010,
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.Nil(t, s.activeTunnels[1], "stale tunnel for deleted endpoint must be removed immediately")
|
||||
}
|
||||
|
||||
func TestCheckTunnelsClosesIdleTunnelAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
endpoint := newEdgeEndpoint(5)
|
||||
_, store := datastore.MustNewTestStore(t, false, true)
|
||||
|
||||
err := store.Endpoint().Create(endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
snapshotCalled := false
|
||||
svc := &mockSnapshotService{
|
||||
snapshotFn: func(_ *portainer.Endpoint) error {
|
||||
snapshotCalled = true
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
s := NewService(store, nil, nil)
|
||||
s.snapshotService = svc
|
||||
s.activeTunnels[endpoint.ID] = &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: 50002,
|
||||
LastActivity: time.Now().Add(-(activeTimeout + time.Second)),
|
||||
}
|
||||
|
||||
s.checkTunnels()
|
||||
|
||||
require.True(t, snapshotCalled)
|
||||
require.Nil(t, s.activeTunnels[endpoint.ID], "tunnel must be closed after idle timeout")
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/pkg/libcrypto"
|
||||
"github.com/portainer/portainer/pkg/librand"
|
||||
|
||||
"github.com/dchest/uniuri"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,171 +25,209 @@ const (
|
||||
maxAvailablePort = 65535
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments")
|
||||
ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments")
|
||||
ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment")
|
||||
)
|
||||
|
||||
// Open will mark the tunnel as REQUIRED so the agent opens it
|
||||
func (s *Service) Open(endpoint *portainer.Endpoint) error {
|
||||
if !endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
return ErrNonEdgeEnv
|
||||
}
|
||||
|
||||
if endpoint.Edge.AsyncMode {
|
||||
return ErrAsyncEnv
|
||||
}
|
||||
|
||||
if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted {
|
||||
return ErrInvalidEnv
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.activeTunnels[endpoint.ID]; ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer cache.Del(endpoint.ID)
|
||||
|
||||
tun := &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentManagementRequired,
|
||||
Port: s.getUnusedPort(),
|
||||
LastActivity: time.Now(),
|
||||
}
|
||||
|
||||
username, password := generateRandomCredentials()
|
||||
|
||||
if s.chiselServer != nil {
|
||||
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port)
|
||||
|
||||
if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tun.Credentials = credentials
|
||||
|
||||
s.activeTunnels[endpoint.ID] = tun
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// close removes the tunnel from the map so the agent will close it.
|
||||
// The lock is released before cleaning up the chisel user and proxy to avoid
|
||||
// blocking Config/Open callers while DeleteUser interacts with chisel internals.
|
||||
func (s *Service) close(endpointID portainer.EndpointID) {
|
||||
s.mu.Lock()
|
||||
|
||||
tun, ok := s.activeTunnels[endpointID]
|
||||
if !ok {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
delete(s.activeTunnels, endpointID)
|
||||
cache.Del(endpointID)
|
||||
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.chiselServer != nil {
|
||||
user, _, _ := strings.Cut(tun.Credentials, ":")
|
||||
s.chiselServer.DeleteUser(user)
|
||||
}
|
||||
|
||||
if s.ProxyManager != nil {
|
||||
s.ProxyManager.DeleteEndpointProxy(endpointID)
|
||||
}
|
||||
}
|
||||
|
||||
// Config returns the tunnel details needed for the agent to connect
|
||||
func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
if tun, ok := s.activeTunnels[endpointID]; ok {
|
||||
return *tun
|
||||
}
|
||||
|
||||
return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle}
|
||||
}
|
||||
|
||||
// TunnelAddr returns the address of the local tunnel, including the port, it
|
||||
// will block until the tunnel is ready
|
||||
func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
|
||||
if err := s.Open(endpoint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tun := s.Config(endpoint.ID)
|
||||
checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second
|
||||
|
||||
for t0 := time.Now(); ; {
|
||||
if time.Since(t0) > 2*checkinInterval {
|
||||
s.close(endpoint.ID)
|
||||
|
||||
return "", errors.New("unable to open the tunnel")
|
||||
}
|
||||
|
||||
// Check if the tunnel is established
|
||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port})
|
||||
if err != nil {
|
||||
time.Sleep(checkinInterval / 100)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to close tcp connection")
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
s.UpdateLastActivity(endpoint.ID)
|
||||
|
||||
return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil
|
||||
}
|
||||
|
||||
// tryEffectiveCheckinInterval avoids a potential deadlock by returning a
|
||||
// previous known value after a timeout
|
||||
func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int {
|
||||
ch := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.defaultCheckinInterval
|
||||
case i := <-ch:
|
||||
s.mu.Lock()
|
||||
s.defaultCheckinInterval = i
|
||||
s.mu.Unlock()
|
||||
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout
|
||||
func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if tun, ok := s.activeTunnels[endpointID]; ok {
|
||||
tun.LastActivity = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: it needs to be called with the lock acquired
|
||||
// getUnusedPort is used to generate an unused random port in the dynamic port range.
|
||||
// Dynamic ports (also called private ports) are 49152 to 65535.
|
||||
func (service *Service) getUnusedPort() int {
|
||||
port := randomInt(minAvailablePort, maxAvailablePort)
|
||||
|
||||
for _, tunnel := range service.tunnelDetailsMap {
|
||||
for _, tunnel := range service.activeTunnels {
|
||||
if tunnel.Port == port {
|
||||
return service.getUnusedPort()
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||
if err == nil {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Warn().Msg("failed to close tcp connection that checks if port is free")
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Int("port", port).
|
||||
Msg("selected port is in use, trying a different one")
|
||||
|
||||
return service.getUnusedPort()
|
||||
}
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
func randomInt(min, max int) int {
|
||||
return min + rand.Intn(max-min)
|
||||
}
|
||||
|
||||
// NOTE: it needs to be called with the lock acquired
|
||||
func (service *Service) getTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
|
||||
|
||||
if tunnel, ok := service.tunnelDetailsMap[endpointID]; ok {
|
||||
return tunnel
|
||||
}
|
||||
|
||||
tunnel := &portainer.TunnelDetails{
|
||||
Status: portainer.EdgeAgentIdle,
|
||||
}
|
||||
|
||||
service.tunnelDetailsMap[endpointID] = tunnel
|
||||
|
||||
cache.Del(endpointID)
|
||||
|
||||
return tunnel
|
||||
}
|
||||
|
||||
// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint).
|
||||
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) portainer.TunnelDetails {
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
return *service.getTunnelDetails(endpointID)
|
||||
}
|
||||
|
||||
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
|
||||
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (portainer.TunnelDetails, error) {
|
||||
if endpoint.Edge.AsyncMode {
|
||||
return portainer.TunnelDetails{}, errors.New("cannot open tunnel on async endpoint")
|
||||
}
|
||||
|
||||
tunnel := service.GetTunnelDetails(endpoint.ID)
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentActive {
|
||||
// update the LastActivity
|
||||
service.SetTunnelStatusToActive(endpoint.ID)
|
||||
}
|
||||
|
||||
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
|
||||
err := service.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return portainer.TunnelDetails{}, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
|
||||
}
|
||||
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
settings, err := service.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return portainer.TunnelDetails{}, fmt.Errorf("failed fetching settings from db: %w", err)
|
||||
}
|
||||
|
||||
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Duration(endpoint.EdgeCheckinInterval) * time.Second)
|
||||
}
|
||||
|
||||
return service.GetTunnelDetails(endpoint.ID), nil
|
||||
}
|
||||
|
||||
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
|
||||
// It sets the status to ACTIVE.
|
||||
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
|
||||
service.mu.Lock()
|
||||
tunnel := service.getTunnelDetails(endpointID)
|
||||
tunnel.Status = portainer.EdgeAgentActive
|
||||
tunnel.Credentials = ""
|
||||
tunnel.LastActivity = time.Now()
|
||||
service.mu.Unlock()
|
||||
|
||||
cache.Del(endpointID)
|
||||
}
|
||||
|
||||
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint).
|
||||
// It sets the status to IDLE.
|
||||
// It removes any existing credentials associated to the tunnel.
|
||||
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
|
||||
service.mu.Lock()
|
||||
|
||||
tunnel := service.getTunnelDetails(endpointID)
|
||||
tunnel.Status = portainer.EdgeAgentIdle
|
||||
tunnel.Port = 0
|
||||
tunnel.LastActivity = time.Now()
|
||||
|
||||
credentials := tunnel.Credentials
|
||||
if credentials != "" {
|
||||
tunnel.Credentials = ""
|
||||
|
||||
if service.chiselServer != nil {
|
||||
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
|
||||
}
|
||||
}
|
||||
|
||||
service.ProxyManager.DeleteEndpointProxy(endpointID)
|
||||
|
||||
service.mu.Unlock()
|
||||
|
||||
cache.Del(endpointID)
|
||||
}
|
||||
|
||||
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint).
|
||||
// It sets the status to REQUIRED.
|
||||
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
|
||||
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
|
||||
// Credentials are encrypted using the Edge ID associated to the environment(endpoint).
|
||||
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
|
||||
defer cache.Del(endpointID)
|
||||
|
||||
tunnel := service.getTunnelDetails(endpointID)
|
||||
|
||||
service.mu.Lock()
|
||||
defer service.mu.Unlock()
|
||||
|
||||
if tunnel.Port == 0 {
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tunnel.Status = portainer.EdgeAgentManagementRequired
|
||||
tunnel.Port = service.getUnusedPort()
|
||||
tunnel.LastActivity = time.Now()
|
||||
|
||||
username, password := generateRandomCredentials()
|
||||
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
|
||||
|
||||
if service.chiselServer != nil {
|
||||
err = service.chiselServer.AddUser(username, password, authorizedRemote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tunnel.Credentials = credentials
|
||||
}
|
||||
|
||||
return nil
|
||||
return min + librand.Intn(max-min)
|
||||
}
|
||||
|
||||
func generateRandomCredentials() (string, string) {
|
||||
username := uniuri.NewLen(8)
|
||||
password := uniuri.NewLen(8)
|
||||
|
||||
return username, password
|
||||
}
|
||||
|
||||
@@ -198,3 +241,18 @@ func encryptCredentials(username, password, key string) (string, error) {
|
||||
|
||||
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
|
||||
}
|
||||
|
||||
func endpointHasSnapshot(dataStore dataservices.DataStore, endpointID portainer.EndpointID) bool {
|
||||
var hasSnapshot bool
|
||||
_ = dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
s, err := tx.Snapshot().Read(endpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasSnapshot = s.Docker != nil || s.Kubernetes != nil
|
||||
return nil
|
||||
})
|
||||
|
||||
return hasSnapshot
|
||||
}
|
||||
|
||||
80
api/chisel/tunnel_test.go
Normal file
80
api/chisel/tunnel_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
type testSettingsService struct {
|
||||
dataservices.SettingsService
|
||||
}
|
||||
|
||||
func (s *testSettingsService) Settings() (*portainer.Settings, error) {
|
||||
return &portainer.Settings{
|
||||
EdgeAgentCheckinInterval: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type testStore struct {
|
||||
dataservices.DataStore
|
||||
}
|
||||
|
||||
func (s *testStore) Settings() dataservices.SettingsService {
|
||||
return &testSettingsService{}
|
||||
}
|
||||
|
||||
func TestGetUnusedPort(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
existingTunnels map[portainer.EndpointID]*portainer.TunnelDetails
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
name: "simple case",
|
||||
},
|
||||
{
|
||||
name: "existing tunnels",
|
||||
existingTunnels: map[portainer.EndpointID]*portainer.TunnelDetails{
|
||||
portainer.EndpointID(1): {
|
||||
Port: 53072,
|
||||
},
|
||||
portainer.EndpointID(2): {
|
||||
Port: 63072,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := &testStore{}
|
||||
s := NewService(store, nil, nil)
|
||||
s.activeTunnels = tc.existingTunnels
|
||||
port := s.getUnusedPort()
|
||||
|
||||
if port < 49152 || port > 65535 {
|
||||
t.Fatalf("Expected port to be inbetween 49152 and 65535 but got %d", port)
|
||||
}
|
||||
|
||||
for _, tun := range tc.existingTunnels {
|
||||
if tun.Port == port {
|
||||
t.Fatalf("returned port %d already has an existing tunnel", port)
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
|
||||
if err == nil {
|
||||
// Ignore error
|
||||
_ = conn.Close()
|
||||
t.Fatalf("expected port %d to be unused", port)
|
||||
} else if !strings.Contains(err.Error(), "connection refused") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
146
api/cli/cli.go
146
api/cli/cli.go
@@ -9,46 +9,35 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
// Service implements the CLIService interface
|
||||
type Service struct{}
|
||||
|
||||
var (
|
||||
errInvalidEndpointProtocol = errors.New("Invalid environment protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||
errSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe")
|
||||
errInvalidSnapshotInterval = errors.New("Invalid snapshot interval")
|
||||
errAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file")
|
||||
ErrInvalidEndpointProtocol = errors.New("Invalid environment protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||
ErrSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe")
|
||||
ErrInvalidSnapshotInterval = errors.New("Invalid snapshot interval")
|
||||
ErrAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file")
|
||||
)
|
||||
|
||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||
func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
kingpin.Version(version)
|
||||
|
||||
flags := &portainer.CLIFlags{
|
||||
func CLIFlags() *portainer.CLIFlags {
|
||||
return &portainer.CLIFlags{
|
||||
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
|
||||
AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(),
|
||||
TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(),
|
||||
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
DemoEnvironment: kingpin.Flag("demo", "Demo environment").Bool(),
|
||||
EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Strings(),
|
||||
FeatureFlags: kingpin.Flag("feat", "List of feature flags").Envar(portainer.FeatureFlagEnvVar).Strings(),
|
||||
EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(),
|
||||
TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(),
|
||||
TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).Bool(),
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(),
|
||||
HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(),
|
||||
HTTPEnabled: kingpin.Flag("http-enabled", "Serve portainer on http").Default(defaultHTTPEnabled).Bool(),
|
||||
SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(),
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
|
||||
Rollback: kingpin.Flag("rollback", "Rollback the database to the previous backup").Bool(),
|
||||
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
|
||||
@@ -62,11 +51,65 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
|
||||
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
|
||||
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
|
||||
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
|
||||
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
|
||||
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
|
||||
NoSetupToken: kingpin.Flag("no-setup-token", "Disable the setup token requirement for admin initialization and restore on an uninitialized instance").Envar(portainer.NoSetupTokenEnvVar).Bool(),
|
||||
SetupToken: kingpin.Flag("setup-token", "Set a custom setup token for admin initialization and restore on an uninitialized instance (overrides auto-generation)").Envar(portainer.SetupTokenEnvVar).String(),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||
func (Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
kingpin.Version(version)
|
||||
|
||||
var hasSSLFlag, hasSSLCertFlag, hasSSLKeyFlag bool
|
||||
sslFlag := kingpin.Flag(
|
||||
"ssl",
|
||||
"Secure Portainer instance using SSL (deprecated)",
|
||||
).Default(defaultSSL).IsSetByUser(&hasSSLFlag)
|
||||
ssl := sslFlag.Bool()
|
||||
sslCertFlag := kingpin.Flag(
|
||||
"sslcert",
|
||||
"Path to the SSL certificate used to secure the Portainer instance",
|
||||
).IsSetByUser(&hasSSLCertFlag)
|
||||
sslCert := sslCertFlag.String()
|
||||
sslKeyFlag := kingpin.Flag(
|
||||
"sslkey",
|
||||
"Path to the SSL key used to secure the Portainer instance",
|
||||
).IsSetByUser(&hasSSLKeyFlag)
|
||||
sslKey := sslKeyFlag.String()
|
||||
|
||||
flags := CLIFlags()
|
||||
|
||||
var hasTLSFlag, hasTLSCertFlag, hasTLSKeyFlag bool
|
||||
tlsFlag := kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).IsSetByUser(&hasTLSFlag)
|
||||
flags.TLS = tlsFlag.Bool()
|
||||
tlsCertFlag := kingpin.Flag(
|
||||
"tlscert",
|
||||
"Path to the TLS certificate file",
|
||||
).Default(defaultTLSCertPath).IsSetByUser(&hasTLSCertFlag)
|
||||
flags.TLSCert = tlsCertFlag.String()
|
||||
tlsKeyFlag := kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).IsSetByUser(&hasTLSKeyFlag)
|
||||
flags.TLSKey = tlsKeyFlag.String()
|
||||
flags.TLSCacert = kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String()
|
||||
|
||||
var hasKubectlShellImageFlag bool
|
||||
kubectlShellImageFlag := kingpin.Flag(
|
||||
"kubectl-shell-image",
|
||||
"Kubectl shell image",
|
||||
).Envar(portainer.KubectlShellImageEnvVar).
|
||||
Default(portainer.DefaultKubectlShellImage).
|
||||
IsSetByUser(&hasKubectlShellImageFlag)
|
||||
flags.KubectlShellImage = kubectlShellImageFlag.String()
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
_, kubectlShellImageEnvVarSet := os.LookupEnv(portainer.KubectlShellImageEnvVar)
|
||||
flags.KubectlShellImageSet = hasKubectlShellImageFlag || kubectlShellImageEnvVarSet
|
||||
|
||||
if !filepath.IsAbs(*flags.Assets) {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -76,25 +119,58 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
||||
}
|
||||
|
||||
// If the user didn't provide a tls flag remove the defaults to match previous behaviour
|
||||
if !hasTLSFlag {
|
||||
if !hasTLSCertFlag {
|
||||
*flags.TLSCert = ""
|
||||
}
|
||||
|
||||
if !hasTLSKeyFlag {
|
||||
*flags.TLSKey = ""
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslFlag.Model().Name, tlsFlag.Model().Name)
|
||||
|
||||
if !hasTLSFlag {
|
||||
flags.TLS = ssl
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLCertFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslCertFlag.Model().Name, tlsCertFlag.Model().Name)
|
||||
|
||||
if !hasTLSCertFlag {
|
||||
flags.TLSCert = sslCert
|
||||
}
|
||||
}
|
||||
|
||||
if hasSSLKeyFlag {
|
||||
log.Warn().Msgf("the %q flag is deprecated. use %q instead.", sslKeyFlag.Model().Name, tlsKeyFlag.Model().Name)
|
||||
|
||||
if !hasTLSKeyFlag {
|
||||
flags.TLSKey = sslKey
|
||||
}
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
// ValidateFlags validates the values of the flags.
|
||||
func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
func (Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
displayDeprecationWarnings(flags)
|
||||
|
||||
err := validateEndpointURL(*flags.EndpointURL)
|
||||
if err != nil {
|
||||
if err := ValidateEndpointURL(*flags.EndpointURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateSnapshotInterval(*flags.SnapshotInterval)
|
||||
if err != nil {
|
||||
if err := ValidateSnapshotInterval(*flags.SnapshotInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
|
||||
return errAdminPassExcludeAdminPassFile
|
||||
return ErrAdminPassExcludeAdminPassFile
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -104,27 +180,24 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) {
|
||||
if *flags.NoAnalytics {
|
||||
log.Warn().Msg("the --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect")
|
||||
}
|
||||
|
||||
if *flags.SSL {
|
||||
log.Warn().Msg("SSL is enabled by default and there is no need for the --ssl flag, it has been kept to allow migration of instances running a previous version of Portainer with this flag enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
func ValidateEndpointURL(endpointURL string) error {
|
||||
if endpointURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
|
||||
return errInvalidEndpointProtocol
|
||||
return ErrInvalidEndpointProtocol
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
|
||||
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
||||
socketPath = strings.TrimPrefix(socketPath, "npipe://")
|
||||
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errSocketOrNamedPipeNotFound
|
||||
return ErrSocketOrNamedPipeNotFound
|
||||
}
|
||||
|
||||
return err
|
||||
@@ -134,14 +207,13 @@ func validateEndpointURL(endpointURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnapshotInterval(snapshotInterval string) error {
|
||||
func ValidateSnapshotInterval(snapshotInterval string) error {
|
||||
if snapshotInterval == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := time.ParseDuration(snapshotInterval)
|
||||
if err != nil {
|
||||
return errInvalidSnapshotInterval
|
||||
if _, err := time.ParseDuration(snapshotInterval); err != nil {
|
||||
return ErrInvalidSnapshotInterval
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
263
api/cli/cli_test.go
Normal file
263
api/cli/cli_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
zerolog "github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOptionParser(t *testing.T) {
|
||||
p := Service{}
|
||||
require.NotNil(t, p)
|
||||
|
||||
a := os.Args
|
||||
defer func() { os.Args = a }()
|
||||
|
||||
os.Args = []string{"portainer", "--edge-compute"}
|
||||
|
||||
opts, err := p.ParseFlags("2.34.5")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.False(t, *opts.HTTPDisabled)
|
||||
require.True(t, *opts.EnableEdgeComputeFeatures)
|
||||
}
|
||||
|
||||
func TestParseKubectlShellImageFlag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
envVars map[string]string
|
||||
expectedKubectlShellImageSet bool
|
||||
expectedKubectlShellFlag string
|
||||
}{
|
||||
{
|
||||
name: "no flag, no env var",
|
||||
expectedKubectlShellImageSet: false,
|
||||
expectedKubectlShellFlag: portainer.DefaultKubectlShellImage,
|
||||
},
|
||||
{
|
||||
name: "explicit flag",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
{
|
||||
name: "env var",
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v3",
|
||||
},
|
||||
{
|
||||
name: "both env var and flag set",
|
||||
args: []string{"portainer", "--kubectl-shell-image=myimage:v2"},
|
||||
envVars: map[string]string{portainer.KubectlShellImageEnvVar: "myimage:v3"},
|
||||
expectedKubectlShellImageSet: true,
|
||||
expectedKubectlShellFlag: "myimage:v2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.args == nil {
|
||||
tc.args = []string{"portainer"}
|
||||
}
|
||||
setOsArgs(t, tc.args)
|
||||
|
||||
for k, v := range tc.envVars {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
flags, err := Service{}.ParseFlags("test-version")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImageSet, flags.KubectlShellImageSet)
|
||||
require.Equal(t, tc.expectedKubectlShellFlag, *flags.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTLSFlags(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectedTLSFlag bool
|
||||
expectedTLSCertFlag string
|
||||
expectedTLSKeyFlag string
|
||||
expectedLogMessages []string
|
||||
}{
|
||||
{
|
||||
name: "no flags",
|
||||
expectedTLSFlag: false,
|
||||
expectedTLSCertFlag: "",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "only ssl flag",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "only tls flag",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: defaultTLSCertPath,
|
||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||
},
|
||||
{
|
||||
name: "partial ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||
expectedTLSKeyFlag: "",
|
||||
},
|
||||
{
|
||||
name: "partial tls flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: defaultTLSKeyPath,
|
||||
},
|
||||
{
|
||||
name: "partial tls and ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "partial tls and ssl flags 2",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "ssl-cert-flag-value",
|
||||
expectedTLSKeyFlag: "ssl-key-flag-value",
|
||||
expectedLogMessages: []string{
|
||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tls flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--tlskey=tls-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||
},
|
||||
{
|
||||
name: "tls and ssl flags",
|
||||
args: []string{
|
||||
"portainer",
|
||||
"--tlsverify",
|
||||
"--tlscert=tls-cert-flag-value",
|
||||
"--tlskey=tls-key-flag-value",
|
||||
"--ssl",
|
||||
"--sslcert=ssl-cert-flag-value",
|
||||
"--sslkey=ssl-key-flag-value",
|
||||
},
|
||||
expectedTLSFlag: true,
|
||||
expectedTLSCertFlag: "tls-cert-flag-value",
|
||||
expectedTLSKeyFlag: "tls-key-flag-value",
|
||||
expectedLogMessages: []string{
|
||||
"the \\\"ssl\\\" flag is deprecated. use \\\"tlsverify\\\" instead.",
|
||||
"the \\\"sslcert\\\" flag is deprecated. use \\\"tlscert\\\" instead.",
|
||||
"the \\\"sslkey\\\" flag is deprecated. use \\\"tlskey\\\" instead.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var logOutput strings.Builder
|
||||
setupLogOutput(t, &logOutput)
|
||||
|
||||
if tc.args == nil {
|
||||
tc.args = []string{"portainer"}
|
||||
}
|
||||
setOsArgs(t, tc.args)
|
||||
|
||||
s := Service{}
|
||||
flags, err := s.ParseFlags("test-version")
|
||||
if err != nil {
|
||||
t.Fatalf("error parsing flags: %v", err)
|
||||
}
|
||||
|
||||
if flags.TLS == nil {
|
||||
t.Fatal("TLS flag was nil")
|
||||
}
|
||||
|
||||
require.Equal(t, tc.expectedTLSFlag, *flags.TLS, "tlsverify flag didn't match")
|
||||
require.Equal(t, tc.expectedTLSCertFlag, *flags.TLSCert, "tlscert flag didn't match")
|
||||
require.Equal(t, tc.expectedTLSKeyFlag, *flags.TLSKey, "tlskey flag didn't match")
|
||||
|
||||
for _, expectedLogMessage := range tc.expectedLogMessages {
|
||||
require.Contains(t, logOutput.String(), expectedLogMessage, "Log didn't contain expected message")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setOsArgs(t *testing.T, args []string) {
|
||||
t.Helper()
|
||||
previousArgs := os.Args
|
||||
os.Args = args
|
||||
t.Cleanup(func() {
|
||||
os.Args = previousArgs
|
||||
})
|
||||
}
|
||||
|
||||
func setupLogOutput(t *testing.T, w io.Writer) {
|
||||
t.Helper()
|
||||
|
||||
oldLogger := zerolog.Logger
|
||||
zerolog.Logger = zerolog.Output(w)
|
||||
t.Cleanup(func() {
|
||||
zerolog.Logger = oldLogger
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,5 @@ func Confirm(message string) (bool, error) {
|
||||
}
|
||||
|
||||
answer = strings.ReplaceAll(answer, "\n", "")
|
||||
answer = strings.ToLower(answer)
|
||||
|
||||
return answer == "y" || answer == "yes", nil
|
||||
return strings.EqualFold(answer, "y") || strings.EqualFold(answer, "yes"), nil
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultHTTPSBindAddress = ":9443"
|
||||
defaultTunnelServerAddress = "0.0.0.0"
|
||||
defaultTunnelServerPort = "8000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultHTTPDisabled = "false"
|
||||
defaultHTTPEnabled = "false"
|
||||
defaultSSL = "false"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultBaseURL = "/"
|
||||
defaultSecretKeyName = "portainer"
|
||||
defaultPullLimitCheckDisabled = "false"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
)
|
||||
|
||||
type pairList []portainer.Pair
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
type pairListBool []portainer.Pair
|
||||
|
||||
// Set implementation for a list of portainer.Pair
|
||||
func (l *pairListBool) Set(value string) error {
|
||||
p := new(portainer.Pair)
|
||||
|
||||
// default to true. example setting=true is equivalent to setting
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
p.Name = parts[0]
|
||||
p.Value = "true"
|
||||
} else {
|
||||
p.Name = parts[0]
|
||||
p.Value = parts[1]
|
||||
}
|
||||
|
||||
*l = append(*l, *p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// String implementation for a list of pair
|
||||
func (l *pairListBool) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsCumulative implementation for a list of pair
|
||||
func (l *pairListBool) IsCumulative() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
|
||||
target = new([]portainer.Pair)
|
||||
s.SetValue((*pairListBool)(target))
|
||||
return
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdlog "log"
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
func configureLogger() {
|
||||
zerolog.ErrorStackFieldName = "stack_trace"
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
|
||||
stdlog.SetFlags(0)
|
||||
stdlog.SetOutput(log.Logger)
|
||||
|
||||
log.Logger = log.Logger.With().Caller().Stack().Logger()
|
||||
}
|
||||
|
||||
func setLoggingLevel(level string) {
|
||||
switch level {
|
||||
case "ERROR":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
case "WARN":
|
||||
zerolog.SetGlobalLevel(zerolog.WarnLevel)
|
||||
case "INFO":
|
||||
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||
case "DEBUG":
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func setLoggingMode(mode string) {
|
||||
switch mode {
|
||||
case "PRETTY":
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
Out: os.Stderr,
|
||||
TimeFormat: "2006/01/02 03:04PM",
|
||||
FormatMessage: formatMessage,
|
||||
})
|
||||
case "JSON":
|
||||
log.Logger = log.Output(os.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func formatMessage(i interface{}) string {
|
||||
if i == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s |", i)
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
nethttp "net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
@@ -19,18 +21,17 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/datastore/postinit"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/exec"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/git"
|
||||
"github.com/portainer/portainer/api/hostmanagement/openamt"
|
||||
"github.com/portainer/portainer/api/http"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security/setuptoken"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
@@ -40,28 +41,40 @@ import (
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
"github.com/portainer/portainer/api/pendingactions/handlers"
|
||||
"github.com/portainer/portainer/api/platform"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/pkg/build"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
"github.com/portainer/portainer/pkg/libhelm"
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/portainer/portainer/pkg/libstack/compose"
|
||||
libswarm "github.com/portainer/portainer/pkg/libstack/swarm"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
gogitclient "github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
gogitraw "github.com/go-git/go-git/v5/plumbing/transport/git"
|
||||
gogithttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
gogitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
var cliService portainer.CLIService = &cli.Service{}
|
||||
cliService := cli.Service{}
|
||||
|
||||
flags, err := cliService.ParseFlags(portainer.APIVersion)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed parsing flags")
|
||||
}
|
||||
|
||||
err = cliService.ValidateFlags(flags)
|
||||
if err != nil {
|
||||
if err := cliService.ValidateFlags(flags); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed validating flags")
|
||||
}
|
||||
|
||||
@@ -78,7 +91,7 @@ func initFileService(dataStorePath string) portainer.FileService {
|
||||
}
|
||||
|
||||
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
|
||||
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
|
||||
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed creating database connection")
|
||||
}
|
||||
@@ -91,15 +104,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
log.Fatal().Msg("failed creating database connection: expecting a boltdb database type but a different one was received")
|
||||
}
|
||||
|
||||
store := datastore.NewStore(*flags.Data, fileService, connection)
|
||||
store := datastore.NewStore(flags, fileService, connection)
|
||||
|
||||
isNew, err := store.Open()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed opening store")
|
||||
}
|
||||
|
||||
if *flags.Rollback {
|
||||
err := store.Rollback(false)
|
||||
if err != nil {
|
||||
if err := store.Rollback(false); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed rolling back")
|
||||
}
|
||||
|
||||
@@ -108,18 +121,17 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
}
|
||||
|
||||
// Init sets some defaults - it's basically a migration
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
if err := store.Init(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing data store")
|
||||
}
|
||||
|
||||
if isNew {
|
||||
instanceId, err := uuid.NewV4()
|
||||
instanceId, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
|
||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
|
||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{Flags: flags})
|
||||
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
||||
|
||||
// from MigrateData
|
||||
@@ -129,28 +141,27 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
InstanceID: instanceId.String(),
|
||||
MigratorCount: migratorCount,
|
||||
}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
err = updateSettingsFromFlags(store, flags)
|
||||
if err != nil {
|
||||
if err := store.VersionService.UpdateVersion(&v); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to update version")
|
||||
}
|
||||
|
||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||
}
|
||||
} else {
|
||||
err = store.MigrateData()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed migration")
|
||||
}
|
||||
} else if err := store.MigrateData(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed migration")
|
||||
}
|
||||
|
||||
err = updateSettingsFromFlags(store, flags)
|
||||
if err != nil {
|
||||
if err := updateSettingsFromFlags(store, flags); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed updating settings from flags")
|
||||
}
|
||||
|
||||
// this is for the db restore functionality - needs more tests.
|
||||
go func() {
|
||||
<-shutdownCtx.Done()
|
||||
defer connection.Close()
|
||||
|
||||
defer logs.CloseAndLogErr(connection)
|
||||
}()
|
||||
|
||||
return store
|
||||
@@ -166,32 +177,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed creating compose manager")
|
||||
}
|
||||
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(
|
||||
assetsPath string,
|
||||
configPath string,
|
||||
signatureService portainer.DigitalSignatureService,
|
||||
fileService portainer.FileService,
|
||||
reverseTunnelService portainer.ReverseTunnelService,
|
||||
dataStore dataservices.DataStore,
|
||||
) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) {
|
||||
return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath})
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
}
|
||||
|
||||
func initAPIKeyService(datastore dataservices.DataStore) apikey.APIKeyService {
|
||||
@@ -203,36 +190,16 @@ func initJWTService(userSessionTimeout string, dataStore dataservices.DataStore)
|
||||
userSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||
}
|
||||
|
||||
jwtService, err := jwt.NewService(userSessionTimeout, dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return jwtService, nil
|
||||
return jwt.NewService(userSessionTimeout, dataStore)
|
||||
}
|
||||
|
||||
func initDigitalSignatureService() portainer.DigitalSignatureService {
|
||||
return crypto.NewECDSAService(os.Getenv("AGENT_SECRET"))
|
||||
}
|
||||
|
||||
func initCryptoService() portainer.CryptoService {
|
||||
return &crypto.Service{}
|
||||
}
|
||||
|
||||
func initLDAPService() portainer.LDAPService {
|
||||
return &ldap.Service{}
|
||||
}
|
||||
|
||||
func initOAuthService() portainer.OAuthService {
|
||||
return oauth.NewService()
|
||||
}
|
||||
|
||||
func initGitService(ctx context.Context) portainer.GitService {
|
||||
return git.NewService(ctx)
|
||||
}
|
||||
|
||||
func initSSLService(addr, certPath, keyPath string, fileService portainer.FileService, dataStore dataservices.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) {
|
||||
slices := strings.Split(addr, ":")
|
||||
|
||||
host := slices[0]
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
@@ -240,34 +207,24 @@ func initSSLService(addr, certPath, keyPath string, fileService portainer.FileSe
|
||||
|
||||
sslService := ssl.NewService(fileService, dataStore, shutdownTrigger)
|
||||
|
||||
err := sslService.Init(host, certPath, keyPath)
|
||||
if err != nil {
|
||||
if err := sslService.Init(host, certPath, keyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sslService, nil
|
||||
}
|
||||
|
||||
func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *dockerclient.ClientFactory {
|
||||
return dockerclient.NewClientFactory(signatureService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*kubecli.ClientFactory, error) {
|
||||
return kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, addrHTTPS, userSessionTimeout)
|
||||
}
|
||||
|
||||
func initSnapshotService(
|
||||
snapshotIntervalFromFlag string,
|
||||
dataStore dataservices.DataStore,
|
||||
dockerClientFactory *dockerclient.ClientFactory,
|
||||
kubernetesClientFactory *kubecli.ClientFactory,
|
||||
shutdownCtx context.Context,
|
||||
pendingActionsService *pendingactions.PendingActionsService,
|
||||
) (portainer.SnapshotService, error) {
|
||||
dockerSnapshotter := docker.NewSnapshotter(dockerClientFactory)
|
||||
kubernetesSnapshotter := kubernetes.NewSnapshotter(kubernetesClientFactory)
|
||||
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, shutdownCtx, pendingActionsService)
|
||||
snapshotService, err := snapshot.NewService(snapshotIntervalFromFlag, dataStore, dockerSnapshotter, kubernetesSnapshotter, pendingActionsService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -275,6 +232,32 @@ func initSnapshotService(
|
||||
return snapshotService, nil
|
||||
}
|
||||
|
||||
func resolveSetupToken(tx dataservices.DataStoreTx, providedToken string) (string, error) {
|
||||
admins, err := tx.User().UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(admins) > 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if providedToken != "" {
|
||||
log.Info().Msg("using custom setup token; admin initialization and backup restore require this token in the X-Setup-Token header")
|
||||
return providedToken, nil
|
||||
}
|
||||
|
||||
token, err := setuptoken.Generate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("setup_token", token).
|
||||
Msg("no administrator account configured; admin initialization and backup restore require this setup token in the X-Setup-Token header. Start with --no-setup-token to disable.")
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func initStatus(instanceID string) *portainer.Status {
|
||||
return &portainer.Status{
|
||||
Version: portainer.APIVersion,
|
||||
@@ -288,34 +271,25 @@ func updateSettingsFromFlags(dataStore dataservices.DataStore, flags *portainer.
|
||||
return err
|
||||
}
|
||||
|
||||
if *flags.SnapshotInterval != "" {
|
||||
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||
}
|
||||
settings.SnapshotInterval = cmp.Or(*flags.SnapshotInterval, settings.SnapshotInterval)
|
||||
settings.LogoURL = cmp.Or(*flags.Logo, settings.LogoURL)
|
||||
settings.EnableEdgeComputeFeatures = cmp.Or(*flags.EnableEdgeComputeFeatures, settings.EnableEdgeComputeFeatures)
|
||||
settings.TemplatesURL = cmp.Or(*flags.Templates, settings.TemplatesURL)
|
||||
|
||||
if *flags.Logo != "" {
|
||||
settings.LogoURL = *flags.Logo
|
||||
}
|
||||
|
||||
if *flags.EnableEdgeComputeFeatures {
|
||||
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||
}
|
||||
|
||||
if *flags.Templates != "" {
|
||||
settings.TemplatesURL = *flags.Templates
|
||||
if flags.KubectlShellImageSet {
|
||||
settings.KubectlShellImage = *flags.KubectlShellImage
|
||||
}
|
||||
|
||||
if *flags.Labels != nil {
|
||||
settings.BlackListedLabels = *flags.Labels
|
||||
}
|
||||
|
||||
settings.AgentSecret = ""
|
||||
if agentKey, ok := os.LookupEnv("AGENT_SECRET"); ok {
|
||||
settings.AgentSecret = agentKey
|
||||
} else {
|
||||
settings.AgentSecret = ""
|
||||
}
|
||||
|
||||
err = dataStore.Settings().UpdateSettings(settings)
|
||||
if err != nil {
|
||||
if err := dataStore.Settings().UpdateSettings(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -338,6 +312,7 @@ func loadAndParseKeyPair(fileService portainer.FileService, signatureService por
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return signatureService.ParseKeyPair(private, public)
|
||||
}
|
||||
|
||||
@@ -346,7 +321,9 @@ func generateAndStoreKeyPair(fileService portainer.FileService, signatureService
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateHeader, publicHeader := signatureService.PEMHeaders()
|
||||
|
||||
return fileService.StoreKeyPair(private, public, privateHeader, publicHeader)
|
||||
}
|
||||
|
||||
@@ -359,11 +336,23 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
||||
if existingKeyPair {
|
||||
return loadAndParseKeyPair(fileService, signatureService)
|
||||
}
|
||||
|
||||
return generateAndStoreKeyPair(fileService, signatureService)
|
||||
}
|
||||
|
||||
// dbSecretPath build the path to the file that contains the db encryption
|
||||
// secret. Normally in Docker this is built from the static path inside
|
||||
// /run/secrets for example: /run/secrets/<keyFilenameFlag> but for ease of
|
||||
// use outside Docker it also accepts an absolute path
|
||||
func dbSecretPath(keyFilenameFlag string) string {
|
||||
if path.IsAbs(keyFilenameFlag) {
|
||||
return keyFilenameFlag
|
||||
}
|
||||
return path.Join("/run/secrets", keyFilenameFlag)
|
||||
}
|
||||
|
||||
func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||
content, err := os.ReadFile(path.Join("/run/secrets", keyfilename))
|
||||
content, err := os.ReadFile(keyfilename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Info().Str("filename", keyfilename).Msg("encryption key file not present")
|
||||
@@ -375,19 +364,34 @@ func loadEncryptionSecretKey(keyfilename string) []byte {
|
||||
}
|
||||
|
||||
// return a 32 byte hash of the secret (required for AES)
|
||||
// fips compliant version of this is not implemented in -ce
|
||||
hash := sha256.Sum256(content)
|
||||
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
|
||||
func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server {
|
||||
if flags.FeatureFlags != nil {
|
||||
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
|
||||
}
|
||||
|
||||
trustedOrigins := []string{}
|
||||
if *flags.TrustedOrigins != "" {
|
||||
// validate if the trusted origins are valid urls
|
||||
for origin := range strings.SplitSeq(*flags.TrustedOrigins, ",") {
|
||||
if !validate.IsTrustedOrigin(origin) {
|
||||
log.Fatal().Str("trusted_origin", origin).Msg("invalid trusted origin: must be scheme://host or scheme://host:port (e.g. https://example.com)")
|
||||
}
|
||||
|
||||
trustedOrigins = append(trustedOrigins, origin)
|
||||
}
|
||||
}
|
||||
|
||||
// -ce can not ever be run in FIPS mode
|
||||
fips.InitFIPS(false)
|
||||
|
||||
fileService := initFileService(*flags.Data)
|
||||
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
|
||||
encryptionKey := loadEncryptionSecretKey(dbSecretPath(*flags.SecretKeyName))
|
||||
if encryptionKey == nil {
|
||||
log.Info().Msg("proceeding without encryption key")
|
||||
}
|
||||
@@ -403,6 +407,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
|
||||
}
|
||||
|
||||
if err := ssrf.Configure(dataStore.AllowList()); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing ssrf service")
|
||||
}
|
||||
|
||||
if !ssrf.WrapDefaultTransport() {
|
||||
log.Fatal().Msg("failed to wrap default HTTP transport with SSRF protection")
|
||||
}
|
||||
|
||||
gogithttp.DefaultClient = gogithttp.NewClient(&nethttp.Client{Transport: nethttp.DefaultTransport})
|
||||
gogitclient.InstallProtocol("git", git.NewSSRFGitTransport(gogitraw.DefaultClient))
|
||||
gogitclient.InstallProtocol("ssh", git.NewSSRFGitTransport(gogitssh.DefaultClient))
|
||||
gogitclient.InstallProtocol("file", nil)
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed getting instance id")
|
||||
@@ -420,21 +437,19 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failed initializing JWT service")
|
||||
}
|
||||
|
||||
ldapService := initLDAPService()
|
||||
ldapService := ldap.Service{}
|
||||
|
||||
oauthService := initOAuthService()
|
||||
oauthService := oauth.NewService()
|
||||
|
||||
gitService := initGitService(shutdownCtx)
|
||||
gitService := git.NewService(shutdownCtx)
|
||||
|
||||
openAMTService := openamt.NewService()
|
||||
cryptoService := crypto.Service{}
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
signatureService := initDigitalSignatureService()
|
||||
|
||||
edgeStacksService := edgestacks.NewService(dataStore)
|
||||
|
||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger)
|
||||
sslService, err := initSSLService(*flags.AddrHTTPS, *flags.TLSCert, *flags.TLSKey, fileService, dataStore, shutdownTrigger)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
@@ -444,77 +459,63 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failed to get SSL settings")
|
||||
}
|
||||
|
||||
err = initKeyPair(fileService, digitalSignatureService)
|
||||
if err != nil {
|
||||
if err := initKeyPair(fileService, signatureService); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing key pair")
|
||||
}
|
||||
|
||||
reverseTunnelService := chisel.NewService(dataStore, shutdownCtx, fileService)
|
||||
|
||||
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
|
||||
kubernetesClientFactory, err := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout)
|
||||
dockerClientFactory := dockerclient.NewClientFactory(signatureService, reverseTunnelService)
|
||||
|
||||
kubernetesClientFactory, err := kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing Kubernetes Client Factory service")
|
||||
}
|
||||
|
||||
authorizationService := authorization.NewService(dataStore)
|
||||
authorizationService.K8sClientFactory = kubernetesClientFactory
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
||||
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
|
||||
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
|
||||
proxyManager := proxy.NewManager(kubernetesClientFactory)
|
||||
|
||||
reverseTunnelService.ProxyManager = proxyManager
|
||||
|
||||
dockerConfigPath := fileService.GetDockerConfigPath()
|
||||
composeDeployer := compose.NewComposeDeployer()
|
||||
|
||||
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
|
||||
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
|
||||
swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
||||
pendingActionsService.RegisterHandler(actions.DeletePortainerK8sRegistrySecrets, handlers.NewHandlerDeleteRegistrySecrets(authorizationService, dataStore, kubernetesClientFactory))
|
||||
pendingActionsService.RegisterHandler(actions.PostInitMigrateEnvironment, handlers.NewHandlerPostInitMigrateEnvironment(authorizationService, dataStore, kubernetesClientFactory, dockerClientFactory, *flags.Assets, kubernetesDeployer))
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing compose deployer")
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
|
||||
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
|
||||
snapshotService.Start(shutdownCtx)
|
||||
|
||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService, dataStore)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||
}
|
||||
proxyManager.NewProxyFactory(dataStore, signatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService, jwtService)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
}
|
||||
|
||||
err = edge.LoadEdgeJobs(dataStore, reverseTunnelService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed loading edge jobs from database")
|
||||
}
|
||||
helmPackageManager := libhelm.NewHelmPackageManager()
|
||||
|
||||
applicationStatus := initStatus(instanceID)
|
||||
|
||||
demoService := demo.NewService()
|
||||
if *flags.DemoEnvironment {
|
||||
err := demoService.Init(dataStore, cryptoService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing demo environment")
|
||||
}
|
||||
}
|
||||
|
||||
// channel to control when the admin user is created
|
||||
adminCreationDone := make(chan struct{}, 1)
|
||||
|
||||
go endpointutils.InitEndpoint(shutdownCtx, adminCreationDone, flags, dataStore, snapshotService)
|
||||
|
||||
adminPasswordHash := ""
|
||||
|
||||
if *flags.AdminPasswordFile != "" {
|
||||
content, err := fileService.GetFileContent(*flags.AdminPasswordFile, "")
|
||||
if err != nil {
|
||||
@@ -537,14 +538,14 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
if len(users) == 0 {
|
||||
log.Info().Msg("created admin user with the given password.")
|
||||
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: adminPasswordHash,
|
||||
}
|
||||
|
||||
err := dataStore.User().Create(user)
|
||||
if err != nil {
|
||||
if err := dataStore.User().Create(user); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed creating admin user")
|
||||
}
|
||||
|
||||
@@ -555,21 +556,43 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
}
|
||||
|
||||
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService)
|
||||
if err != nil {
|
||||
setupToken := ""
|
||||
if adminPasswordHash == "" && !*flags.NoSetupToken {
|
||||
if err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
var txErr error
|
||||
setupToken, txErr = resolveSetupToken(tx, *flags.SetupToken)
|
||||
return txErr
|
||||
}); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing setup token")
|
||||
}
|
||||
}
|
||||
|
||||
if err := reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed starting tunnel server")
|
||||
}
|
||||
|
||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||
stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer, dockerClientFactory, dataStore)
|
||||
deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
|
||||
if err := deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to start stack scheduler")
|
||||
}
|
||||
|
||||
sslDBSettings, err := dataStore.SSLSettings().Settings()
|
||||
if err != nil {
|
||||
log.Fatal().Msg("failed to fetch SSL settings from DB")
|
||||
}
|
||||
|
||||
upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory)
|
||||
platformService := platform.NewService(dataStore)
|
||||
|
||||
upgradeService, err := upgrade.NewService(
|
||||
*flags.Assets,
|
||||
kubernetesClientFactory,
|
||||
dockerClientFactory,
|
||||
composeStackManager,
|
||||
dataStore,
|
||||
fileService,
|
||||
stackDeployer,
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing upgrade service")
|
||||
}
|
||||
@@ -578,21 +601,31 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
// but some more complex migrations require access to a kubernetes or docker
|
||||
// client. Therefore we run a separate migration process just before
|
||||
// starting the server.
|
||||
postInitMigrator := datastore.NewPostInitMigrator(
|
||||
postInitMigrator := postinit.NewPostInitMigrator(
|
||||
kubernetesClientFactory,
|
||||
dockerClientFactory,
|
||||
dataStore,
|
||||
*flags.Assets,
|
||||
kubernetesDeployer,
|
||||
)
|
||||
if err := postInitMigrator.PostInitMigrate(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failure during post init migrations")
|
||||
}
|
||||
|
||||
if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return recoverStaleDeployingStacks(tx)
|
||||
}); err != nil {
|
||||
log.Info().Err(err).
|
||||
Msg("Error recovering stale deploying stacks")
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
BindAddressHTTPS: *flags.AddrHTTPS,
|
||||
CSP: *flags.CSP,
|
||||
HTTPEnabled: sslDBSettings.HTTPEnabled,
|
||||
AssetsPath: *flags.Assets,
|
||||
DataStore: dataStore,
|
||||
@@ -608,48 +641,88 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
LDAPService: ldapService,
|
||||
OAuthService: oauthService,
|
||||
GitService: gitService,
|
||||
OpenAMTService: openAMTService,
|
||||
ProxyManager: proxyManager,
|
||||
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
KubeClusterAccessService: kubeClusterAccessService,
|
||||
SignatureService: digitalSignatureService,
|
||||
SignatureService: signatureService,
|
||||
SnapshotService: snapshotService,
|
||||
SSLService: sslService,
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
Scheduler: scheduler,
|
||||
ShutdownCtx: shutdownCtx,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
StackDeployer: stackDeployer,
|
||||
DemoService: demoService,
|
||||
UpgradeService: upgradeService,
|
||||
AdminCreationDone: adminCreationDone,
|
||||
PendingActionsService: pendingActionsService,
|
||||
PlatformService: platformService,
|
||||
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
|
||||
TrustedOrigins: trustedOrigins,
|
||||
SetupToken: setupToken,
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
configureLogger()
|
||||
setLoggingMode("PRETTY")
|
||||
logs.ConfigureLogger()
|
||||
logs.SetLoggingMode("PRETTY")
|
||||
|
||||
flags := initCLI()
|
||||
|
||||
setLoggingLevel(*flags.LogLevel)
|
||||
setLoggingMode(*flags.LogMode)
|
||||
logs.SetLoggingLevel(*flags.LogLevel)
|
||||
logs.SetLoggingMode(*flags.LogMode)
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
shutdownCtx, shutdownTrigger := context.WithCancel(context.Background())
|
||||
server := buildServer(flags, shutdownCtx, shutdownTrigger)
|
||||
|
||||
log.Info().
|
||||
Str("version", portainer.APIVersion).
|
||||
Str("build_number", build.BuildNumber).
|
||||
Str("image_tag", build.ImageTag).
|
||||
Str("nodejs_version", build.NodejsVersion).
|
||||
Str("yarn_version", build.YarnVersion).
|
||||
Str("pnpm_version", build.PnpmVersion).
|
||||
Str("webpack_version", build.WebpackVersion).
|
||||
Str("go_version", build.GoVersion).
|
||||
Msg("starting Portainer")
|
||||
|
||||
err := server.Start()
|
||||
err := server.Start(shutdownCtx)
|
||||
|
||||
log.Info().Err(err).Msg("HTTP server exited")
|
||||
}
|
||||
}
|
||||
|
||||
// recoverStaleDeployingStacks resets any stack that was left in the Deploying state
|
||||
// (e.g. because the server was restarted mid-deployment) to the Error state so the
|
||||
// user can retry.
|
||||
func recoverStaleDeployingStacks(tx dataservices.DataStoreTx) error {
|
||||
stacks, err := tx.Stack().ReadAll(func(s portainer.Stack) bool {
|
||||
return s.Status == portainer.StackStatusDeploying
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
stack.Status = portainer.StackStatusError
|
||||
stack.DeploymentStatus = append(stack.DeploymentStatus, portainer.StackDeploymentStatus{
|
||||
Status: portainer.StackStatusError,
|
||||
Time: time.Now().Unix(),
|
||||
Message: "Deployment interrupted by server restart",
|
||||
})
|
||||
|
||||
if err := tx.Stack().Update(stack.ID, &stack); err != nil {
|
||||
log.Warn().Err(err).
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Unable to recover stale deploying stack")
|
||||
continue
|
||||
}
|
||||
log.Debug().
|
||||
Int("stack_id", int(stack.ID)).
|
||||
Str("stack_name", stack.Name).
|
||||
Str("context", "RecoverStaleDeployingStacks").
|
||||
Msg("Recovered stale deploying stack to error state")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
159
api/cmd/portainer/main_test.go
Normal file
159
api/cmd/portainer/main_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_resolveSetupToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("admin already exists — returns empty token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("no admin — generates a 64-char hex token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, token, 64)
|
||||
|
||||
token2, err := resolveSetupToken(store, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, token, token2)
|
||||
})
|
||||
|
||||
t.Run("no admin — uses provided token", func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "mysecrettoken", token)
|
||||
})
|
||||
|
||||
t.Run("admin already exists — ignores provided token", func(t *testing.T) {
|
||||
admin := portainer.User{Role: portainer.AdministratorRole}
|
||||
store := testhelpers.NewDatastore(testhelpers.WithUsers([]portainer.User{admin}))
|
||||
token, err := resolveSetupToken(store, "mysecrettoken")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
const secretFileName = "secret.txt"
|
||||
|
||||
func createPasswordFile(t *testing.T, secretPath, password string) string {
|
||||
err := os.WriteFile(secretPath, []byte(password), 0o600)
|
||||
require.NoError(t, err)
|
||||
return secretPath
|
||||
}
|
||||
|
||||
func TestLoadEncryptionSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
tempDir := t.TempDir()
|
||||
secretPath := filesystem.JoinPaths(tempDir, secretFileName)
|
||||
|
||||
// first pointing to file that does not exist, gives nil hash (no encryption)
|
||||
encryptionKey := loadEncryptionSecretKey(secretPath)
|
||||
require.Nil(t, encryptionKey)
|
||||
|
||||
// point to a directory instead of a file
|
||||
encryptionKey = loadEncryptionSecretKey(tempDir)
|
||||
require.Nil(t, encryptionKey)
|
||||
|
||||
password := "portainer@1234"
|
||||
createPasswordFile(t, secretPath, password)
|
||||
|
||||
encryptionKey = loadEncryptionSecretKey(secretPath)
|
||||
require.NotNil(t, encryptionKey)
|
||||
// should be 32 bytes for aes256 encryption
|
||||
require.Len(t, encryptionKey, 32)
|
||||
}
|
||||
|
||||
func TestUpdateSettingsFromFlags_KubectlShellImage(t *testing.T) {
|
||||
const existingImage = "existing-image:v1"
|
||||
const newImage = "new-image:v2"
|
||||
|
||||
emptyString := ""
|
||||
falseBool := false
|
||||
var emptyLabels []portainer.Pair
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
imageSet bool
|
||||
flagImage string
|
||||
expectedKubectlShellImage string
|
||||
}{
|
||||
{
|
||||
name: "flag not set — DB image unchanged",
|
||||
imageSet: false,
|
||||
flagImage: portainer.DefaultKubectlShellImage,
|
||||
expectedKubectlShellImage: existingImage,
|
||||
},
|
||||
{
|
||||
name: "flag set — DB image updated",
|
||||
imageSet: true,
|
||||
flagImage: newImage,
|
||||
expectedKubectlShellImage: newImage,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := testhelpers.NewDatastore(
|
||||
testhelpers.WithSettingsService(&portainer.Settings{
|
||||
KubectlShellImage: existingImage,
|
||||
}),
|
||||
testhelpers.WithSSLSettingsService(&portainer.SSLSettings{}),
|
||||
)
|
||||
|
||||
flags := &portainer.CLIFlags{
|
||||
SnapshotInterval: &emptyString,
|
||||
Logo: &emptyString,
|
||||
EnableEdgeComputeFeatures: &falseBool,
|
||||
Templates: &emptyString,
|
||||
Labels: &emptyLabels,
|
||||
HTTPDisabled: &falseBool,
|
||||
HTTPEnabled: &falseBool,
|
||||
}
|
||||
flags.KubectlShellImage = &tc.flagImage
|
||||
flags.KubectlShellImageSet = tc.imageSet
|
||||
|
||||
err := updateSettingsFromFlags(store, flags)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedKubectlShellImage, settings.KubectlShellImage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBSecretPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
keyFilenameFlag string
|
||||
expected string
|
||||
}{
|
||||
{keyFilenameFlag: "secret.txt", expected: "/run/secrets/secret.txt"},
|
||||
{keyFilenameFlag: "/tmp/secret.txt", expected: "/tmp/secret.txt"},
|
||||
{keyFilenameFlag: "/run/secrets/secret.txt", expected: "/run/secrets/secret.txt"},
|
||||
{keyFilenameFlag: "./secret.txt", expected: "/run/secrets/secret.txt"},
|
||||
{keyFilenameFlag: "../secret.txt", expected: "/run/secret.txt"},
|
||||
{keyFilenameFlag: "foo/bar/secret.txt", expected: "/run/secrets/foo/bar/secret.txt"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.expected, dbSecretPath(test.keyFilenameFlag))
|
||||
}
|
||||
}
|
||||
148
api/concurrent/concurrent.go
Normal file
148
api/concurrent/concurrent.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package concurrent provides utilities for running multiple functions concurrently in Go.
|
||||
// For example, many kubernetes calls can take a while to fulfill. Oftentimes in Portainer
|
||||
// we need to get a list of objects from multiple kubernetes REST APIs. We can often call these
|
||||
// apis concurrently to speed up the response time.
|
||||
// This package provides a clean way to do just that.
|
||||
//
|
||||
// Examples:
|
||||
// The ConfigMaps and Secrets function converted using concurrent.Run.
|
||||
/*
|
||||
|
||||
// GetConfigMapsAndSecrets gets all the ConfigMaps AND all the Secrets for a
|
||||
// given namespace in a k8s endpoint. The result is a list of both config maps
|
||||
// and secrets. The IsSecret boolean property indicates if a given struct is a
|
||||
// secret or configmap.
|
||||
func (kcl *KubeClient) GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error) {
|
||||
|
||||
// use closures to capture the current kube client and namespace by declaring wrapper functions
|
||||
// that match the interface signature for concurrent.Func
|
||||
|
||||
listConfigMaps := func(ctx context.Context) (any, error) {
|
||||
return kcl.cli.CoreV1().ConfigMaps(namespace).List(context.Background(), meta.ListOptions{})
|
||||
}
|
||||
|
||||
listSecrets := func(ctx context.Context) (any, error) {
|
||||
return kcl.cli.CoreV1().Secrets(namespace).List(context.Background(), meta.ListOptions{})
|
||||
}
|
||||
|
||||
// run the functions concurrently and wait for results. We can also pass in a context to cancel.
|
||||
// e.g. Deadline timer.
|
||||
results, err := concurrent.Run(context.TODO(), listConfigMaps, listSecrets)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var configMapList *core.ConfigMapList
|
||||
var secretList *core.SecretList
|
||||
for _, r := range results {
|
||||
switch v := r.Result.(type) {
|
||||
case *core.ConfigMapList:
|
||||
configMapList = v
|
||||
case *core.SecretList:
|
||||
secretList = v
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Applications
|
||||
var combined []models.K8sConfigMapOrSecret
|
||||
for _, m := range configMapList.Items {
|
||||
var cm models.K8sConfigMapOrSecret
|
||||
cm.UID = string(m.UID)
|
||||
cm.Name = m.Name
|
||||
cm.Namespace = m.Namespace
|
||||
cm.Annotations = m.Annotations
|
||||
cm.Data = m.Data
|
||||
cm.CreationDate = m.CreationTimestamp.Time.UTC().Format(time.RFC3339)
|
||||
combined = append(combined, cm)
|
||||
}
|
||||
|
||||
for _, s := range secretList.Items {
|
||||
var secret models.K8sConfigMapOrSecret
|
||||
secret.UID = string(s.UID)
|
||||
secret.Name = s.Name
|
||||
secret.Namespace = s.Namespace
|
||||
secret.Annotations = s.Annotations
|
||||
secret.Data = msbToMss(s.Data)
|
||||
secret.CreationDate = s.CreationTimestamp.Time.UTC().Format(time.RFC3339)
|
||||
secret.IsSecret = true
|
||||
secret.SecretType = string(s.Type)
|
||||
combined = append(combined, secret)
|
||||
}
|
||||
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
package concurrent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Result contains the result and any error returned from running a client task function
|
||||
type Result struct {
|
||||
Result any // the result of running the task function
|
||||
Err error // any error that occurred while running the task function
|
||||
}
|
||||
|
||||
// Func is a function returns a result or error
|
||||
type Func func(ctx context.Context) (any, error)
|
||||
|
||||
// Run runs a list of functions returns the results
|
||||
func Run(ctx context.Context, maxConcurrency int, tasks ...Func) ([]Result, error) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
resultsChan := make(chan Result, len(tasks))
|
||||
taskChan := make(chan Func, len(tasks))
|
||||
|
||||
localCtx, cancelCtx := context.WithCancel(ctx)
|
||||
defer cancelCtx()
|
||||
|
||||
runTask := func() {
|
||||
defer wg.Done()
|
||||
|
||||
for fn := range taskChan {
|
||||
result, err := fn(localCtx)
|
||||
resultsChan <- Result{Result: result, Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Set maxConcurrency to the number of tasks if zero or negative
|
||||
if maxConcurrency <= 0 {
|
||||
maxConcurrency = len(tasks)
|
||||
}
|
||||
|
||||
// Start worker goroutines
|
||||
for range maxConcurrency {
|
||||
wg.Add(1)
|
||||
go runTask()
|
||||
}
|
||||
|
||||
// Add tasks to the task channel
|
||||
for _, fn := range tasks {
|
||||
taskChan <- fn
|
||||
}
|
||||
|
||||
// Close the task channel to signal workers to stop when all tasks are done
|
||||
close(taskChan)
|
||||
|
||||
// Wait for all workers to complete
|
||||
wg.Wait()
|
||||
close(resultsChan)
|
||||
|
||||
// Collect the results and cancel on error
|
||||
results := make([]Result, 0, len(tasks))
|
||||
for r := range resultsChan {
|
||||
if r.Err != nil {
|
||||
cancelCtx()
|
||||
|
||||
return nil, r.Err
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
149
api/concurrent/concurrent_test.go
Normal file
149
api/concurrent/concurrent_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package concurrent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRun_AllSucceed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fn1 := func(ctx context.Context) (any, error) { return "one", nil }
|
||||
fn2 := func(ctx context.Context) (any, error) { return "two", nil }
|
||||
fn3 := func(ctx context.Context) (any, error) { return "three", nil }
|
||||
|
||||
results, err := Run(t.Context(), 0, fn1, fn2, fn3)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3)
|
||||
|
||||
values := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
values = append(values, r.Result.(string))
|
||||
}
|
||||
require.ElementsMatch(t, []string{"one", "two", "three"}, values)
|
||||
}
|
||||
|
||||
func TestRun_OneError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sentinel := errors.New("task failed")
|
||||
|
||||
fn1 := func(ctx context.Context) (any, error) { return "ok", nil }
|
||||
fn2 := func(ctx context.Context) (any, error) { return nil, sentinel }
|
||||
|
||||
_, err := Run(t.Context(), 0, fn1, fn2)
|
||||
|
||||
require.ErrorIs(t, err, sentinel)
|
||||
}
|
||||
|
||||
func TestRun_NoTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results, err := Run(t.Context(), 0)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestRun_MaxConcurrency(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numTasks = 10
|
||||
var peak atomic.Int32
|
||||
var active atomic.Int32
|
||||
|
||||
task := func(ctx context.Context) (any, error) {
|
||||
current := active.Add(1)
|
||||
if current > peak.Load() {
|
||||
peak.Store(current)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
active.Add(-1)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tasks := make([]Func, numTasks)
|
||||
for i := range tasks {
|
||||
tasks[i] = task
|
||||
}
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
results, err := Run(t.Context(), 3, tasks...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, numTasks)
|
||||
require.LessOrEqual(t, peak.Load(), int32(3))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRun_ZeroConcurrencyUsesAllTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const numTasks = 5
|
||||
var peak atomic.Int32
|
||||
var active atomic.Int32
|
||||
|
||||
task := func(ctx context.Context) (any, error) {
|
||||
current := active.Add(1)
|
||||
if current > peak.Load() {
|
||||
peak.Store(current)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
active.Add(-1)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tasks := make([]Func, numTasks)
|
||||
for i := range tasks {
|
||||
tasks[i] = task
|
||||
}
|
||||
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
results, err := Run(t.Context(), 0, tasks...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, numTasks)
|
||||
require.Equal(t, int32(numTasks), peak.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRun_ContextCancelledBeforeStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
|
||||
called := atomic.Bool{}
|
||||
fn := func(ctx context.Context) (any, error) {
|
||||
called.Store(true)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
_, err := Run(ctx, 1, fn, fn, fn)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRun_ContextPassedToTasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type key struct{}
|
||||
ctx := context.WithValue(t.Context(), key{}, "testvalue")
|
||||
|
||||
fn := func(ctx context.Context) (any, error) {
|
||||
return ctx.Value(key{}), nil
|
||||
}
|
||||
|
||||
results, err := Run(ctx, 0, fn)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "testvalue", results[0].Result)
|
||||
}
|
||||
@@ -5,22 +5,23 @@ import (
|
||||
)
|
||||
|
||||
type ReadTransaction interface {
|
||||
GetObject(bucketName string, key []byte, object interface{}) error
|
||||
GetAll(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||
GetAllWithJsoniter(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||
GetObject(bucketName string, key []byte, object any) error
|
||||
GetRawBytes(bucketName string, key []byte) ([]byte, error)
|
||||
GetAll(bucketName string, obj any, append func(o any) (any, error)) error
|
||||
GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, append func(o any) (any, error)) error
|
||||
KeyExists(bucketName string, key []byte) (bool, error)
|
||||
}
|
||||
|
||||
type Transaction interface {
|
||||
ReadTransaction
|
||||
|
||||
SetServiceName(bucketName string) error
|
||||
UpdateObject(bucketName string, key []byte, object interface{}) error
|
||||
UpdateObject(bucketName string, key []byte, object any) error
|
||||
DeleteObject(bucketName string, key []byte) error
|
||||
CreateObject(bucketName string, fn func(uint64) (int, interface{})) error
|
||||
CreateObjectWithId(bucketName string, id int, obj interface{}) error
|
||||
CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error
|
||||
DeleteAllObjects(bucketName string, obj interface{}, matching func(o interface{}) (id int, ok bool)) error
|
||||
CreateObject(bucketName string, fn func(uint64) (int, any)) error
|
||||
CreateObjectWithId(bucketName string, id int, obj any) error
|
||||
CreateObjectWithStringId(bucketName string, id []byte, obj any) error
|
||||
DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error
|
||||
GetNextIdentifier(bucketName string) int
|
||||
}
|
||||
|
||||
@@ -41,13 +42,14 @@ type Connection interface {
|
||||
GetDatabaseFileName() string
|
||||
GetDatabaseFilePath() string
|
||||
GetStorePath() string
|
||||
GetDatabaseFileSize() (int64, error)
|
||||
|
||||
IsEncryptedStore() bool
|
||||
NeedsEncryptionMigration() (bool, error)
|
||||
SetEncrypted(encrypted bool)
|
||||
SetEncrypted(encrypted bool) error
|
||||
|
||||
BackupMetadata() (map[string]interface{}, error)
|
||||
RestoreMetadata(s map[string]interface{}) error
|
||||
BackupMetadata() (map[string]any, error)
|
||||
RestoreMetadata(s map[string]any) error
|
||||
|
||||
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
|
||||
ConvertToKey(v int) []byte
|
||||
|
||||
@@ -1,55 +1,382 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/scrypt"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
// Not allowed in FIPS mode
|
||||
"golang.org/x/crypto/argon2" //nolint:depguard
|
||||
"golang.org/x/crypto/scrypt" //nolint:depguard
|
||||
)
|
||||
|
||||
// NOTE: has to go with what is considered to be a simplistic in that it omits any
|
||||
// authentication of the encrypted data.
|
||||
// Person with better knowledge is welcomed to improve it.
|
||||
// sourced from https://golang.org/src/crypto/cipher/example_test.go
|
||||
const (
|
||||
// AES GCM settings
|
||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
aesGcmFIPSHeader = "FIPS-AES256-GCM"
|
||||
aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm
|
||||
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// Argon2 settings
|
||||
// Recommended settings lower memory hardware according to current OWASP recommendations
|
||||
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
argon2MemoryCost = 12 * 1024
|
||||
argon2TimeCost = 3
|
||||
argon2Threads = 1
|
||||
argon2KeyLength = 32
|
||||
|
||||
pbkdf2Iterations = 600_000 // use recommended iterations from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 a little overkill for this use
|
||||
pbkdf2SaltLength = 32
|
||||
)
|
||||
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||
// Copy the input to the output, encrypting as we go.
|
||||
if _, err := io.Copy(writer, input); err != nil {
|
||||
return err
|
||||
if fips.FIPSMode() {
|
||||
if err := aesEncryptGCMFIPS(input, output, passphrase); err != nil {
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := aesEncryptGCM(input, output, passphrase); err != nil {
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
||||
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return aesDecrypt(input, passphrase, fips.FIPSMode())
|
||||
}
|
||||
|
||||
func aesDecrypt(input io.Reader, passphrase []byte, fipsMode bool) (io.Reader, error) {
|
||||
// Read file header to determine how it was encrypted
|
||||
inputReader := bufio.NewReader(input)
|
||||
header, err := inputReader.Peek(len(aesGcmFIPSHeader))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(string(header), aesGcmFIPSHeader) {
|
||||
if !fipsMode {
|
||||
return nil, errors.New("fips encrypted file detected but fips mode is not enabled")
|
||||
}
|
||||
|
||||
reader, err := aesDecryptGCMFIPS(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(string(header), aesGcmHeader) {
|
||||
if fipsMode {
|
||||
return nil, errors.New("fips mode is enabled but non-fips encrypted file detected")
|
||||
}
|
||||
|
||||
reader, err := aesDecryptGCM(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// Use the previous decryption routine which has no header (to support older archives)
|
||||
reader, err := aesDecryptOFB(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
|
||||
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// Derive key using argon2 with a random salt
|
||||
salt := make([]byte, 16) // 16 bytes salt
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce, err := NewRandomNonce(aesgcm.NonceSize())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the header
|
||||
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write nonce and salt to the output file
|
||||
if _, err := output.Write(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(nonce.Value()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buffer for reading plaintext blocks
|
||||
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
|
||||
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
|
||||
|
||||
// Encrypt plaintext in blocks
|
||||
for {
|
||||
n, err := io.ReadFull(input, buf)
|
||||
if n == 0 {
|
||||
break // end of plaintext input
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
||||
|
||||
if _, err := output.Write(ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := nonce.Increment(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
|
||||
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Reader & verify header
|
||||
header := make([]byte, len(aesGcmHeader))
|
||||
if _, err := io.ReadFull(input, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(header) != aesGcmHeader {
|
||||
return nil, errors.New("invalid header")
|
||||
}
|
||||
|
||||
// Read salt
|
||||
salt := make([]byte, 16) // Salt size
|
||||
if _, err := io.ReadFull(input, salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
|
||||
// Initialize AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create GCM mode with the cipher block
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read nonce from the input reader
|
||||
nonce := NewNonce(aesgcm.NonceSize())
|
||||
if err := nonce.Read(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize a buffer to store decrypted data
|
||||
buf := bytes.Buffer{}
|
||||
plaintext := make([]byte, aesGcmBlockSize)
|
||||
|
||||
// Decrypt the ciphertext in blocks
|
||||
for {
|
||||
// Read a block of ciphertext from the input reader
|
||||
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
|
||||
n, err := io.ReadFull(input, ciphertextBlock)
|
||||
if n == 0 {
|
||||
break // end of ciphertext
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the block of ciphertext
|
||||
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := buf.Write(plaintext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := nonce.Increment(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// aesEncryptGCMFIPS reads from input, encrypts with AES-256 in a fips compliant
|
||||
// way and writes to output. passphrase is used to generate an encryption key.
|
||||
func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
salt := make([]byte, pbkdf2SaltLength)
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deriving key: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the header
|
||||
if _, err := output.Write([]byte(aesGcmFIPSHeader)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write nonce and salt to the output file
|
||||
if _, err := output.Write(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buffer for reading plaintext blocks
|
||||
buf := make([]byte, aesGcmFIPSBlockSize)
|
||||
|
||||
// Encrypt plaintext in blocks
|
||||
for {
|
||||
// new random nonce for each block
|
||||
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating gcm: %w", err)
|
||||
}
|
||||
|
||||
n, err := io.ReadFull(input, buf)
|
||||
if n == 0 {
|
||||
break // end of plaintext input
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seal encrypts the plaintext
|
||||
ciphertext := aesgcm.Seal(nil, nil, buf[:n], nil)
|
||||
|
||||
if _, err := output.Write(ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesDecryptGCMFIPS reads from input, decrypts with AES-256 in a fips compliant
|
||||
// way and returns the reader to read the decrypted content from.
|
||||
func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Reader & verify header
|
||||
header := make([]byte, len(aesGcmFIPSHeader))
|
||||
if _, err := io.ReadFull(input, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(header) != aesGcmFIPSHeader {
|
||||
return nil, errors.New("invalid header")
|
||||
}
|
||||
|
||||
// Read salt
|
||||
salt := make([]byte, pbkdf2SaltLength)
|
||||
if _, err := io.ReadFull(input, salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := pbkdf2.Key(sha256.New, string(passphrase), salt, pbkdf2Iterations, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error deriving key: %w", err)
|
||||
}
|
||||
|
||||
// Initialize AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize a buffer to store decrypted data
|
||||
buf := bytes.Buffer{}
|
||||
|
||||
// Decrypt the ciphertext in blocks
|
||||
for {
|
||||
// Create GCM mode with the cipher block
|
||||
aesgcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read a block of ciphertext from the input reader
|
||||
ciphertextBlock := make([]byte, aesGcmFIPSBlockSize+aesgcm.Overhead())
|
||||
n, err := io.ReadFull(input, ciphertextBlock)
|
||||
if n == 0 {
|
||||
break // end of ciphertext
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the block of ciphertext
|
||||
plaintext, err := aesgcm.Open(nil, nil, ciphertextBlock[:n], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := buf.Write(plaintext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
||||
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -59,12 +386,25 @@ func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
reader := &cipher.StreamReader{S: stream, R: input}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// HasEncryptedHeader checks if the data has an encrypted header, note that fips
|
||||
// mode changes this behavior and so will only recognize data encrypted by the
|
||||
// same mode (fips enabled or disabled)
|
||||
func HasEncryptedHeader(data []byte) bool {
|
||||
return hasEncryptedHeader(data, fips.FIPSMode())
|
||||
}
|
||||
|
||||
func hasEncryptedHeader(data []byte, fipsMode bool) bool {
|
||||
if fipsMode {
|
||||
return bytes.HasPrefix(data, []byte(aesGcmFIPSHeader))
|
||||
}
|
||||
|
||||
return bytes.HasPrefix(data, []byte(aesGcmHeader))
|
||||
}
|
||||
|
||||
@@ -1,127 +1,449 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fips.InitFIPS(false)
|
||||
}
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
type encryptFunc func(input io.Reader, output io.Writer, passphrase []byte) error
|
||||
type decryptFunc func(input io.Reader, passphrase []byte) (io.Reader, error)
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
const passphrase = "passphrase"
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
content := []byte("content")
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
if !decryptShouldSucceed {
|
||||
require.Error(t, err, "Failed to decrypt file as indicated by decryptShouldSucceed")
|
||||
} else {
|
||||
require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS, true)
|
||||
})
|
||||
|
||||
t.Run("non_fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCM, true)
|
||||
})
|
||||
|
||||
t.Run("system_fips_mode_public_entry_points", func(t *testing.T) {
|
||||
// use the init mode, public entry points
|
||||
testFunc(t, AesEncrypt, AesDecrypt, true)
|
||||
})
|
||||
|
||||
t.Run("fips_encrypted_file_header_fails_in_non_fips_mode", func(t *testing.T) {
|
||||
// use aesDecrypt which checks the header, confirm that it fails
|
||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return aesDecrypt(input, passphrase, false)
|
||||
}
|
||||
|
||||
testFunc(t, aesEncryptGCMFIPS, decrypt, false)
|
||||
})
|
||||
|
||||
t.Run("non_fips_encrypted_file_header_fails_in_fips_mode", func(t *testing.T) {
|
||||
// use aesDecrypt which checks the header, confirm that it fails
|
||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return aesDecrypt(input, passphrase, true)
|
||||
}
|
||||
|
||||
testFunc(t, aesEncryptGCM, decrypt, false)
|
||||
})
|
||||
|
||||
t.Run("fips_encrypted_file_fails_in_non_fips_mode", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCM, false)
|
||||
})
|
||||
|
||||
t.Run("non_fips_encrypted_file_with_fips_mode_should_fail", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCMFIPS, false)
|
||||
})
|
||||
|
||||
t.Run("fips_with_base_aesDecrypt", func(t *testing.T) {
|
||||
// maximize coverage, use the base aesDecrypt function with valid fips mode
|
||||
decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return aesDecrypt(input, passphrase, true)
|
||||
}
|
||||
|
||||
testFunc(t, aesEncryptGCMFIPS, decrypt, true)
|
||||
})
|
||||
|
||||
t.Run("legacy", func(t *testing.T) {
|
||||
testFunc(t, legacyAesEncrypt, aesDecryptOFB, true)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||
})
|
||||
|
||||
t.Run("non_fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin2")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||
})
|
||||
|
||||
t.Run("non_fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(1024 * 50)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte(""))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(""))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
|
||||
require.NoError(t, err, "Failed to decrypt file")
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
_, err = io.Copy(decryptedFileWriter, decryptedReader)
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedContent, err := os.ReadFile(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||
})
|
||||
|
||||
t.Run("non_fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
t.Parallel()
|
||||
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
var (
|
||||
originFilePath = filesystem.JoinPaths(tmpdir, "origin")
|
||||
encryptedFilePath = filesystem.JoinPaths(tmpdir, "encrypted")
|
||||
decryptedFilePath = filesystem.JoinPaths(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
content := randBytes(1034)
|
||||
err := os.WriteFile(originFilePath, content, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
originFile, err := os.Open(originFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(originFile)
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
encryptedFileWriter, err := os.Create(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileWriter)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
err = encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
require.NoError(t, err, "Failed to encrypt a file")
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
require.NoError(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
encryptedFileReader, err := os.Open(encryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(encryptedFileReader)
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
decryptedFileWriter, err := os.Create(decryptedFilePath)
|
||||
require.NoError(t, err)
|
||||
defer logs.CloseAndLogErr(decryptedFileWriter)
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
|
||||
_, err = decrypt(encryptedFileReader, []byte("garbage"))
|
||||
require.Error(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
}
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
t.Run("fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS)
|
||||
})
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
|
||||
t.Run("non_fips", func(t *testing.T) {
|
||||
testFunc(t, aesEncryptGCM, aesDecryptGCM)
|
||||
})
|
||||
}
|
||||
|
||||
func legacyAesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
key, err := scrypt.Key(passphrase, nil, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||
if _, err := io.Copy(writer, input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_hasEncryptedHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
fipsMode bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "non-FIPS mode with valid header",
|
||||
data: []byte("AES256-GCM" + "some encrypted data"),
|
||||
fipsMode: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non-FIPS mode with FIPS header",
|
||||
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
||||
fipsMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "FIPS mode with valid header",
|
||||
data: []byte("FIPS-AES256-GCM" + "some encrypted data"),
|
||||
fipsMode: true,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "FIPS mode with non-FIPS header",
|
||||
data: []byte("AES256-GCM" + "some encrypted data"),
|
||||
fipsMode: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid header",
|
||||
data: []byte("INVALID-HEADER" + "some data"),
|
||||
fipsMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty data",
|
||||
data: []byte{},
|
||||
fipsMode: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil data",
|
||||
data: nil,
|
||||
fipsMode: false,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasEncryptedHeader(tt.data, tt.fipsMode)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
|
||||
message = service.secret
|
||||
}
|
||||
|
||||
hash := libcrypto.HashFromBytes([]byte(message))
|
||||
hash := libcrypto.InsecureHashFromBytes([]byte(message))
|
||||
|
||||
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
|
||||
if err != nil {
|
||||
|
||||
23
api/crypto/ecdsa_test.go
Normal file
23
api/crypto/ecdsa_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateSignature(t *testing.T) {
|
||||
t.Parallel()
|
||||
var s = NewECDSAService("secret")
|
||||
|
||||
privKey, pubKey, err := s.GenerateKeyPair()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, privKey)
|
||||
require.NotEmpty(t, pubKey)
|
||||
|
||||
m := "test message"
|
||||
r, err := s.CreateSignature(m)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, r, m)
|
||||
require.NotEmpty(t, r)
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
// Not allowed in FIPS mode
|
||||
"golang.org/x/crypto/bcrypt" //nolint:depguard
|
||||
)
|
||||
|
||||
// Service represents a service for encrypting/hashing data.
|
||||
type Service struct{}
|
||||
|
||||
// Hash hashes a string using the bcrypt algorithm
|
||||
func (*Service) Hash(data string) (string, error) {
|
||||
func (Service) Hash(data string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.
|
||||
func (*Service) CompareHashAndData(hash string, data string) error {
|
||||
func (Service) CompareHashAndData(hash string, data string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(data))
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestService_Hash(t *testing.T) {
|
||||
var s = &Service{}
|
||||
t.Parallel()
|
||||
var s = Service{}
|
||||
|
||||
type args struct {
|
||||
hash string
|
||||
@@ -51,3 +54,12 @@ func TestService_Hash(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := Service{}
|
||||
|
||||
hash, err := s.Hash("Passw0rd!")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, hash)
|
||||
}
|
||||
|
||||
62
api/crypto/nonce.go
Normal file
62
api/crypto/nonce.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Nonce struct {
|
||||
val []byte
|
||||
}
|
||||
|
||||
func NewNonce(size int) *Nonce {
|
||||
return &Nonce{val: make([]byte, size)}
|
||||
}
|
||||
|
||||
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
||||
// This ensures there are plenty of nonce values available before rolling over
|
||||
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
||||
func NewRandomNonce(size int) (*Nonce, error) {
|
||||
randomBytes := 1
|
||||
if size <= randomBytes {
|
||||
return nil, errors.New("nonce size must be greater than the number of random bytes")
|
||||
}
|
||||
|
||||
randomPart := make([]byte, randomBytes)
|
||||
if _, err := rand.Read(randomPart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zeroPart := make([]byte, size-randomBytes)
|
||||
nonceVal := append(randomPart, zeroPart...)
|
||||
return &Nonce{val: nonceVal}, nil
|
||||
}
|
||||
|
||||
func (n *Nonce) Read(stream io.Reader) error {
|
||||
_, err := io.ReadFull(stream, n.val)
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *Nonce) Value() []byte {
|
||||
return n.val
|
||||
}
|
||||
|
||||
func (n *Nonce) Increment() error {
|
||||
// Start incrementing from the least significant byte
|
||||
for i := range slices.Backward(n.val) {
|
||||
// Increment the current byte
|
||||
n.val[i]++
|
||||
|
||||
// Check for overflow
|
||||
if n.val[i] != 0 {
|
||||
// No overflow, nonce is successfully incremented
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, it means the nonce has overflowed
|
||||
return errors.New("nonce overflow")
|
||||
}
|
||||
@@ -4,11 +4,32 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/pkg/fips"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration creates a basic tls.Config with recommended TLS settings
|
||||
func CreateTLSConfiguration() *tls.Config {
|
||||
return &tls.Config{
|
||||
func CreateTLSConfiguration(insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||
return createTLSConfiguration(fips.FIPSMode(), insecureSkipVerify)
|
||||
}
|
||||
|
||||
func createTLSConfiguration(fipsEnabled bool, insecureSkipVerify bool) *tls.Config { //nolint:forbidigo
|
||||
if fipsEnabled {
|
||||
return &tls.Config{ //nolint:forbidigo
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS13,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
},
|
||||
CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521},
|
||||
}
|
||||
}
|
||||
|
||||
return &tls.Config{ //nolint:forbidigo
|
||||
MinVersion: tls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
tls.TLS_AES_128_GCM_SHA256,
|
||||
@@ -22,25 +43,40 @@ func CreateTLSConfiguration() *tls.Config {
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
},
|
||||
InsecureSkipVerify: insecureSkipVerify, //nolint:forbidigo
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTLSConfigurationFromBytes initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from memory.
|
||||
func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := CreateTLSConfiguration()
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
func CreateTLSConfigurationFromBytes(useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||
return createTLSConfigurationFromBytes(fips.FIPSMode(), useTLS, caCert, cert, key, skipClientVerification, skipServerVerification)
|
||||
}
|
||||
|
||||
if !skipClientVerification {
|
||||
func createTLSConfigurationFromBytes(fipsEnabled, useTLS bool, caCert, cert, key []byte, skipClientVerification, skipServerVerification bool) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !useTLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
config := createTLSConfiguration(fipsEnabled, skipServerVerification)
|
||||
|
||||
if !skipClientVerification || fipsEnabled {
|
||||
certificate, err := tls.X509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Certificates = []tls.Certificate{certificate}
|
||||
}
|
||||
|
||||
if !skipServerVerification {
|
||||
if !skipServerVerification || fipsEnabled {
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
@@ -51,29 +87,38 @@ func CreateTLSConfigurationFromBytes(caCert, cert, key []byte, skipClientVerific
|
||||
|
||||
// CreateTLSConfigurationFromDisk initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
// loaded from disk.
|
||||
func CreateTLSConfigurationFromDisk(caCertPath, certPath, keyPath string, skipServerVerification bool) (*tls.Config, error) {
|
||||
config := CreateTLSConfiguration()
|
||||
config.InsecureSkipVerify = skipServerVerification
|
||||
func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
return createTLSConfigurationFromDisk(fips.FIPSMode(), config)
|
||||
}
|
||||
|
||||
if certPath != "" && keyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tlsConfig := createTLSConfiguration(fipsEnabled, config.TLSSkipVerify)
|
||||
|
||||
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if !skipServerVerification && caCertPath != "" {
|
||||
caCert, err := os.ReadFile(caCertPath)
|
||||
if !tlsConfig.InsecureSkipVerify && config.TLSCACertPath != "" { //nolint:forbidigo
|
||||
caCert, err := os.ReadFile(config.TLSCACertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
tlsConfig.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
return config, nil
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
92
api/crypto/tls_test.go
Normal file
92
api/crypto/tls_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateTLSConfiguration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// InsecureSkipVerify = false
|
||||
config := CreateTLSConfiguration(false)
|
||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||
|
||||
// InsecureSkipVerify = true
|
||||
config = CreateTLSConfiguration(true)
|
||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||
require.True(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFIPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips := true
|
||||
|
||||
fipsCipherSuites := []uint16{
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
}
|
||||
|
||||
fipsCurvePreferences := []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.CurveP521}
|
||||
|
||||
config := createTLSConfiguration(fips, false)
|
||||
require.Equal(t, config.MinVersion, uint16(tls.VersionTLS12)) //nolint:forbidigo
|
||||
require.Equal(t, config.MaxVersion, uint16(tls.VersionTLS13)) //nolint:forbidigo
|
||||
require.Equal(t, config.CipherSuites, fipsCipherSuites) //nolint:forbidigo
|
||||
require.Equal(t, config.CurvePreferences, fipsCurvePreferences) //nolint:forbidigo
|
||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromBytes(false, nil, nil, nil, false, false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, config)
|
||||
|
||||
// Skip TLS client/server verifications
|
||||
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, true, true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
|
||||
// Empty TLS
|
||||
config, err = CreateTLSConfigurationFromBytes(true, nil, nil, nil, false, false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, config)
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromDisk(t *testing.T) {
|
||||
t.Parallel()
|
||||
// No TLS
|
||||
config, err := CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, config)
|
||||
|
||||
// Skip TLS verifications
|
||||
config, err = CreateTLSConfigurationFromDisk(portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
}
|
||||
|
||||
func TestCreateTLSConfigurationFromDiskFIPS(t *testing.T) {
|
||||
t.Parallel()
|
||||
fips := true
|
||||
|
||||
// Skipping TLS verifications cannot be done in FIPS mode
|
||||
config, err := createTLSConfigurationFromDisk(fips, portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.False(t, config.InsecureSkipVerify) //nolint:forbidigo
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -8,6 +10,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -20,6 +23,9 @@ import (
|
||||
const (
|
||||
DatabaseFileName = "portainer.db"
|
||||
EncryptedDatabaseFileName = "portainer.edb"
|
||||
|
||||
txMaxSize = 65536
|
||||
compactedSuffix = ".compacted"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -34,6 +40,9 @@ type DbConnection struct {
|
||||
InitialMmapSize int
|
||||
EncryptionKey []byte
|
||||
isEncrypted bool
|
||||
Compact bool
|
||||
|
||||
gcm cipher.AEAD
|
||||
|
||||
*bolt.DB
|
||||
}
|
||||
@@ -61,8 +70,37 @@ func (connection *DbConnection) GetStorePath() string {
|
||||
return connection.Path
|
||||
}
|
||||
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) {
|
||||
func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
|
||||
file, err := os.Stat(connection.GetDatabaseFilePath())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Failed to stat database file path: %s err: %w", connection.GetDatabaseFilePath(), err)
|
||||
}
|
||||
|
||||
return file.Size(), nil
|
||||
}
|
||||
|
||||
func (connection *DbConnection) SetEncrypted(flag bool) error {
|
||||
connection.isEncrypted = flag
|
||||
|
||||
if !flag || connection.EncryptionKey == nil {
|
||||
connection.gcm = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(connection.EncryptionKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating AES cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating GCM cipher for database encryption: %w", err)
|
||||
}
|
||||
|
||||
connection.gcm = gcm
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return true if the database is encrypted
|
||||
@@ -73,7 +111,6 @@ func (connection *DbConnection) IsEncryptedStore() bool {
|
||||
// NeedsEncryptionMigration returns true if database encryption is enabled and
|
||||
// we have an un-encrypted DB that requires migration to an encrypted DB
|
||||
func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
|
||||
// Cases: Note, we need to check both portainer.db and portainer.edb
|
||||
// to determine if it's a new store. We only need to differentiate between cases 2,3 and 5
|
||||
|
||||
@@ -87,7 +124,9 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
|
||||
// If we have a loaded encryption key, always set encrypted
|
||||
if connection.EncryptionKey != nil {
|
||||
connection.SetEncrypted(true)
|
||||
if err := connection.SetEncrypted(true); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check for portainer.db
|
||||
@@ -121,15 +160,10 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
|
||||
|
||||
// Open opens and initializes the BoltDB database.
|
||||
func (connection *DbConnection) Open() error {
|
||||
|
||||
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
|
||||
|
||||
// Now we open the db
|
||||
databasePath := connection.GetDatabaseFilePath()
|
||||
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
|
||||
Timeout: 1 * time.Second,
|
||||
InitialMmapSize: connection.InitialMmapSize,
|
||||
})
|
||||
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,6 +172,24 @@ func (connection *DbConnection) Open() error {
|
||||
db.MaxBatchDelay = connection.MaxBatchDelay
|
||||
connection.DB = db
|
||||
|
||||
if connection.Compact {
|
||||
log.Info().Msg("compacting database")
|
||||
if err := connection.compact(); err != nil {
|
||||
log.Error().Err(err).Msg("failed to compact database")
|
||||
|
||||
// Close the read-only database and re-open in read-write mode
|
||||
if err := connection.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
|
||||
}
|
||||
|
||||
connection.Compact = false
|
||||
|
||||
return connection.Open()
|
||||
} else {
|
||||
log.Info().Msg("database compaction completed")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -178,6 +230,7 @@ func (connection *DbConnection) ViewTx(fn func(portainer.Transaction) error) err
|
||||
func (connection *DbConnection) BackupTo(w io.Writer) error {
|
||||
return connection.View(func(tx *bolt.Tx) error {
|
||||
_, err := tx.WriteTo(w)
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -192,6 +245,7 @@ func (connection *DbConnection) ExportRaw(filename string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filename, b, 0600)
|
||||
}
|
||||
|
||||
@@ -201,6 +255,7 @@ func (connection *DbConnection) ExportRaw(filename string) error {
|
||||
func (connection *DbConnection) ConvertToKey(v int) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, uint64(v))
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -212,7 +267,7 @@ func keyToString(b []byte) string {
|
||||
|
||||
v := binary.BigEndian.Uint64(b)
|
||||
if v <= math.MaxInt32 {
|
||||
return fmt.Sprintf("%d", v)
|
||||
return strconv.FormatUint(v, 10)
|
||||
}
|
||||
|
||||
return string(b)
|
||||
@@ -226,12 +281,38 @@ func (connection *DbConnection) SetServiceName(bucketName string) error {
|
||||
}
|
||||
|
||||
// GetObject is a generic function used to retrieve an unmarshalled object from a database.
|
||||
func (connection *DbConnection) GetObject(bucketName string, key []byte, object interface{}) error {
|
||||
func (connection *DbConnection) GetObject(bucketName string, key []byte, object any) error {
|
||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(bucketName, key, object)
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
var value []byte
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
value, err = tx.GetRawBytes(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
err := connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
var err error
|
||||
exists, err = tx.KeyExists(bucketName, key)
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) getEncryptionKey() []byte {
|
||||
if !connection.isEncrypted {
|
||||
return nil
|
||||
@@ -241,7 +322,7 @@ func (connection *DbConnection) getEncryptionKey() []byte {
|
||||
}
|
||||
|
||||
// UpdateObject is a generic function used to update an object inside a database.
|
||||
func (connection *DbConnection) UpdateObject(bucketName string, key []byte, object interface{}) error {
|
||||
func (connection *DbConnection) UpdateObject(bucketName string, key []byte, object any) error {
|
||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.UpdateObject(bucketName, key, object)
|
||||
})
|
||||
@@ -282,7 +363,7 @@ func (connection *DbConnection) DeleteObject(bucketName string, key []byte) erro
|
||||
|
||||
// DeleteAllObjects delete all objects where matching() returns (id, ok).
|
||||
// TODO: think about how to return the error inside (maybe change ok to type err, and use "notfound"?
|
||||
func (connection *DbConnection) DeleteAllObjects(bucketName string, obj interface{}, matching func(o interface{}) (id int, ok bool)) error {
|
||||
func (connection *DbConnection) DeleteAllObjects(bucketName string, obj any, matching func(o any) (id int, ok bool)) error {
|
||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.DeleteAllObjects(bucketName, obj, matching)
|
||||
})
|
||||
@@ -301,71 +382,64 @@ func (connection *DbConnection) GetNextIdentifier(bucketName string) int {
|
||||
}
|
||||
|
||||
// CreateObject creates a new object in the bucket, using the next bucket sequence id
|
||||
func (connection *DbConnection) CreateObject(bucketName string, fn func(uint64) (int, interface{})) error {
|
||||
func (connection *DbConnection) CreateObject(bucketName string, fn func(uint64) (int, any)) error {
|
||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.CreateObject(bucketName, fn)
|
||||
})
|
||||
}
|
||||
|
||||
// CreateObjectWithId creates a new object in the bucket, using the specified id
|
||||
func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, obj interface{}) error {
|
||||
func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, obj any) error {
|
||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.CreateObjectWithId(bucketName, id, obj)
|
||||
})
|
||||
}
|
||||
|
||||
// CreateObjectWithStringId creates a new object in the bucket, using the specified id
|
||||
func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error {
|
||||
func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj any) error {
|
||||
return connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.CreateObjectWithStringId(bucketName, id, obj)
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetAll(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||
func (connection *DbConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAll(bucketName, obj, append)
|
||||
return tx.GetAll(bucketName, obj, appendFn)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: decide which Unmarshal to use, and use one...
|
||||
func (connection *DbConnection) GetAllWithJsoniter(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||
func (connection *DbConnection) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error {
|
||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAllWithJsoniter(bucketName, obj, append)
|
||||
})
|
||||
}
|
||||
|
||||
func (connection *DbConnection) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, append func(o interface{}) (interface{}, error)) error {
|
||||
return connection.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAllWithKeyPrefix(bucketName, keyPrefix, obj, append)
|
||||
return tx.GetAllWithKeyPrefix(bucketName, keyPrefix, obj, appendFn)
|
||||
})
|
||||
}
|
||||
|
||||
// BackupMetadata will return a copy of the boltdb sequence numbers for all buckets.
|
||||
func (connection *DbConnection) BackupMetadata() (map[string]interface{}, error) {
|
||||
buckets := map[string]interface{}{}
|
||||
func (connection *DbConnection) BackupMetadata() (map[string]any, error) {
|
||||
buckets := map[string]any{}
|
||||
|
||||
err := connection.View(func(tx *bolt.Tx) error {
|
||||
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
bucketName := string(name)
|
||||
seqId := bucket.Sequence()
|
||||
buckets[bucketName] = int(seqId)
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return buckets, err
|
||||
}
|
||||
|
||||
// RestoreMetadata will restore the boltdb sequence numbers for all buckets.
|
||||
func (connection *DbConnection) RestoreMetadata(s map[string]interface{}) error {
|
||||
func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
|
||||
var err error
|
||||
|
||||
for bucketName, v := range s {
|
||||
id, ok := v.(float64) // JSON ints are unmarshalled to interface as float64. See: https://pkg.go.dev/encoding/json#Decoder.Decode
|
||||
if !ok {
|
||||
log.Error().Str("bucket", bucketName).Msg("failed to restore metadata to bucket, skipped")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -381,3 +455,48 @@ func (connection *DbConnection) RestoreMetadata(s map[string]interface{}) error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// compact attempts to compact the database and replace it iff it succeeds
|
||||
func (connection *DbConnection) compact() (err error) {
|
||||
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
|
||||
|
||||
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
|
||||
}
|
||||
|
||||
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure to create the compacted database: %w", err)
|
||||
}
|
||||
|
||||
compactedDB.MaxBatchSize = connection.MaxBatchSize
|
||||
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
|
||||
|
||||
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
|
||||
return fmt.Errorf("failure to compact the database: %w",
|
||||
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
||||
}
|
||||
|
||||
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
|
||||
return fmt.Errorf("failure to move the compacted database: %w",
|
||||
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
|
||||
}
|
||||
|
||||
if err := connection.Close(); err != nil {
|
||||
log.Warn().Err(err).Msg("failure to close the database after compaction")
|
||||
}
|
||||
|
||||
connection.DB = compactedDB
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
|
||||
return &bolt.Options{
|
||||
Timeout: 1 * time.Second,
|
||||
InitialMmapSize: connection.InitialMmapSize,
|
||||
FreelistType: bolt.FreelistMapType,
|
||||
NoFreelistSync: true,
|
||||
ReadOnly: readOnly,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package boltdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Test the specific scenarios mentioned in NeedsEncryptionMigration
|
||||
|
||||
// i.e.
|
||||
@@ -87,32 +91,47 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
connection := DbConnection{Path: dir}
|
||||
|
||||
if tc.dbname == "both" {
|
||||
// Special case. If portainer.db and portainer.edb exist.
|
||||
dbFile1 := path.Join(connection.Path, DatabaseFileName)
|
||||
dbFile1 := filesystem.JoinPaths(connection.Path, DatabaseFileName)
|
||||
f, _ := os.Create(dbFile1)
|
||||
f.Close()
|
||||
defer os.Remove(dbFile1)
|
||||
|
||||
dbFile2 := path.Join(connection.Path, EncryptedDatabaseFileName)
|
||||
err := f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
err := os.Remove(dbFile1)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
dbFile2 := filesystem.JoinPaths(connection.Path, EncryptedDatabaseFileName)
|
||||
f, _ = os.Create(dbFile2)
|
||||
f.Close()
|
||||
defer os.Remove(dbFile2)
|
||||
|
||||
err = f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
err := os.Remove(dbFile2)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
} else if tc.dbname != "" {
|
||||
dbFile := path.Join(connection.Path, tc.dbname)
|
||||
dbFile := filesystem.JoinPaths(connection.Path, tc.dbname)
|
||||
f, _ := os.Create(dbFile)
|
||||
f.Close()
|
||||
defer os.Remove(dbFile)
|
||||
|
||||
err := f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
err := os.Remove(dbFile)
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
}
|
||||
|
||||
if tc.key {
|
||||
connection.EncryptionKey = []byte("secret")
|
||||
connection.EncryptionKey = secretToEncryptionKey("secret")
|
||||
}
|
||||
|
||||
result, err := connection.NeedsEncryptionMigration()
|
||||
@@ -122,3 +141,112 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{EncryptionKey: []byte("bad")}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
}
|
||||
|
||||
func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conn.gcm)
|
||||
|
||||
err = conn.SetEncrypted(false)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, conn.gcm)
|
||||
|
||||
// MarshalObject must return plaintext after encryption is disabled
|
||||
data, err := conn.MarshalObject("hello")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", string(data))
|
||||
}
|
||||
|
||||
func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conn := DbConnection{
|
||||
Path: t.TempDir(),
|
||||
EncryptionKey: []byte("bad"),
|
||||
}
|
||||
|
||||
result, err := conn.NeedsEncryptionMigration()
|
||||
require.Error(t, err)
|
||||
require.False(t, result)
|
||||
}
|
||||
|
||||
func TestDBCompaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
db := &DbConnection{Path: t.TempDir()}
|
||||
|
||||
err := db.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Update(func(tx *bbolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = b.Put([]byte("key"), []byte("value"))
|
||||
require.NoError(t, err)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Reopen the DB to trigger compaction
|
||||
db.Compact = true
|
||||
err = db.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the data is still there
|
||||
err = db.View(func(tx *bbolt.Tx) error {
|
||||
b := tx.Bucket([]byte("testbucket"))
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
val := b.Get([]byte("key"))
|
||||
require.Equal(t, []byte("value"), val)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Failures
|
||||
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
|
||||
err = os.Mkdir(compactedPath, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
err = db.Open()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,14 @@ package boltdb
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func backupMetadata(connection *bolt.DB) (map[string]interface{}, error) {
|
||||
buckets := map[string]interface{}{}
|
||||
func backupMetadata(connection *bolt.DB) (map[string]any, error) {
|
||||
buckets := map[string]any{}
|
||||
|
||||
err := connection.View(func(tx *bolt.Tx) error {
|
||||
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
@@ -37,9 +38,9 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
||||
if err != nil {
|
||||
return []byte("{}"), err
|
||||
}
|
||||
defer connection.Close()
|
||||
defer logs.CloseAndLogErr(connection)
|
||||
|
||||
backup := make(map[string]interface{})
|
||||
backup := make(map[string]any)
|
||||
if metadata {
|
||||
meta, err := backupMetadata(connection)
|
||||
if err != nil {
|
||||
@@ -49,10 +50,10 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
||||
backup["__metadata"] = meta
|
||||
}
|
||||
|
||||
err = connection.View(func(tx *bolt.Tx) error {
|
||||
err = tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
if err := connection.View(func(tx *bolt.Tx) error {
|
||||
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
bucketName := string(name)
|
||||
var list []interface{}
|
||||
var list []any
|
||||
version := make(map[string]string)
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
@@ -60,7 +61,7 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
||||
continue
|
||||
}
|
||||
|
||||
var obj interface{}
|
||||
var obj any
|
||||
err := c.UnmarshalObject(v, &obj)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
@@ -84,27 +85,22 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(list) > 0 {
|
||||
if bucketName == "ssl" ||
|
||||
bucketName == "settings" ||
|
||||
bucketName == "tunnel_server" {
|
||||
backup[bucketName] = nil
|
||||
if len(list) > 0 {
|
||||
backup[bucketName] = list[0]
|
||||
}
|
||||
return nil
|
||||
if bucketName == "ssl" ||
|
||||
bucketName == "settings" ||
|
||||
bucketName == "tunnel_server" {
|
||||
backup[bucketName] = nil
|
||||
if len(list) > 0 {
|
||||
backup[bucketName] = list[0]
|
||||
}
|
||||
backup[bucketName] = list
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
backup[bucketName] = list
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return []byte("{}"), err
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,16 @@ package boltdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
|
||||
var errEncryptedStringTooShort = fmt.Errorf("encrypted string too short")
|
||||
var errEncryptedStringTooShort = errors.New("encrypted string too short")
|
||||
|
||||
// MarshalObject encodes an object to binary format
|
||||
func (connection *DbConnection) MarshalObject(object interface{}) ([]byte, error) {
|
||||
func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
@@ -31,85 +27,54 @@ func (connection *DbConnection) MarshalObject(object interface{}) ([]byte, error
|
||||
}
|
||||
}
|
||||
|
||||
if connection.getEncryptionKey() == nil {
|
||||
if connection.gcm == nil {
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
return encrypt(buf.Bytes(), connection.getEncryptionKey())
|
||||
return encrypt(buf.Bytes(), connection.gcm), nil
|
||||
}
|
||||
|
||||
// UnmarshalObject decodes an object from binary data
|
||||
func (connection *DbConnection) UnmarshalObject(data []byte, object interface{}) error {
|
||||
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
var err error
|
||||
if connection.getEncryptionKey() != nil {
|
||||
data, err = decrypt(data, connection.getEncryptionKey())
|
||||
if connection.gcm != nil {
|
||||
data, err = decrypt(data, connection.gcm)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
e := json.Unmarshal(data, object)
|
||||
if e != nil {
|
||||
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// mmm, don't have a KMS .... aes GCM seems the most likely from
|
||||
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
|
||||
|
||||
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||
block, _ := aes.NewCipher(passphrase)
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
ciphertextByte := gcm.Seal(
|
||||
nonce,
|
||||
nonce,
|
||||
plaintext,
|
||||
nil)
|
||||
return ciphertextByte, nil
|
||||
func encrypt(plaintext []byte, gcm cipher.AEAD) []byte {
|
||||
return gcm.Seal(nil, nil, plaintext, nil)
|
||||
}
|
||||
|
||||
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
|
||||
func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) {
|
||||
if string(encrypted) == "false" {
|
||||
return []byte("false"), nil
|
||||
}
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(encrypted) < nonceSize {
|
||||
if len(encrypted) < gcm.Overhead() {
|
||||
return encrypted, errEncryptedStringTooShort
|
||||
}
|
||||
|
||||
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
plaintextByte, err = gcm.Open(
|
||||
nil,
|
||||
nonce,
|
||||
ciphertextByteClean,
|
||||
nil)
|
||||
plaintextByte, err := gcm.Open(nil, nil, encrypted, nil)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||
}
|
||||
|
||||
return plaintextByte, err
|
||||
return plaintextByte, nil
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
package boltdb
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||
jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"InternalAuthSettings": {"RequiredPasswordLength": 12}"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
|
||||
passphrase = "my secret key"
|
||||
)
|
||||
|
||||
@@ -20,12 +27,13 @@ func secretToEncryptionKey(passphrase string) []byte {
|
||||
}
|
||||
|
||||
func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
uuid := uuid.Must(uuid.NewV4())
|
||||
uuid := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
object interface{}
|
||||
object any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
@@ -57,7 +65,7 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
expected: uuid.String(),
|
||||
},
|
||||
{
|
||||
object: map[string]interface{}{"key": "value"},
|
||||
object: map[string]any{"key": "value"},
|
||||
expected: `{"key":"value"}`,
|
||||
},
|
||||
{
|
||||
@@ -73,11 +81,11 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
expected: `["1","2","3"]`,
|
||||
},
|
||||
{
|
||||
object: []map[string]interface{}{{"key1": "value1"}, {"key2": "value2"}},
|
||||
object: []map[string]any{{"key1": "value1"}, {"key2": "value2"}},
|
||||
expected: `[{"key1":"value1"},{"key2":"value2"}]`,
|
||||
},
|
||||
{
|
||||
object: []interface{}{1, "2", false, map[string]interface{}{"key1": "value1"}},
|
||||
object: []any{1, "2", false, map[string]any{"key1": "value1"}},
|
||||
expected: `[1,"2",false,{"key1":"value1"}]`,
|
||||
},
|
||||
}
|
||||
@@ -87,13 +95,14 @@ func Test_MarshalObjectUnencrypted(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -128,13 +137,14 @@ func Test_UnMarshalObjectUnencrypted(t *testing.T) {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
var object string
|
||||
err := conn.UnmarshalObject(test.object, &object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.expected, object)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
t.Parallel()
|
||||
is := assert.New(t)
|
||||
|
||||
// Based on actual data entering and what we expect out of the function
|
||||
@@ -161,17 +171,221 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
|
||||
|
||||
data, err := conn.MarshalObject(test.object)
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
|
||||
var object []byte
|
||||
err = conn.UnmarshalObject(data, &object)
|
||||
|
||||
is.NoError(err)
|
||||
require.NoError(t, err)
|
||||
is.Equal(test.object, object)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NonceSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
// ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with
|
||||
// the old way of creating and including the nonce
|
||||
|
||||
encryptOldFn := func(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
|
||||
block, _ := aes.NewCipher(passphrase)
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return encrypted, err
|
||||
}
|
||||
|
||||
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||
}
|
||||
|
||||
decryptOldFn := func(encrypted []byte, passphrase []byte) (plaintext []byte, err error) {
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating cypher block")
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error creating GCM")
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(encrypted) < nonceSize {
|
||||
return encrypted, errEncryptedStringTooShort
|
||||
}
|
||||
|
||||
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:]
|
||||
|
||||
plaintext, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
|
||||
if err != nil {
|
||||
return encrypted, errors.Wrap(err, "Error decrypting text")
|
||||
}
|
||||
|
||||
return plaintext, err
|
||||
}
|
||||
|
||||
passphrase := make([]byte, 32)
|
||||
_, err := io.ReadFull(rand.Reader, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
block, err := aes.NewCipher(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
junk := make([]byte, 1024)
|
||||
_, err = io.ReadFull(rand.Reader, junk)
|
||||
require.NoError(t, err)
|
||||
|
||||
junkEnc := make([]byte, base64.StdEncoding.EncodedLen(len(junk)))
|
||||
base64.StdEncoding.Encode(junkEnc, junk)
|
||||
|
||||
cases := [][]byte{
|
||||
[]byte("test"),
|
||||
[]byte("35"),
|
||||
[]byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"),
|
||||
[]byte(jsonobject),
|
||||
passphrase,
|
||||
junk,
|
||||
junkEnc,
|
||||
}
|
||||
|
||||
for _, plain := range cases {
|
||||
var enc, dec []byte
|
||||
var err error
|
||||
|
||||
enc, err = encryptOldFn(plain, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
dec, err = decrypt(enc, gcm)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, plain, dec)
|
||||
|
||||
enc = encrypt(plain, gcm)
|
||||
|
||||
dec, err = decryptOldFn(enc, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, plain, dec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt([]byte("false"), gcm)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("false"), result)
|
||||
}
|
||||
|
||||
func TestDecrypt_ShortDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
short := []byte("short")
|
||||
result, err := decrypt(short, gcm)
|
||||
require.ErrorIs(t, err, errEncryptedStringTooShort)
|
||||
require.Equal(t, short, result)
|
||||
}
|
||||
|
||||
func TestDecrypt_CorruptDataReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
block, err := aes.NewCipher(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 30 bytes passes the length check but fails authentication
|
||||
corrupted := make([]byte, 30)
|
||||
_, err = io.ReadFull(rand.Reader, corrupted)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := decrypt(corrupted, gcm)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, corrupted, result)
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused.
|
||||
func BenchmarkEncryptCachedCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call.
|
||||
func BenchmarkEncryptPerCallCipher(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCMWithRandomNonce(block)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
_ = gcm.Seal(nil, nil, data, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use.
|
||||
func BenchmarkEncryptCachedCipherParallel(b *testing.B) {
|
||||
key := secretToEncryptionKey(passphrase)
|
||||
conn := DbConnection{EncryptionKey: key}
|
||||
err := conn.SetEncrypted(true)
|
||||
require.NoError(b, err)
|
||||
|
||||
data := []byte(jsonobject)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = encrypt(data, conn.gcm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
@@ -20,7 +21,7 @@ func (tx *DbTransaction) SetServiceName(bucketName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetObject(bucketName string, key []byte, object interface{}) error {
|
||||
func (tx *DbTransaction) GetObject(bucketName string, key []byte, object any) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
@@ -31,7 +32,34 @@ func (tx *DbTransaction) GetObject(bucketName string, key []byte, object interfa
|
||||
return tx.conn.UnmarshalObject(value, object)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object interface{}) error {
|
||||
func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
|
||||
}
|
||||
|
||||
if tx.conn.gcm != nil {
|
||||
var err error
|
||||
|
||||
if value, err = decrypt(value, tx.conn.gcm); err != nil {
|
||||
return value, errors.Wrap(err, "Failed decrypting object")
|
||||
}
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) KeyExists(bucketName string, key []byte) (bool, error) {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
value := bucket.Get(key)
|
||||
|
||||
return value != nil, nil
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) UpdateObject(bucketName string, key []byte, object any) error {
|
||||
data, err := tx.conn.MarshalObject(object)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -46,7 +74,7 @@ func (tx *DbTransaction) DeleteObject(bucketName string, key []byte) error {
|
||||
return bucket.Delete(key)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj interface{}, matchingFn func(o interface{}) (id int, ok bool)) error {
|
||||
func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj any, matchingFn func(o any) (id int, ok bool)) error {
|
||||
var ids []int
|
||||
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
@@ -74,16 +102,18 @@ func (tx *DbTransaction) DeleteAllObjects(bucketName string, obj interface{}, ma
|
||||
|
||||
func (tx *DbTransaction) GetNextIdentifier(bucketName string) int {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
id, err := bucket.NextSequence()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("bucket", bucketName).Msg("failed to get the next identifer")
|
||||
log.Error().Err(err).Str("bucket", bucketName).Msg("failed to get the next identifier")
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
return int(id)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, interface{})) error {
|
||||
func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, any)) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
seqId, _ := bucket.NextSequence()
|
||||
@@ -97,7 +127,7 @@ func (tx *DbTransaction) CreateObject(bucketName string, fn func(uint64) (int, i
|
||||
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj interface{}) error {
|
||||
func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj any) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
data, err := tx.conn.MarshalObject(obj)
|
||||
if err != nil {
|
||||
@@ -107,7 +137,7 @@ func (tx *DbTransaction) CreateObjectWithId(bucketName string, id int, obj inter
|
||||
return bucket.Put(tx.conn.ConvertToKey(id), data)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error {
|
||||
func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte, obj any) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
data, err := tx.conn.MarshalObject(obj)
|
||||
if err != nil {
|
||||
@@ -117,7 +147,7 @@ func (tx *DbTransaction) CreateObjectWithStringId(bucketName string, id []byte,
|
||||
return bucket.Put(id, data)
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetAll(bucketName string, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||
func (tx *DbTransaction) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
return bucket.ForEach(func(k []byte, v []byte) error {
|
||||
@@ -130,20 +160,7 @@ func (tx *DbTransaction) GetAll(bucketName string, obj interface{}, appendFn fun
|
||||
})
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetAllWithJsoniter(bucketName string, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||
bucket := tx.tx.Bucket([]byte(bucketName))
|
||||
|
||||
return bucket.ForEach(func(k []byte, v []byte) error {
|
||||
err := tx.conn.UnmarshalObject(v, obj)
|
||||
if err == nil {
|
||||
obj, err = appendFn(obj)
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (tx *DbTransaction) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj interface{}, appendFn func(o interface{}) (interface{}, error)) error {
|
||||
func (tx *DbTransaction) GetAllWithKeyPrefix(bucketName string, keyPrefix []byte, obj any, appendFn func(o any) (any, error)) error {
|
||||
cursor := tx.tx.Bucket([]byte(bucketName)).Cursor()
|
||||
|
||||
for k, v := cursor.Seek(keyPrefix); k != nil && bytes.HasPrefix(k, keyPrefix); k, v = cursor.Next() {
|
||||
|
||||
@@ -2,10 +2,12 @@ package boltdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testBucketName = "test-bucket"
|
||||
@@ -17,70 +19,56 @@ type testStruct struct {
|
||||
}
|
||||
|
||||
func TestTxs(t *testing.T) {
|
||||
conn := DbConnection{
|
||||
Path: t.TempDir(),
|
||||
}
|
||||
t.Parallel()
|
||||
conn := DbConnection{Path: t.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// Error propagation
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return errors.New("this is an error")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("an error was expected, got nil instead")
|
||||
}
|
||||
require.Error(t, err)
|
||||
|
||||
// Create an object
|
||||
newObj := testStruct{
|
||||
Key: "key",
|
||||
Value: "value",
|
||||
}
|
||||
newObj := testStruct{Key: "key", Value: "value"}
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
err = tx.SetServiceName(testBucketName)
|
||||
if err != nil {
|
||||
if err := tx.SetServiceName(testBucketName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
obj := testStruct{}
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if obj.Key != newObj.Key || obj.Value != newObj.Value {
|
||||
t.Fatalf("expected %s:%s, got %s:%s instead", newObj.Key, newObj.Value, obj.Key, obj.Value)
|
||||
}
|
||||
|
||||
// Update an object
|
||||
updatedObj := testStruct{
|
||||
Key: "updated-key",
|
||||
Value: "updated-value",
|
||||
}
|
||||
updatedObj := testStruct{Key: "updated-key", Value: "updated-value"}
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.UpdateObject(testBucketName, conn.ConvertToKey(testId), &updatedObj)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if obj.Key != updatedObj.Key || obj.Value != updatedObj.Value {
|
||||
t.Fatalf("expected %s:%s, got %s:%s instead", updatedObj.Key, updatedObj.Value, obj.Key, obj.Value)
|
||||
@@ -90,16 +78,12 @@ func TestTxs(t *testing.T) {
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return tx.DeleteObject(testBucketName, conn.ConvertToKey(testId))
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetObject(testBucketName, conn.ConvertToKey(testId), &obj)
|
||||
})
|
||||
if !dataservices.IsErrObjectNotFound(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.True(t, dataservices.IsErrObjectNotFound(err))
|
||||
|
||||
// Get next identifier
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
@@ -112,15 +96,65 @@ func TestTxs(t *testing.T) {
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to write in a read transaction
|
||||
err = conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.CreateObjectWithId(testBucketName, testId, newObj)
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("an error was expected, got nil instead")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func BenchmarkGetAll(b *testing.B) {
|
||||
const endpointBucket = "endpoints"
|
||||
const n = 10000
|
||||
|
||||
conn := DbConnection{Path: b.TempDir()}
|
||||
|
||||
err := conn.Open()
|
||||
require.NoError(b, err)
|
||||
b.Cleanup(func() {
|
||||
err := conn.Close()
|
||||
require.NoError(b, err)
|
||||
})
|
||||
|
||||
err = conn.UpdateTx(func(tx portainer.Transaction) error {
|
||||
if err := tx.SetServiceName(endpointBucket); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := 1; i <= n; i++ {
|
||||
ep := portainer.Endpoint{
|
||||
ID: portainer.EndpointID(i),
|
||||
Name: "env-" + strconv.Itoa(i),
|
||||
Type: portainer.DockerEnvironment,
|
||||
URL: "tcp://192.168.1." + strconv.Itoa(i%254+1) + ":2375",
|
||||
PublicURL: "https://env-" + strconv.Itoa(i) + ".example.com",
|
||||
GroupID: portainer.EndpointGroupID(i%10 + 1),
|
||||
TagIDs: []portainer.TagID{portainer.TagID(i%5 + 1), portainer.TagID(i%3 + 1)},
|
||||
LastCheckInDate: int64(i) * 1000,
|
||||
EdgeID: "edge-" + strconv.Itoa(i),
|
||||
}
|
||||
|
||||
if err := tx.CreateObjectWithId(endpointBucket, i, &ep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(b, err)
|
||||
|
||||
b.ResetTimer()
|
||||
b.ReportAllocs()
|
||||
|
||||
for b.Loop() {
|
||||
var collection []portainer.Endpoint
|
||||
|
||||
if err := conn.ViewTx(func(tx portainer.Transaction) error {
|
||||
return tx.GetAll(endpointBucket, new(portainer.Endpoint), dataservices.AppendFn(&collection))
|
||||
}); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import (
|
||||
)
|
||||
|
||||
// NewDatabase should use config options to return a connection to the requested database
|
||||
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
|
||||
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
|
||||
if storeType == "boltdb" {
|
||||
return &boltdb.DbConnection{
|
||||
Path: storePath,
|
||||
EncryptionKey: encryptionKey,
|
||||
Compact: compact,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user