Compare commits
1517 Commits
v3.0-beta
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
|
658c0cb382 | ||
|
eb1d52ffba | ||
|
5db7351f8c | ||
|
5ae3a56337 | ||
|
e7068b472e | ||
|
3dc94a35a1 | ||
|
f82abd71a3 | ||
|
602238d794 | ||
|
4d4a15740b | ||
|
524d50ee07 | ||
|
fc591b7fe8 | ||
|
c2f06193d0 | ||
|
f2ead12315 | ||
|
ca8700ac2a | ||
|
10a8d22efd | ||
|
fc3ec61373 | ||
|
094d1c0718 | ||
|
0d814ec03c | ||
|
5ccfe07e12 | ||
|
101ac5e985 | ||
|
113a780eec | ||
|
cf77610a56 | ||
|
84675fe521 | ||
|
5db5b35311 | ||
|
ff345c9609 | ||
|
6cccfec923 | ||
|
8231dd1463 | ||
|
d8ff020d8c | ||
|
238fb91360 | ||
|
9ecc16fcc1 | ||
|
7d9f60cf9b | ||
|
8ee16b4173 | ||
|
74861fa96b | ||
|
442a658487 | ||
|
3706b91b26 | ||
|
48cb54156b | ||
|
f3104c29ea | ||
|
689aee34ec | ||
|
3862ea4d28 | ||
|
a7e0eb52c2 | ||
|
537a88f618 | ||
|
93a5624294 | ||
|
37539990dd | ||
|
5cd8a80118 | ||
|
c74ecc3d75 | ||
|
404bccfb64 | ||
|
335c69f517 | ||
|
0d118f4d32 | ||
|
8f58a06731 | ||
|
4bfcc1abde | ||
|
25f39db690 | ||
|
da5ce7da9e | ||
|
e8126f3630 | ||
|
f842a7cc62 | ||
|
7250422aaa | ||
|
9661ff0b78 | ||
|
562201a342 | ||
|
1ee9f7bc68 | ||
|
18daa74ecd | ||
|
eadae9373f | ||
|
fb645dd84c | ||
|
0cffed3037 | ||
|
3bd5e02118 | ||
|
c4fe81fcbf | ||
|
40976870ee | ||
|
d526deb826 | ||
|
4ea3aa0a58 | ||
|
569ee8ac58 | ||
|
c42776a6d7 | ||
|
22af38579b | ||
|
a9ecf6c850 | ||
|
77112675ae | ||
|
0b054ae668 | ||
|
51ba9a86fa | ||
|
83eeaa0d73 | ||
|
a2316c8641 | ||
|
f231e9214c | ||
|
3673813e6a | ||
|
feb3c38113 | ||
|
73a969a6bf | ||
|
7ac6d6c498 | ||
|
b2f306789c | ||
|
1a26f757a8 | ||
|
1d66cda277 | ||
|
b52dad961b | ||
|
5a84136d87 | ||
|
73d701eb08 | ||
|
636ba5ebc8 | ||
|
627065e793 | ||
|
c9f395acbd | ||
|
919a5e9077 | ||
|
b5986fde82 | ||
|
cf95aded77 | ||
|
694a06ddb6 | ||
|
2eb3a17775 | ||
|
628464d2e1 | ||
|
ccaaa4bd21 | ||
|
1b285537ad | ||
|
eab31ba5d0 | ||
|
acc1233b11 | ||
|
91a3b52a4a | ||
|
b2532e305e | ||
|
06f7e7f74b | ||
|
a517867cdf | ||
|
e1d3ad11cc | ||
|
41dbdc8ccd | ||
|
df98ee8738 | ||
|
3be1cb6702 | ||
|
4644840009 | ||
|
4e75a95a73 | ||
|
92a2e26755 | ||
|
8ebd65cc0b | ||
|
15d51735d2 | ||
|
ee8afbd357 | ||
|
b3889bb1e3 | ||
|
8bbb4a48f7 | ||
|
2dce79bb85 | ||
|
1319c28f90 | ||
|
f95c6beeba | ||
|
6cf1eb6140 | ||
|
4b713ab66e | ||
|
43d055a8b4 | ||
|
28ce4bb1d6 | ||
|
604d53d2f0 | ||
|
62d3332522 | ||
|
9caed31185 | ||
|
030c1bdcba | ||
|
44af7eba11 | ||
|
41975973dc | ||
|
572c223854 | ||
|
8191668d60 | ||
|
632c4f3dc7 | ||
|
caacbfae03 | ||
|
3b3c071402 | ||
|
85fa427134 | ||
|
c3c7e50f08 | ||
|
45524eaee5 | ||
|
48ec4c7f6f | ||
|
593417a1fd | ||
|
e2c9941651 | ||
|
f865317600 | ||
|
c83a075886 | ||
|
56d894a8d1 | ||
|
bba7817c9b | ||
|
f360ef5d2f | ||
|
f6e625c5f8 | ||
|
e26639cdc4 | ||
|
38e0a939c2 | ||
|
2ec190ecfd | ||
|
549982db7f | ||
|
eab2c9d358 | ||
|
c5b72cb6d8 | ||
|
282a83829f | ||
|
20edd7cbcd | ||
|
a6311b4f63 | ||
|
b8792200c6 | ||
|
ba85879151 | ||
|
86bb847374 | ||
|
e61afba608 | ||
|
f418520f66 | ||
|
af3aebe34c | ||
|
8a568d787a | ||
|
0d943cb06f | ||
|
f9e9d32c52 | ||
|
6bf38d4346 | ||
|
2cd59fc310 | ||
|
1dfa1d62e1 | ||
|
854b9d252f | ||
|
0c0cf3a378 | ||
|
b4f29e63b4 | ||
|
afbb3df571 | ||
|
01caca707b | ||
|
8ed5826e6c | ||
|
2c1e36e54d | ||
|
1ed8b9f2d5 | ||
|
0ff8e9ed86 | ||
|
029081c610 | ||
|
63b9d15d34 | ||
|
7dcc3e7589 | ||
|
81e394a436 | ||
|
288068bf70 | ||
|
37546515be | ||
|
c32787ccd3 | ||
|
c9c7084db5 | ||
|
7b99441602 | ||
|
24940886f6 | ||
|
f777ac5f75 | ||
|
a511eb21fc | ||
|
f11a3c8c3b | ||
|
430a6053ef | ||
|
1867db5c99 | ||
|
f661cf0f83 | ||
|
b4814e281f | ||
|
9d4e5d8cb5 | ||
|
88f40b244a | ||
|
3e3047f23e | ||
|
ab4876e066 | ||
|
00d11880e8 | ||
|
c757cee988 | ||
|
ebbb681dd8 | ||
|
feff6ce027 | ||
|
39d01015e5 | ||
|
2aa2b15234 | ||
|
21c6d0b8f9 | ||
|
ffc9176225 | ||
|
d54609cc29 | ||
|
0a87453961 | ||
|
12d9058f1e | ||
|
f55c961e91 | ||
|
fbe4e7dc4c | ||
|
53079497a1 | ||
|
2d08171e7c | ||
|
e879ceb1bc | ||
|
37e2985b9a | ||
|
aaca74874d | ||
|
41c5b4bd64 | ||
|
71e43eb503 | ||
|
b52bb83c67 | ||
|
7322b7cbf0 | ||
|
62ffd97808 | ||
|
a4ee56648e | ||
|
f62e481fa0 | ||
|
fa26fce0cc | ||
|
2fbee4aacc | ||
|
530a7ef393 | ||
|
99cb546b59 | ||
|
084bec0f07 | ||
|
c199413d49 | ||
|
24eada4432 | ||
|
4df4aa07f4 | ||
|
91fd0f0e9a | ||
|
12f6244930 | ||
|
327ecbe34c | ||
|
2ca62293a9 | ||
|
f3cae0b005 | ||
|
8f15d5dcdd | ||
|
c8348f7be8 | ||
|
1839645360 | ||
|
2c73dc1df8 | ||
|
fefabe073f | ||
|
77b156c7f5 | ||
|
5ac84e109d | ||
|
83d105facd | ||
|
b7af06d59d | ||
|
4da32690a9 | ||
|
a49c2a1cc0 | ||
|
f633a9654a | ||
|
0d58a172a9 | ||
|
651784b1d1 | ||
|
68abc7ec1b | ||
|
9745e8b034 | ||
|
d946c108a3 | ||
|
2b66f9a5c4 | ||
|
4d321cf3f6 | ||
|
299d84b16a | ||
|
3d75f6bbbd | ||
|
6b194bba15 | ||
|
e63bccf274 | ||
|
4ca79ac1c9 | ||
|
aafef538f1 | ||
|
68d8546383 | ||
|
c43b3926b8 | ||
|
7e9cfc2872 | ||
|
e88936c05a | ||
|
9c1b4222d0 | ||
|
69ec55b638 | ||
|
67a455c403 | ||
|
5bf4df2d27 | ||
|
7797cc06d0 | ||
|
541d89e170 | ||
|
d775fb69e3 | ||
|
2a1a885056 | ||
|
a334ce1527 | ||
|
447cb5ccdc | ||
|
bca20e5b02 | ||
|
90675dcc2e | ||
|
e8deadaaff | ||
|
ecc4cc7670 | ||
|
4a5de5efd4 | ||
|
bdf557fde3 | ||
|
29600cb54c | ||
|
9f43fd7c92 | ||
|
6a6c1aa527 | ||
|
df7f9f2b14 | ||
|
568da8cc64 | ||
|
e16435f4fc | ||
|
76e9f3fd29 | ||
|
d0e46a517b | ||
|
c94345cb2f | ||
|
e2882acec1 | ||
|
3c2362177f | ||
|
5e92931108 | ||
|
d54e388b58 | ||
|
390cfa0cdf | ||
|
c6fc741aa8 | ||
|
a0e15e1671 | ||
|
8367cba259 | ||
|
f7bf709295 | ||
|
ab802ea5cf | ||
|
922d8eab58 | ||
|
409acc9f1a | ||
|
196dc78b4f | ||
|
61404d9c12 | ||
|
ceab5ead8c | ||
|
9b60acf3db | ||
|
90bb321a07 | ||
|
e56fa24a38 | ||
|
574aff605f | ||
|
9c6d0b56c3 | ||
|
e0761396b8 | ||
|
4b44eb5c80 | ||
|
eb66a44edf | ||
|
f8708b84e6 | ||
|
cc29091116 | ||
|
2f860772d2 | ||
|
2f5d1c0966 | ||
|
39c6817e65 | ||
|
2d63f56d64 | ||
|
a4a158a9e9 | ||
|
be78cb5321 | ||
|
1e483dc34d | ||
|
8ddf77973d | ||
|
d9a4858c4f | ||
|
c3e5406218 | ||
|
b92c345b3a | ||
|
ac9fd8f2ca | ||
|
20aae4769d | ||
|
1052c72863 | ||
|
4beb61c3af | ||
|
4b6c5db904 | ||
|
18493bb9b0 | ||
|
13a4bee725 | ||
|
7db0f7ec35 | ||
|
9936038603 | ||
|
ae9fb91c72 | ||
|
dcf7126f51 | ||
|
d3a512bf9e | ||
|
6809d97dd6 | ||
|
b89919546c | ||
|
cfa1c23506 | ||
|
e61b5d2a3f | ||
|
9089fd37e0 | ||
|
4eab083a30 | ||
|
11a07758aa | ||
|
ae712c1c98 | ||
|
9b2415f0f1 | ||
|
f130098937 | ||
|
e280a2e4a9 | ||
|
60bd4bc91b | ||
|
145c3d8f96 | ||
|
663c134e60 | ||
|
1e264ca4a1 | ||
|
207e9f7afd | ||
|
6e4c144af6 | ||
|
325c97cfe6 | ||
|
4d07845c7f | ||
|
93baa505c7 | ||
|
b81d4667b2 | ||
|
3dd065dd7b | ||
|
0a8692dcc0 | ||
|
26cc295167 | ||
|
48d9800b71 | ||
|
9424ad1f13 | ||
|
5c58f548c0 | ||
|
f59111025b | ||
|
e313776982 | ||
|
faa0bc952f | ||
|
87f8c60e2f | ||
|
e551c499db | ||
|
9aaa1edad6 | ||
|
d96b178a9c | ||
|
1c857c0781 | ||
|
ae160aef23 | ||
|
2ccce69180 | ||
|
a9f618891b | ||
|
85d1cc8be4 | ||
|
6315112b3b | ||
|
8cff8d85f6 | ||
|
d7a1007f41 | ||
|
0cd2c6864e | ||
|
0cb46e1444 | ||
|
cb9dfa1321 | ||
|
674fea7063 | ||
|
722cbb6054 | ||
|
042160e6bd | ||
|
06c44fe91f | ||
|
d92c636b69 | ||
|
0599503779 | ||
|
2cf337a606 | ||
|
b0bb320fb6 | ||
|
7a2a2846e1 | ||
|
90c35b67bd | ||
|
65287ba800 | ||
|
14af465aa3 | ||
|
95f0b60cac | ||
|
d69044231b | ||
|
72fde9860b | ||
|
481ada43d6 | ||
|
a9d74e834d | ||
|
43fd2fff2b | ||
|
b37c64f5a5 | ||
|
f1aa064b2d | ||
|
8abadd1070 | ||
|
3f9d9732a0 | ||
|
b9dc3c44a8 | ||
|
68e757aafc | ||
|
af045447e6 | ||
|
db6976a06a | ||
|
aa66a5ffb2 | ||
|
bf74150f62 | ||
|
2987216169 | ||
|
08a41f8f68 | ||
|
714a824823 | ||
|
2242dca27d | ||
|
681558126d | ||
|
927e637d88 | ||
|
3b97cb420d | ||
|
241fbd6be5 | ||
|
a619e7f571 | ||
|
491119d676 | ||
|
29a8c15d62 | ||
|
26741512ea | ||
|
a987d91ae1 | ||
|
380b9a73ab | ||
|
66bd1da571 | ||
|
79ad3c0a84 | ||
|
e69e7ff3c1 | ||
|
1483ef83d9 | ||
|
dbed799e20 | ||
|
4602b68425 | ||
|
2d3eaedaa7 | ||
|
4d60b21a5f | ||
|
50ee8374ee | ||
|
65eb23e8ce | ||
|
5c76b18ddd | ||
|
fc6f5d2535 | ||
|
6a0348e9dc | ||
|
40d3548c82 | ||
|
fc7bbf89c6 | ||
|
6f848e3df8 | ||
|
d80eb03707 | ||
|
85d4b8c487 | ||
|
90e6409b1e | ||
|
96b28a8e9b | ||
|
a818e87e96 | ||
|
dc715758a6 | ||
|
87069329d8 | ||
|
8a380a4545 | ||
|
a5e18cb761 | ||
|
e9da3e7b6a | ||
|
289fa23728 | ||
|
c6a44bfe09 | ||
|
c6af129960 | ||
|
6cb30bcd7f | ||
|
55027fd3cd | ||
|
41bf9b8baa | ||
|
c117ee61d5 | ||
|
7385932e52 | ||
|
cb90b69b3f | ||
|
8d0e31872a | ||
|
881925fd43 | ||
|
3a2f744f0a | ||
|
9dc9e668c5 | ||
|
39477c8de8 | ||
|
1b0bb95e81 | ||
|
cd5a4bec52 | ||
|
43070ab809 | ||
|
532fedbb62 | ||
|
585bf37783 | ||
|
cad364e407 | ||
|
45457c5b38 | ||
|
4e9142b5be | ||
|
207b365d40 | ||
|
1ec95a0d86 | ||
|
db4b9ccc7a | ||
|
a86d0c74d3 | ||
|
354f4e47df | ||
|
84167650b8 | ||
|
15c12a81f1 | ||
|
249ae584c3 | ||
|
b04f7b2d2c | ||
|
630ce459cb | ||
|
68fae3b23c | ||
|
3525cd1083 | ||
|
a7a30fb282 | ||
|
e9730f24a0 | ||
|
c35d22a82f | ||
|
b76d92bfeb | ||
|
afa578aa34 | ||
|
519ccda5ed | ||
|
832513a7fc | ||
|
0300c26952 | ||
|
243071d4cc | ||
|
c95937d08b | ||
|
58f944c72e | ||
|
173cc57490 | ||
|
4c8ba6b0a8 | ||
|
ab5abe9bcf | ||
|
be2ea8c6d5 | ||
|
7834fff541 | ||
|
16ec9d2938 | ||
|
ef8849e8a9 | ||
|
93a23671e4 | ||
|
8a77fbfefd | ||
|
78bedf9ad6 | ||
|
6ec757ab66 | ||
|
9ffb7f54c7 | ||
|
7d71299c51 | ||
|
bea37aee7f | ||
|
5323687ea5 | ||
|
d1372a4c43 | ||
|
c9249a164a | ||
|
6f105f2626 | ||
|
e4c08896f4 | ||
|
e85a0df9b7 | ||
|
a5b7eabd97 | ||
|
f3688431a3 | ||
|
44e714352d | ||
|
60da68c994 | ||
|
11288fac20 | ||
|
be10a644a0 | ||
|
a5a64eadc7 | ||
|
2cee252b14 | ||
|
050b4a5c9d | ||
|
6d4b5d4484 | ||
|
964a6c2e3e | ||
|
fe9d373444 | ||
|
cce31f9b0b | ||
|
ca779ed5ad | ||
|
14336529d9 | ||
|
2e57285120 | ||
|
2784059a0f | ||
|
04e78f4de7 | ||
|
c051ab56b4 | ||
|
eb0eaaae2e | ||
|
9631b97694 | ||
|
6f036876c8 | ||
|
bd47179ea4 | ||
|
17004f704c | ||
|
418c6bd88b | ||
|
ada4c4f816 | ||
|
fc34c1fc35 | ||
|
6e6cd9a7e5 | ||
|
16051981d7 | ||
|
91b499fb14 | ||
|
1f73adffd6 | ||
|
82bd313e7a | ||
|
14cbfe47b9 | ||
|
f2d4ff6dc4 | ||
|
bf33a70727 | ||
|
e1bdcbd581 | ||
|
051c1e7622 | ||
|
3b176474ff | ||
|
15f1b33ea6 | ||
|
0603d4076a | ||
|
ac94f10dc3 | ||
|
bbb92490e9 | ||
|
2cb63092c0 | ||
|
b9bcb59592 | ||
|
e083adc022 | ||
|
c3cd38fe9f | ||
|
a7c2db5e99 | ||
|
4926ee5117 | ||
|
e7723ac3db | ||
|
8dbfb93e4e | ||
|
6096366756 | ||
|
8e4cf12512 | ||
|
b61fa1f870 | ||
|
ac3cf9e4b1 | ||
|
e18463f059 | ||
|
ee54e08d18 | ||
|
3c07df6496 | ||
|
2117b828c8 | ||
|
28d9694432 | ||
|
7d977700e6 | ||
|
33942945d0 | ||
|
8d7d78db46 | ||
|
3268cc30ea | ||
|
8830ebe34f | ||
|
1d9adba6dd | ||
|
71be73777e | ||
|
7709f70ef1 | ||
|
9b528b84e1 | ||
|
c53a4d4861 | ||
|
766173df3d | ||
|
7f65cae891 | ||
|
bc56ecb85c | ||
|
50c3151301 | ||
|
d49ec0a81e | ||
|
6692028762 | ||
|
b71c357958 | ||
|
03b7621f3e | ||
|
2bcf24bd84 | ||
|
cc5aa05b12 | ||
|
3232c5c4ce | ||
|
72a52f5cd6 | ||
|
bda48a56e0 | ||
|
09cdcf8e53 | ||
|
a4d5b41ca7 | ||
|
9fa0d91d06 | ||
|
510f60bdeb | ||
|
30fe827253 | ||
|
8f0f4b168b | ||
|
cd11c4beb6 | ||
|
4d49cc413a | ||
|
3d50a58a31 | ||
|
d5701230fa | ||
|
ab945d6afe | ||
|
b4f8a36d43 | ||
|
608c1b4eb6 | ||
|
edf3c42157 | ||
|
c523cec113 | ||
|
6f8b987d42 | ||
|
d0d0642bdf | ||
|
924d760e3b | ||
|
f8c207ca2b | ||
|
ada1edd0b7 | ||
|
c79333db61 | ||
|
13778bed87 | ||
|
83b4d96f42 | ||
|
6cf96de0b4 | ||
|
481fadc7fc | ||
|
44f2c59e56 | ||
|
83589a912f | ||
|
fab9f03e7d | ||
|
928c83b13c | ||
|
72f20bc69b | ||
|
8293d5379d | ||
|
1d29445a8b | ||
|
5c308d757f | ||
|
5bf16004c9 | ||
|
417fa437b7 | ||
|
43f893c921 | ||
|
8ad9af4bc2 | ||
|
b3931bdc9d | ||
|
7ac153848b | ||
|
7689ebcac5 | ||
|
bf7adf9ca9 | ||
|
51ab8c556a | ||
|
149a8e910d | ||
|
310746b8cd | ||
|
6681303450 | ||
|
3228f37f09 | ||
|
421785bf6a | ||
|
308e8ca8c7 | ||
|
cb068ef70e | ||
|
acd3ec782f | ||
|
12d1e5b8d0 | ||
|
b165cdbd79 | ||
|
66f4507dee | ||
|
a724e1cb58 | ||
|
f942809de0 | ||
|
8043f77e02 | ||
|
1db9ba90d8 | ||
|
464fa59cb6 | ||
|
a8e2cbf55b | ||
|
fecd8dab38 | ||
|
13a833e3ed | ||
|
8d4784052a | ||
|
d9e9b41861 | ||
|
f0c3ef0aa1 | ||
|
3888831679 | ||
|
1a32fad324 | ||
|
d842ae9540 | ||
|
d0177b7504 | ||
|
e3842b25f3 | ||
|
a29b59c9cd | ||
|
f94eb97aa4 | ||
|
86017b79eb | ||
|
bc22fa5fad | ||
|
d6b70028ff | ||
|
0f7f9acd58 | ||
|
20633a6d1a | ||
|
a23856270d | ||
|
55543e370e | ||
|
8137a46c68 | ||
|
aeb9597c71 | ||
|
5067485e94 | ||
|
dfd456c7dc | ||
|
0ccb07e683 | ||
|
ca67a6897f | ||
|
0390227641 | ||
|
395b0982db | ||
|
f096ab4da7 | ||
|
d5ec9f7640 | ||
|
5732867407 | ||
|
31842f4c12 | ||
|
d5168d2da6 | ||
|
cac5ec836b | ||
|
ee9569e7d4 | ||
|
95df7de026 | ||
|
d3a5bd374d | ||
|
43ac3dddf1 | ||
|
1174328de3 | ||
|
dda54fb907 | ||
|
92ea808a5d | ||
|
2bc3a75c94 | ||
|
07ef97ce7c | ||
|
5fe3539331 | ||
|
4bc3bd5f13 | ||
|
4abce854d7 | ||
|
3b5c73992e | ||
|
c5abea2944 | ||
|
8e5cf14ebc | ||
|
7c8410ab86 | ||
|
369b4b92cc | ||
|
c50bb70383 | ||
|
d84b2060f0 | ||
|
afcf6024e6 | ||
|
3542bd6668 | ||
|
69b9116dd5 | ||
|
a2db4f06b1 | ||
|
b60b0fb511 | ||
|
0c1e9a6bb5 | ||
|
2692f92cb9 | ||
|
a43c8b4b00 | ||
|
6f15389411 | ||
|
f055241802 | ||
|
ac77c3a390 | ||
|
5cd99f2edc | ||
|
7c70fbec30 | ||
|
ac0dc3196f | ||
|
7f4da826b1 | ||
|
cc2af4371f | ||
|
2a79a03d38 | ||
|
47aac7fe33 | ||
|
d4055884b1 | ||
|
61658e847a | ||
|
bd714223ce | ||
|
060154cb89 | ||
|
99a1bfca9d | ||
|
4379f30628 | ||
|
b501244577 | ||
|
6f7b9815ca | ||
|
227bd088f7 | ||
|
b1d6ecb07c | ||
|
41772f28bd | ||
|
393dac1c99 | ||
|
db9d0be6c7 | ||
|
f0774ec273 | ||
|
d4a4d28b58 | ||
|
3ec97021bd | ||
|
56cf972373 | ||
|
2f3ae4c1af | ||
|
bc3dd04e12 | ||
|
4e8fc1b431 | ||
|
cd39aa2968 | ||
|
87ea3fc982 | ||
|
f74a511778 | ||
|
202461fe48 | ||
|
2af6687351 | ||
|
e603af5f24 | ||
|
84069ee882 | ||
|
edbb5cef92 | ||
|
a62c54b4ed | ||
|
41df7c04c3 | ||
|
9b783a8322 | ||
|
57db4df618 | ||
|
9d1081bd56 | ||
|
cc3773817b | ||
|
8956355e57 | ||
|
645db97c14 | ||
|
07a04dc507 | ||
|
a7317af413 | ||
|
ba6d6b8851 | ||
|
3726810108 | ||
|
4f92a7edf3 | ||
|
5d84b61f18 | ||
|
cd1329ec67 | ||
|
48a58b2b69 | ||
|
9b64aba8bf | ||
|
cae8264d98 | ||
|
95d8985336 | ||
|
b26ae90807 | ||
|
66d171c432 | ||
|
40463d9831 | ||
|
eb7dee013d | ||
|
1a878599b1 | ||
|
b9e25abdd9 | ||
|
c612022717 | ||
|
31e7f02b8d | ||
|
75747a2979 | ||
|
a85a8668a7 | ||
|
cca5fd859c | ||
|
2ed49abb1b | ||
|
409e6d49b2 | ||
|
93cfc482b8 | ||
|
85be6d53d0 | ||
|
bf6f58eb5e | ||
|
cd9d17ab18 | ||
|
e0bc6a10d0 | ||
|
ccfc1ad166 | ||
|
812060240f | ||
|
715a266ca4 | ||
|
fa4b3ece56 | ||
|
aac2a002bb | ||
|
8574acaf6e | ||
|
ddf4639354 | ||
|
9bd394f351 | ||
|
6899d48aae | ||
|
514e1ca8d0 | ||
|
eba5be010a | ||
|
f578d5c1c9 | ||
|
e58b1d670b | ||
|
c2642259b4 | ||
|
c47b0c9741 | ||
|
f425156cad | ||
|
7d7e31120e | ||
|
ccd247d154 | ||
|
53df6849f7 | ||
|
c2080bd1b3 | ||
|
9e93f8c2a5 | ||
|
907a142c8d | ||
|
d7bc8cd8e4 | ||
|
bb3e00a695 | ||
|
af5e7974c3 | ||
|
6a88959ec4 | ||
|
7b59149f90 | ||
|
3e01079caf | ||
|
d92e62e40b | ||
|
b4952dea7b | ||
|
00acb04329 | ||
|
939dd0591e | ||
|
febdb2a9e0 | ||
|
bcfd9fc1c9 | ||
|
e4964da5d4 | ||
|
a042298a4a | ||
|
908be168a9 | ||
|
d780bb3937 | ||
|
461e7e8913 | ||
|
819e8b73c3 | ||
|
627b7087a1 | ||
|
f23cf555e1 | ||
|
000978e4fb | ||
|
74782483bd | ||
|
c5f9387b92 | ||
|
57583b6747 | ||
|
434c236210 | ||
|
807bb97b6a | ||
|
da53bd44d1 | ||
|
3b01943649 | ||
|
3340f9c6ee | ||
|
b21cfe8504 | ||
|
97ab6ec299 | ||
|
7fda58e5c8 | ||
|
db6e820d1d | ||
|
8c2e1875ca | ||
|
bd95fe9af1 | ||
|
a517f89234 | ||
|
f994e7bfa8 | ||
|
28716924c9 | ||
|
b597f90f5b | ||
|
5912420467 | ||
|
90f35fd680 | ||
|
3e2d6e71b9 | ||
|
cbffdd829a | ||
|
d77e092948 | ||
|
1f5e10e784 | ||
|
68e3813c6c | ||
|
c9d78e3f67 | ||
|
b4f3fb3b30 | ||
|
bf7fb898f9 | ||
|
6c5e0543b4 | ||
|
578a1db62f | ||
|
4524a55b23 | ||
|
f942eaf1b6 | ||
|
6a4d16fae9 | ||
|
53b234252f | ||
|
9287e81ef1 | ||
|
5462326f79 | ||
|
b8b3992159 | ||
|
8214000713 | ||
|
94337a33d4 | ||
|
8ddee03338 | ||
|
8e2934533b | ||
|
71ee784003 | ||
|
597528e9b7 | ||
|
45fbbf9218 | ||
|
c7a4a01fee | ||
|
fa04ad1395 | ||
|
abdd85bdd5 | ||
|
dba55d57e8 | ||
|
9819c96717 | ||
|
1b0885bffe | ||
|
c0a8540ab8 | ||
|
f3b95f03a1 | ||
|
5f06f3da52 | ||
|
10a7fb809b | ||
|
d3ea1f3da2 | ||
|
74d0471a6f | ||
|
a1c1805409 | ||
|
b843145bed | ||
|
119920b665 | ||
|
6190e2d290 | ||
|
79de99d146 | ||
|
56a9b3df0a | ||
|
4df6413dba | ||
|
fcd0ada639 | ||
|
6b6bd155aa | ||
|
9c91b1bca5 | ||
|
1a5c0e5b24 | ||
|
981b30e4af | ||
|
30c84d8cbf | ||
|
4c538c54a8 | ||
|
ad7a8f1d08 | ||
|
f02a07dab4 | ||
|
c61b0fc1e1 | ||
|
fed7dfcb73 | ||
|
0d69c811ab | ||
|
83274add22 | ||
|
7497d1b6d4 | ||
|
239bc144e3 | ||
|
1ece64abe1 | ||
|
915783699b | ||
|
43f6bd41c9 | ||
|
98c017dd56 | ||
|
811ed3251b | ||
|
665b0f1484 | ||
|
9e26d845da | ||
|
6ddf20f5ce | ||
|
27ed0710cc | ||
|
cb87c9f345 | ||
|
e1e147c8f0 | ||
|
b37d889de9 | ||
|
b61c9bfc5e | ||
|
e963788a81 | ||
|
3ef4798e09 | ||
|
21730102ae | ||
|
b1ae05152e | ||
|
cc87f01e2f | ||
|
85dfa1b078 | ||
|
fdff54e11e | ||
|
0894f38660 | ||
|
f643d8ef25 | ||
|
e437284980 | ||
|
f6e0d330ac | ||
|
890cbf0f36 | ||
|
36cf2ea2e8 | ||
|
a8f7bd0280 | ||
|
aa6517046d | ||
|
42e4d8c7ba | ||
|
3d1da38cbc | ||
|
5ae8490999 | ||
|
715675b2f1 | ||
|
0d7198a26c | ||
|
d8b7ce6d9b | ||
|
d032ca31e3 | ||
|
67b49c0585 | ||
|
c23242f18e | ||
|
c23f7a3ee2 | ||
|
7e3e957649 | ||
|
b8f304dfd3 | ||
|
e895ac9245 | ||
|
77579a71e8 | ||
|
cc7e13cdbf | ||
|
5df3886e42 | ||
|
cc179a05d0 | ||
|
ec5f4f2778 | ||
|
084c95f0e5 | ||
|
3b78cfe0fd | ||
|
160ce3338d | ||
|
d425cfe1aa | ||
|
6ff286f9b4 | ||
|
b930461434 | ||
|
ee6bb24506 | ||
|
b69124c89f | ||
|
f089e4a1ce | ||
|
7dedb5c076 | ||
|
3276c9a84c | ||
|
04ef2ea3b6 | ||
|
04a2f27a75 | ||
|
d962f59884 | ||
|
fd3bd4567a | ||
|
29e7a5ecdb | ||
|
4956b0d89d | ||
|
3bc54a4e16 | ||
|
29ecd602ec | ||
|
1425dec564 | ||
|
2bb1c0c999 | ||
|
97f3daae70 | ||
|
c53a7ef6fe | ||
|
b97fc16ad9 | ||
|
eaac12ddc8 | ||
|
183be5da0e | ||
|
ace0953c87 | ||
|
1fd7e7833d | ||
|
aa5801d73b | ||
|
d1ea8081e4 | ||
|
49467c906d | ||
|
79bd575aca | ||
|
e533a0e405 | ||
|
94b597d29a | ||
|
504fefff94 | ||
|
ff794a3638 | ||
|
efb9a34cb8 | ||
|
a93291b38f | ||
|
dfc9eab9d3 | ||
|
3c74b0b1ef | ||
|
9b981fcea8 | ||
|
9d3a189d77 | ||
|
01c0175e8f | ||
|
d7e5e2f381 | ||
|
c27de8b9a5 | ||
|
c3a55f8b69 | ||
|
78b65a799e | ||
|
6a30cee611 | ||
|
bc19e3a6ff | ||
|
5a8f2fa2be | ||
|
81168c27c6 | ||
|
a606626053 | ||
|
adeb57864b | ||
|
747f1a6fae | ||
|
3ac9c23573 | ||
|
5ad9c0e77a | ||
|
ba5ba2f1d6 | ||
|
4902b5f351 | ||
|
166fcda193 | ||
|
83560bc775 | ||
|
4ffb00c9f5 | ||
|
fbac41a774 | ||
|
c837ab8693 | ||
|
1cc321ddff | ||
|
7861cffb91 | ||
|
82a472f368 | ||
|
cfe59774e7 | ||
|
1098475473 | ||
|
4546e795ef | ||
|
bb0aba586b | ||
|
204b995e6c | ||
|
321b7b4cee | ||
|
413377fbb9 | ||
|
e2e821881c | ||
|
51712ed2a8 | ||
|
27de7ddbf8 | ||
|
bb700f3a3d | ||
|
563268558b | ||
|
56744cec7b | ||
|
87696a2e6c | ||
|
79f1c20f8a | ||
|
4e5638df36 | ||
|
71349f35e4 | ||
|
2cb06bb4bb | ||
|
5e1769b81f | ||
|
e344f28265 | ||
|
2297d6b85b | ||
|
baaecdbd8c | ||
|
31d8af642d | ||
|
27c67ec202 | ||
|
4833a29e57 | ||
|
4a2f3e0372 | ||
|
f3eeff4ec8 | ||
|
221e03ecfa | ||
|
392a9c6f53 | ||
|
145d12b2c8 | ||
|
0c5033ff79 | ||
|
5f46f54dfd | ||
|
d4819b13eb | ||
|
9dd9be3b8c | ||
|
dd38809866 | ||
|
c414725f12 | ||
|
d3c8a350a4 | ||
|
e04b748b91 | ||
|
1eee8c4725 | ||
|
2b7a4b170c | ||
|
7c0bf4f137 | ||
|
54bae43d2e | ||
|
25be1155a6 | ||
|
4905d61a1a | ||
|
ff0147bebb | ||
|
7fe4889b6e | ||
|
3c9b1b1833 | ||
|
2a46c873b8 | ||
|
6ed09324ad | ||
|
d1f9c10790 | ||
|
5d041b2fd3 | ||
|
5cea0fa87b | ||
|
6f140eef29 | ||
|
e5850adfd7 | ||
|
6921833ce2 | ||
|
57b9accd97 | ||
|
de94f5b233 | ||
|
b5519a73fb | ||
|
c5d8c2c355 | ||
|
c5b02a426c | ||
|
4cae910b68 | ||
|
b664fccce2 | ||
|
21672f99d1 | ||
|
c269e46892 | ||
|
8534e8cf5b | ||
|
94bf1c2484 | ||
|
b3b30470fc | ||
|
41d91e75fc | ||
|
a97a91b844 | ||
|
f1c577ab76 | ||
|
17a9fe5024 | ||
|
d74a76dee3 | ||
|
c01201b88e | ||
|
833bdf878a | ||
|
891990b2f1 | ||
|
e9ab7029c9 | ||
|
6f681dba09 | ||
|
b3edff947d | ||
|
ce6d80d601 | ||
|
a80a2866de | ||
|
02c2221970 | ||
|
d35bd6e75b | ||
|
f3a2f98864 | ||
|
a3a312e3db | ||
|
f5cb5c4516 | ||
|
d458a28337 | ||
|
a102a780f8 | ||
|
4dbbc32108 | ||
|
b6aedb43ee | ||
|
ec50dcbbd9 | ||
|
bcc983f11f | ||
|
809651054e | ||
|
2e965ceb9e | ||
|
e35f942964 | ||
|
40e6fce281 | ||
|
2139865876 | ||
|
c3cda05d98 | ||
|
548f3db33d | ||
|
a76e9ed98b | ||
|
c0ef41a9bb | ||
|
f6e5d9675a | ||
|
ef028659d8 | ||
|
40f39e918d | ||
|
2ec3ee2734 | ||
|
bc29b89a16 | ||
|
e21853286e | ||
|
c012b8c4a5 | ||
|
48f6c28556 | ||
|
0c1502f801 | ||
|
fec20ed381 | ||
|
252c147dcf | ||
|
453d474104 | ||
|
84cf4a9b66 | ||
|
fb016bebde | ||
|
8f6a738481 | ||
|
b07f958577 | ||
|
8da0fde52a | ||
|
39be16cb63 | ||
|
59d0c0def4 | ||
|
79c03db9a0 | ||
|
0c77823020 | ||
|
deed7e0022 | ||
|
99db8c7335 | ||
|
9fe2aa9ed5 | ||
|
4c80dc256b | ||
|
cafe9e9c11 | ||
|
27ff4e63b6 | ||
|
8020714e07 | ||
|
dbd825ba4b | ||
|
fee6cf29eb | ||
|
56287d8e7a | ||
|
45504eaf95 | ||
|
b8a9b1150a | ||
|
8bd0e43f58 | ||
|
2c83e9e83c | ||
|
53c9ca10a7 | ||
|
75fbdac42e | ||
|
09d54546ca | ||
|
b62fece3d0 | ||
|
284a2b7f64 | ||
|
9c873ccbbd | ||
|
5f72f90031 | ||
|
93cf3c69b8 | ||
|
88f856cbc7 | ||
|
2d5796d161 | ||
|
1d20dc9fcb | ||
|
49502235b5 | ||
|
6e9d71fcf8 | ||
|
27c7e33773 | ||
|
3012619049 | ||
|
d6801966c4 | ||
|
518e29118c | ||
|
acf4f3fbf0 | ||
|
8378030c70 | ||
|
dc7140d486 | ||
|
e3771a1c53 | ||
|
2e9ac00a42 | ||
|
4b8b3acd39 | ||
|
8703798ca0 | ||
|
47ac438844 | ||
|
bd3aa28523 | ||
|
68d0ae4002 | ||
|
6991039640 | ||
|
00611ef9dc | ||
|
3c50e4768a | ||
|
63dc9834be | ||
|
f042e42633 | ||
|
39b80a2e7e | ||
|
fb6e948358 | ||
|
181b0845bf | ||
|
b2a82dcfe5 | ||
|
ed1c05dec9 | ||
|
cd73aef0c9 | ||
|
ed522ec024 | ||
|
16998d1e16 | ||
|
11421d2d32 | ||
|
1af708aaea | ||
|
fbb82fa759 | ||
|
a9f977c3b4 | ||
|
5669e22548 | ||
|
da4bb9b83d | ||
|
c2cbaf0937 | ||
|
d81dce536c | ||
|
8e1e2c5b85 | ||
|
959d5e6822 | ||
|
036a0b4cb3 | ||
|
c038f14374 | ||
|
343cf91643 | ||
|
4fe70a4c46 | ||
|
59f7200512 | ||
|
0335e709c2 | ||
|
a7bb5ac21b | ||
|
99655cab33 | ||
|
348e574154 | ||
|
f35dee8643 | ||
|
168412c2e7 | ||
|
b9832542fb | ||
|
316545b253 | ||
|
348a57a0d8 | ||
|
a3f5654816 | ||
|
8207afe806 | ||
|
e15b8f8092 | ||
|
e6c99028d6 | ||
|
2e691d7c26 | ||
|
6c87812780 | ||
|
798a3632cf | ||
|
aa1d0aabd2 | ||
|
f348e691fa | ||
|
0dfda83e45 | ||
|
56044fa3f7 | ||
|
4c69fd4f60 | ||
|
92e3f7a6a3 | ||
|
76d0618d6e | ||
|
80c3a99eb1 | ||
|
91e3a3237b | ||
|
8c8c5a5826 | ||
|
b9370955d6 | ||
|
2761e728f7 | ||
|
f418bf4f63 | ||
|
3d046e4369 | ||
|
b6eb6f2d70 | ||
|
45ce3e26c1 | ||
|
ae91cedf76 | ||
|
b2a6b484b5 | ||
|
95c4fa56cc | ||
|
e8c56afa8e | ||
|
ddc3b1f6c2 | ||
|
d33d886574 | ||
|
0487980d2f | ||
|
e68257d6c9 | ||
|
ea0bbab680 | ||
|
5d4a8136c5 | ||
|
c98d851cd2 | ||
|
42fa89db7a | ||
|
1a70acc6f2 | ||
|
ee0a287112 | ||
|
cf16a66c63 | ||
|
4544b17c94 | ||
|
8119c9da51 | ||
|
baf4d75c01 | ||
|
ac9eff0fcc | ||
|
dc57bd92ff | ||
|
3c56a2c868 | ||
|
2d2ab10a24 | ||
|
ac906c9994 | ||
|
480406d579 | ||
|
47efb644b7 | ||
|
fd0e519e41 | ||
|
0c8bb990d0 | ||
|
bd7139827b | ||
|
4f648aff52 | ||
|
3feb45dc01 | ||
|
0489dc7c33 | ||
|
a30843cff9 | ||
|
3a34a0eb40 | ||
|
e3f82e136a | ||
|
8a7df4ba9f | ||
|
e86d1a4c7a | ||
|
5b9d0b60a1 | ||
|
7eff2f0c49 | ||
|
97236bb01d | ||
|
9c6aa12f48 | ||
|
96ccb03eea | ||
|
55f55820c5 | ||
|
955839d513 | ||
|
a650e628e5 | ||
|
54142b73fb | ||
|
55e0d2695d | ||
|
2f90ab15dc | ||
|
fd3fc66bfc | ||
|
a352a94d8a | ||
|
410b81f46f | ||
|
aa3711c5cc | ||
|
d6b1f97a04 | ||
|
b4e8e57a22 | ||
|
9644e6195c | ||
|
764e0c7607 | ||
|
97d640dd40 | ||
|
d2915b5b05 | ||
|
f274f6fd18 | ||
|
f507ac2569 | ||
|
208cbd6d89 | ||
|
cd2aa2902a | ||
|
fa2d7fa3da | ||
|
7463767781 | ||
|
958bc864c9 | ||
|
4484668750 | ||
|
d5dea4b87f | ||
|
0fdef6a0a2 | ||
|
bd71b6bad8 | ||
|
9b7887b279 | ||
|
3960e43872 | ||
|
201c8f9ec9 | ||
|
8c8374a08c | ||
|
467595afc9 | ||
|
acb54f098c | ||
|
5755d13460 | ||
|
2c3500315d | ||
|
47ea60c0cd | ||
|
18b18c1396 | ||
|
ff227de5fa | ||
|
6799692811 | ||
|
c6173f7f6f | ||
|
d0e4dabc44 | ||
|
f815dae300 | ||
|
b3bd6bb39e | ||
|
71df6409c2 | ||
|
e4f9a1e0cf | ||
|
ca6a05e393 | ||
|
c0d26164dc | ||
|
76fe2a1ba9 | ||
|
8cbdb54402 | ||
|
764ef80a62 | ||
|
0c37d93c01 | ||
|
c57a5128e5 | ||
|
6cf4eba20a | ||
|
6825d728c2 | ||
|
6d3091b2a2 | ||
|
61473877a4 | ||
|
52989c8f5c | ||
|
b64ba2ef16 | ||
|
461ae99dd8 | ||
|
8681df6f02 | ||
|
ba081ee442 | ||
|
cf90d05115 | ||
|
658c6554af | ||
|
94d9d608f7 | ||
|
015b50be5f | ||
|
85970f8c96 | ||
|
1740ab0bbe | ||
|
9d2b5593a2 | ||
|
881d62d69d | ||
|
935129f0a5 | ||
|
1a9bdc5e6d | ||
|
1f565bca10 | ||
|
da089197a3 | ||
|
2df8f41d6c | ||
|
4e4f0d4c97 | ||
|
eaad22c0a1 | ||
|
63e8553a09 | ||
|
b65828416f | ||
|
48dc8033f5 | ||
|
2d838b69fd | ||
|
6c529a6908 | ||
|
9baefec541 | ||
|
327d66bb80 | ||
|
760a4dfcb9 | ||
|
ceb8cdd337 | ||
|
570c754eec | ||
|
8ed75d1d21 | ||
|
54710b8221 | ||
|
ff794033e1 | ||
|
f0f486da9e | ||
|
aedcfd1d24 | ||
|
44e738acf5 | ||
|
701b45c286 | ||
|
72fe687d82 | ||
|
632cd66b57 | ||
|
20530c000e | ||
|
8824786fb4 | ||
|
bdeb4a4efe | ||
|
cf455fc19b | ||
|
1bcddadb7a | ||
|
6dc28f11e0 | ||
|
8ad601fcc0 | ||
|
f400844a3d | ||
|
560096878f | ||
|
0938f5ab71 | ||
|
06193d27c0 | ||
|
eb18857ecc | ||
|
9a280e99ad | ||
|
c7ca20b45a | ||
|
60e64a3646 | ||
|
d60f89976e | ||
|
41e05ddf9c | ||
|
5a34f16dcf | ||
|
769ca4e26d | ||
|
b6e62b08e4 | ||
|
effe5b32fd | ||
|
9e38137e76 | ||
|
57c2e89f00 | ||
|
914a0bf514 | ||
|
210f5eabc9 | ||
|
5f5c91a8ff | ||
|
05c7121c8a | ||
|
551a8e9588 | ||
|
75fbdb653a | ||
|
bdfe75cff3 | ||
|
bcd845fd59 | ||
|
f1e71ecb78 | ||
|
0aa4c8af6f | ||
|
8fe5507ff8 | ||
|
a950b80d5a | ||
|
ed3bb6429b | ||
|
1e88491ca1 | ||
|
4aed647865 | ||
|
74ab7aaa3d | ||
|
dcf2055851 | ||
|
6b6ad05e3a | ||
|
734a4b5e00 | ||
|
da70fac0b6 | ||
|
5f4a364095 | ||
|
95a8867527 | ||
|
7cb1301e80 | ||
|
e6e070d89e | ||
|
ba2bcaba07 | ||
|
3d6ecba4f5 | ||
|
864f82ba11 | ||
|
f671c992e1 | ||
|
86220694ce | ||
|
77f31177c8 | ||
|
33ff91aea8 | ||
|
a4151800f1 | ||
|
932f24c966 | ||
|
0c0bce9755 | ||
|
f07508073f | ||
|
2c9cade70a | ||
|
e06cc1bd2d | ||
|
36e33a4c10 | ||
|
7f668c1653 | ||
|
b464fa98df | ||
|
23491f1e8c | ||
|
2b90a2eed2 | ||
|
13b9d15d8f | ||
|
a053504bb8 | ||
|
d7d7a84bd5 | ||
|
990463fbea | ||
|
dcdd4aec85 | ||
|
0d9a8ba6f7 | ||
|
179da2ac05 | ||
|
4848739b6e | ||
|
46da285831 | ||
|
71a6a36a54 | ||
|
c8ca9ef7ab | ||
|
5af2fff9ca | ||
|
edbaf3ac82 | ||
|
337c9bc01e | ||
|
cd84674ae0 | ||
|
a196dce1fa | ||
|
f60ea43f29 | ||
|
76f547a726 | ||
|
dffe5e0819 | ||
|
b9633bbcd6 | ||
|
46efe2b8dd | ||
|
fb9ef0c547 | ||
|
d52cd2b17c | ||
|
cefa80f317 | ||
|
cc1dd682e8 | ||
|
bdd984a887 | ||
|
2d3dffe5fc | ||
|
65f31a0b38 | ||
|
4a1a6c5933 | ||
|
7e1fd99c37 | ||
|
8fe8209580 | ||
|
264a050360 | ||
|
3623104e3b | ||
|
191ff1abec | ||
|
3bb86493cc | ||
|
d1d3151e1e | ||
|
f9dc9ebdb3 | ||
|
3b478bcc2d | ||
|
77bb78c381 | ||
|
cafa97f502 | ||
|
0d858493d5 | ||
|
99fb07c6b3 | ||
|
eaad971c0a | ||
|
377abd87fd | ||
|
af71176296 | ||
|
69737177ef | ||
|
efae1222c1 | ||
|
3caea1a903 | ||
|
a43478d627 | ||
|
ed2c3f43c7 | ||
|
84c9846f7b | ||
|
fcfd816cec | ||
|
2e3977e59c | ||
|
3c68430336 | ||
|
43afb86fa8 | ||
|
194ccbdbb4 | ||
|
7139e230cf | ||
|
120d3b9f54 | ||
|
712460a040 | ||
|
be5594f1c9 | ||
|
0a885117db | ||
|
5b73654544 | ||
|
1485b78b7b | ||
|
bfec57172a | ||
|
9f8559c12d | ||
|
83dde12ca9 | ||
|
d17888db4e | ||
|
096529af96 | ||
|
8a56b22635 | ||
|
dcdd353981 |
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"files": [
|
||||
"README.md"
|
||||
],
|
||||
"imageSize": 100,
|
||||
"commit": false,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "antonioag95",
|
||||
"name": "antonioag95",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/30556866?v=4",
|
||||
"profile": "https://github.com/antonioag95",
|
||||
"contributions": [
|
||||
"test",
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "tonjo",
|
||||
"name": "tonjo",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/4726289?v=4",
|
||||
"profile": "https://github.com/tonjo",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "reafian",
|
||||
"name": "Richard Newton",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/11992416?v=4",
|
||||
"profile": "https://github.com/reafian",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "davejlong",
|
||||
"name": "David Long",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/175317?v=4",
|
||||
"profile": "http://www.davejlong.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "marneu",
|
||||
"name": "Markus Neubauer",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/5978293?v=4",
|
||||
"profile": "http://www.std-soft.com",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
"projectName": "WGDashboard",
|
||||
"projectOwner": "donaldzou",
|
||||
"repoType": "github",
|
||||
"repoHost": "https://github.com",
|
||||
"skipCi": true
|
||||
}
|
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
tests/
|
||||
docs/
|
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [donaldzou]
|
||||
patreon: DonaldDonnyZou
|
||||
|
31
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/src"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/src/static/app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "docker-compose"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "weekly"
|
71
.github/workflows/codeql-analyze.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 5 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'python' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
116
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- '*'
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
env:
|
||||
DOCKERHUB_PREFIX: docker.io
|
||||
GITHUB_CONTAINER_PREFIX: ghcr.io
|
||||
DOCKER_IMAGE: WGDashboard
|
||||
|
||||
jobs:
|
||||
docker_build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_PREFIX }}
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_CONTAINER_PREFIX }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: |
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/v7
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta by docs https://github.com/docker/metadata-action
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_PREFIX }}/donaldzou/${{ env.DOCKER_IMAGE }}
|
||||
${{ env.GITHUB_CONTAINER_PREFIX }}/${{ github.repository_owner }}/${{ env.DOCKER_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=sha,format=short,prefix=
|
||||
|
||||
- name: Build and export (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
||||
docker_scan:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: docker_build
|
||||
steps:
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKERHUB_PREFIX }}
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GITHUB_CONTAINER_PREFIX }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Scout CVEs
|
||||
uses: docker/scout-action@v1
|
||||
with:
|
||||
command: cves
|
||||
image: ${{ env.GITHUB_CONTAINER_PREFIX }}/${{ github.repository_owner }}/${{ env.DOCKER_IMAGE }}:main
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
write-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
exit-code: true
|
||||
|
||||
- name: Docker Scout Compare
|
||||
uses: docker/scout-action@v1
|
||||
with:
|
||||
command: compare
|
||||
# Set to Github for maximum compat
|
||||
image: ${{ env.GITHUB_CONTAINER_PREFIX }}/${{ github.repository_owner }}/${{ env.DOCKER_IMAGE }}:main
|
||||
to: ${{ env.GITHUB_CONTAINER_PREFIX }}/${{ github.repository_owner }}/${{ env.DOCKER_IMAGE }}:latest
|
||||
only-severities: critical,high
|
||||
ignore-unchanged: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
26
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '00 08 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: 'This issue has not been updated for 20 days'
|
||||
stale-pr-message: 'This pull request has not been updated for 20 days'
|
||||
stale-issue-label: 'stale'
|
||||
exempt-issue-labels: 'enhancement,ongoing'
|
||||
days-before-stale: 20
|
46
.gitignore
vendored
@@ -1,12 +1,10 @@
|
||||
.vscode/sftp.json
|
||||
src/.vscode/sftp.json
|
||||
.vscode
|
||||
.DS_Store
|
||||
wg.db
|
||||
*.json
|
||||
.idea
|
||||
src/test.py
|
||||
tmp
|
||||
src/db
|
||||
__pycache__
|
||||
src/test.py
|
||||
*.db
|
||||
src/wg-dashboard.ini
|
||||
src/static/pic.xd
|
||||
*.conf
|
||||
@@ -15,6 +13,40 @@ public_key.txt
|
||||
venv/**
|
||||
log/**
|
||||
release/*
|
||||
*.db
|
||||
src/db/wgdashboard.db
|
||||
.jshintrc
|
||||
node_modules/**
|
||||
*/proxy.js
|
||||
src/static/app/proxy.js
|
||||
.secrets
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.vite/*
|
569
README.md
@@ -1,499 +1,88 @@
|
||||
<p align="center">
|
||||
<img alt="WGDashboard" src="img/logo.png" width="128">
|
||||
</p>
|
||||
<h1 align="center">WGDashboard</h1>
|
||||
> [!TIP]
|
||||
> 🎉 I'm excited to announce that WGDashboard is officially listed on DigitalOcean's Marketplace! For more information, please visit [Host WGDashboard & WireGuard with DigitalOcean](https://docs.wgdashboard.dev/host-wgdashboard-wireguard-with-digitalocean.html) for more information!
|
||||
|
||||
> [!NOTE]
|
||||
> **Help Wanted 🎉**: Localizing WGDashboard to other languages! If you're willing to help, please visit https://github.com/WGDashboard/WGDashboard/issues/397. Many thanks!
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="http://ForTheBadge.com/images/badges/made-with-python.svg">
|
||||
<img alt="WGDashboard" src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Logos/Logo-2-Rounded-512x512.png" width="128">
|
||||
</p>
|
||||
<h1 align="center">
|
||||
<a href="https://wgdashboard.dev">WGDashboard</a>
|
||||
</h1>
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Made_With-Python-blue?style=for-the-badge&logo=python&logoColor=ffffff">
|
||||
<img src="https://img.shields.io/badge/Made_With-Vue.js-42b883?style=for-the-badge&logo=vuedotjs&logoColor=ffffff">
|
||||
<img src="https://img.shields.io/badge/License-Apache_License_2.0-D22128?style=for-the-badge&logo=apache&logoColor=ffffff">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/WGDashboard/WGDashboard/releases/latest"><img src="https://img.shields.io/github/v/release/donaldzou/wireguard-dashboard?style=for-the-badge"></a>
|
||||
<a href="https://wakatime.com/badge/github/donaldzou/WGDashboard"><img src="https://wakatime.com/badge/github/donaldzou/WGDashboard.svg?style=for-the-badge" alt="wakatime"></a>
|
||||
<a href="https://hitscounter.dev"><img src="https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fdonaldzou%2FWGDashboard&label=Visitor&icon=github&color=%230a58ca&style=for-the-badge"></a>
|
||||
<img src="https://img.shields.io/docker/pulls/donaldzou/wgdashboard?logo=docker&label=Docker%20Image%20Pulls&labelColor=ffffff&style=for-the-badge">
|
||||
<img src="https://github.com/WGDashboard/WGDashboard/actions/workflows/docker.yml/badge.svg?style=for-the-badge">
|
||||
<img src="https://github.com/WGDashboard/WGDashboard/actions/workflows/codeql-analyze.yaml/badge.svg">
|
||||
</p>
|
||||
<p align="center"><b>This project is supported by</b></p>
|
||||
<p align="center">
|
||||
<a href="https://m.do.co/c/a84cb9aac585">
|
||||
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">Monitoring WireGuard is not convenient, in most case, you'll need to login to your server and type <code>wg show</code>. That's why this project is being created, to view and manage all WireGuard configurations in a easy way.</p>
|
||||
<p align="center">With all these awesome features, while keeping it <b>easy to install and use</b></p>
|
||||
|
||||
<p align="center"><b><i>This project is not affiliate to the official WireGuard Project</i></b></p>
|
||||
|
||||
<h3 align="center">Looking for help or want to chat about this project?</h4>
|
||||
<p align="center">
|
||||
You can reach out at
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/donaldzou/wireguard-dashboard/releases/latest"><img src="https://img.shields.io/github/v/release/donaldzou/wireguard-dashboard"></a>
|
||||
<a href="https://wakatime.com/badge/user/45f53c7c-9da9-4cb0-85d6-17bd38cc748b/project/5334ae20-e9a6-4c55-9fea-52d4eb9dfba6"><img src="https://wakatime.com/badge/user/45f53c7c-9da9-4cb0-85d6-17bd38cc748b/project/5334ae20-e9a6-4c55-9fea-52d4eb9dfba6.svg" alt="wakatime"></a>
|
||||
<a align="center" href="https://discord.gg/72TwzjeuWm" target="_blank"><img src="https://img.shields.io/discord/1276818723637956628?labelColor=ffffff&style=for-the-badge&logo=discord&label=Discord"></a>
|
||||
<a align="center" href="https://www.reddit.com/r/WGDashboard/" target="_blank"><img src="https://img.shields.io/badge/Reddit-r%2FWGDashboard-FF4500?style=for-the-badge&logo=reddit"></a>
|
||||
<a align="center" href="https://app.element.io/#/room/#wgd:matrix.org" target="_blank"><img src="https://img.shields.io/badge/Matrix_Chatroom-%23WGD-000000?style=for-the-badge&logo=matrix"></a>
|
||||
</p>
|
||||
<h3 align="center">Want to support this project?</h4>
|
||||
<p align="center">
|
||||
You can support via <br>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a align="center" href="https://github.com/sponsors/donaldzou" target="_blank"><img src="https://img.shields.io/badge/GitHub%20Sponsor-2e9a40?style=for-the-badge&logo=github"></a>
|
||||
<a align="center" href="https://buymeacoffee.com/donaldzou" target="_blank"><img src="https://img.shields.io/badge/Buy%20me%20a%20coffee-ffdd00?style=for-the-badge&logo=buymeacoffee&logoColor=000000"></a>
|
||||
<a align="center" href="https://patreon.com/c/DonaldDonnyZou/membership" target="_blank"><img src="https://img.shields.io/badge/Patreon-000000?style=for-the-badge&logo=patreon&logoColor=ffffff"></a>
|
||||
</p>
|
||||
<p align="center">Monitoring WireGuard is not convinient, need to login into server and type <code>wg show</code>. That's why this platform is being created, to view all configurations and manage them in a easier way.</p>
|
||||
<p align="center"><small>Note: This project is not affiliate to the official WireGuard Project ;)</small></p>
|
||||
|
||||
## 📣 What's New: v3.0
|
||||
|
||||
- 🎉 **New Features**
|
||||
- **Moved from TinyDB to SQLite**: SQLite provide a better performance and loading speed when getting peers! Also avoided crashing the database due to **race condition**.
|
||||
- **Added Gunicorn WSGI Server**: This could provide more stable on handling HTTP request, and more flexibility in the future (such as HTTPS support). **BIG THANKS to @pgalonza :heart: **
|
||||
- **Add Peers by Bulk: ** User can add peers by bulk, just simply set the amount and click add.
|
||||
- **Delete Peers by Bulk**: User can delete peers by bulk, without deleting peers one by one.
|
||||
- **Download Peers in Zip**: User can download all *downloadable* peers in a zip.
|
||||
- **Added Pre-shared Key to peers:** Now each peer can add with a pre-shared key to enhance security. Previously added peers can add the pre-shared key through the peer setting button.
|
||||
- **Redirect Back to Previous Page:** The dashboard will now redirect you back to your previous page if the current session got timed out and you need to sign in again.
|
||||
|
||||
- 🪚 **Bug Fixed**
|
||||
- [IP Sorting range issues #99](https://github.com/donaldzou/WGDashboard/issues/99) [❤️ @barryboom]
|
||||
- [INvalid character written to tunnel json file #108](https://github.com/donaldzou/WGDashboard/issues/108) [❤️ @ ikidd]
|
||||
- [Add IPv6 #91](https://github.com/donaldzou/WGDashboard/pull/91) [❤️ @ pgalonza]
|
||||
- [Added MTU and PersistentKeepalive to QR code and download files #112](https://github.com/donaldzou/WGDashboard/pull/112) [:heart: @reafian]
|
||||
- **And many other bugs provided by our beloved users** :heart:
|
||||
- **🧐 Other Changes**
|
||||
- **Key generating moved to front-end**: No longer need to use the server's WireGuard to generate keys, thanks to the `wireguard.js` from the [official repository](https://git.zx2c4.com/wireguard-tools/tree/contrib/keygen-html/wireguard.js)!
|
||||
- **Peer transfer calculation**: each peer will now show all transfer amount (previously was only showing transfer amount from the last configuration start-up).
|
||||
- **UI adjustment on running peers**: peers will have a new style indicating that it is running.
|
||||
- **`wgd.sh` finally can update itself**: So now user could update the whole dashboard from `wgd.sh`, with the `update` command.
|
||||
- **Minified JS and CSS files**: Although only a small changes on the file size, but I think is still a good practice to save a bit of bandwidth ;)
|
||||
|
||||
*And many other small changes for performance and bug fixes! :laughing:*
|
||||
|
||||
> If you have any other brilliant ideas for this project, please shout it in here [#129](https://github.com/donaldzou/WGDashboard/issues/129) :heart:
|
||||
<p align="center">
|
||||
<b>or, visit our merch store and support us by purchasing a merch for only $USD 17.00 (Including shipping worldwide & duties)</b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a align="center" href="https://merch.wgdashboard.dev" target="_blank"><img src="https://img.shields.io/badge/Merch%20from%20WGDashboard-926183?style=for-the-badge"></a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
## Table of Content
|
||||
|
||||
|
||||
- [💡 Features](#-features)
|
||||
- [📝 Requirement](#-requirement)
|
||||
- [🛠 Install](#-install)
|
||||
- [🪜 Usage](#-usage)
|
||||
- [Start/Stop/Restart WGDashboard](#startstoprestart-wgdashboard)
|
||||
- [Autostart WGDashboard on boot](#autostart-wgdashboard-on-boot--v22)
|
||||
- [✂️ Dashboard Configuration](#%EF%B8%8F-dashboard-configuration)
|
||||
- [Dashboard Configuration file](#dashboard-configuration-file)
|
||||
- [Generating QR code and peer configuration file (.conf)](#generating-qr-code-and-peer-configuration-file-conf)
|
||||
- [❓ How to update the dashboard?](#-how-to-update-the-dashboard)
|
||||
- [🔍 Screenshot](#-screenshot)
|
||||
- [⏰ Changelog](#--changelog)
|
||||
- [🛒 Dependencies](#-dependencies)
|
||||
- [✨ Contributors](#-contributors)
|
||||
|
||||
## 💡 Features
|
||||
|
||||
- **No need to re-configure existing WireGuard configuration! It can search for existed configuration files.**
|
||||
- Easy to use interface, provided username and password protection to the dashboard
|
||||
- Add peers and edit (Allowed IPs, DNS, Private Key...)
|
||||
- View peers and configuration real time details (Data Usage, Latest Handshakes...)
|
||||
- Share your peer configuration with QR code or file download
|
||||
- Testing tool: Ping and Traceroute to your peer's ip
|
||||
- **And more functions are coming up!**
|
||||
|
||||
|
||||
## 📝 Requirement
|
||||
|
||||
- Recommend the following OS, tested by our beloved users:
|
||||
- [x] Ubuntu 18.04.1 LTS - 20.04.1 LTS [@Me]
|
||||
- [x] Debian GNU/Linux 10 (buster) [❤️ @[robchez](https://github.com/robchez)]
|
||||
- [x] AlmaLinux 8.4 (Electric Cheetah) [❤️ @[barry-smithjr](https://github.com/)]
|
||||
- [x] CentOS 7 [❤️ @[PrzemekSkw](https://github.com/PrzemekSkw)]
|
||||
|
||||
> **If you have tested on other OS and it works perfectly please provide it to me in [#31](https://github.com/donaldzou/wireguard-dashboard/issues/31). Thank you!**
|
||||
|
||||
- **WireGuard** and **WireGuard-Tools (`wg-quick`)** are installed.
|
||||
|
||||
> Don't know how? Check this <a href="https://www.wireguard.com/install/">official documentation</a>
|
||||
|
||||
- Configuration files under **`/etc/wireguard`**, but please note the following sample
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
...
|
||||
SaveConfig = true
|
||||
# Need to include this line to allow WireGuard Tool to save your configuration,
|
||||
# or if you just want it to monitor your WireGuard Interface and don't need to
|
||||
# make any changes with the dashboard, you can set it to false.
|
||||
|
||||
[Peer]
|
||||
PublicKey = abcd1234
|
||||
AllowedIPs = 1.2.3.4/32
|
||||
# Must have for each peer
|
||||
```
|
||||
|
||||
- Python 3.7+ & Pip3
|
||||
|
||||
- Browser support CSS3 and ES6
|
||||
|
||||
## 🛠 Install
|
||||
1. Download WGDashboard
|
||||
|
||||
```shell
|
||||
git clone -b v3.0 https://github.com/donaldzou/WGDashboard.git wgdashboard
|
||||
|
||||
2. Open the WGDashboard folder
|
||||
|
||||
```shell
|
||||
cd wgdashboard/src
|
||||
```
|
||||
|
||||
3. Install WGDashboard
|
||||
|
||||
```shell
|
||||
sudo chmod u+x wgd.sh
|
||||
sudo ./wgd.sh install
|
||||
```
|
||||
|
||||
4. Give read and execute permission to root of the WireGuard configuration folder, you can change the path if your configuration files are not stored in `/etc/wireguard`
|
||||
|
||||
```shell
|
||||
sudo chmod -R 755 /etc/wireguard
|
||||
```
|
||||
|
||||
5. Run WGDashboard
|
||||
|
||||
```shell
|
||||
./wgd.sh start
|
||||
```
|
||||
|
||||
**Note**:
|
||||
|
||||
> For [`pivpn`](https://github.com/pivpn/pivpn) user, please use `sudo ./wgd.sh start` to run if your current account does not have the permission to run `wg show` and `wg-quick`.
|
||||
|
||||
6. Access dashboard
|
||||
|
||||
Access your server with port `10086` (e.g. http://your_server_ip:10086), using username `admin` and password `admin`. See below how to change port and ip that the dashboard is running with.
|
||||
|
||||
## 🪜 Usage
|
||||
|
||||
#### Start/Stop/Restart WGDashboard
|
||||
|
||||
|
||||
```shell
|
||||
cd wgdashboard/src
|
||||
-----------------------------
|
||||
./wgd.sh start # Start the dashboard in background
|
||||
-----------------------------
|
||||
./wgd.sh debug # Start the dashboard in foreground (debug mode)
|
||||
-----------------------------
|
||||
./wgd.sh stop # Stop the dashboard
|
||||
-----------------------------
|
||||
./wgd.sh restart # Restart the dasboard
|
||||
```
|
||||
|
||||
#### Autostart WGDashboard on boot (>= v2.2)
|
||||
|
||||
In the `src` folder, it contained a file called `wg-dashboard.service`, we can use this file to let our system to autostart the dashboard after reboot. The following guide has tested on **Ubuntu**, most **Debian** based OS might be the same, but some might not. Please don't hesitate to provide your system if you have tested the autostart on another system.
|
||||
|
||||
1. Changing the directory to the dashboard's directory
|
||||
|
||||
```shell
|
||||
cd wgdashboard/src
|
||||
```
|
||||
|
||||
2. Get the full path of the dashboard's directory
|
||||
|
||||
```shell
|
||||
pwd
|
||||
#Output: /root/wgdashboard/src
|
||||
```
|
||||
|
||||
For this example, the output is `/root/wireguard-dashboard/src`, your path might be different since it depends on where you downloaded the dashboard in the first place. **Copy the the output to somewhere, we will need this in the next step.**
|
||||
|
||||
3. Edit the service file, the service file is located in `wireguard-dashboard/src`, you can use other editor you like, here will be using `nano`
|
||||
|
||||
```shell
|
||||
nano wg-dashboard.service
|
||||
```
|
||||
|
||||
You will see something like this:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
After=network.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=<your dashboard directory full path here>
|
||||
ExecStart=/usr/bin/python3 <your dashboard directory full path here>/dashboard.py
|
||||
Restart=always
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
Now, we need to replace both `<your dashboard directory full path here>` to the one you just copied from step 2. After doing this, the file will become something like this, your file might be different:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
After=netword.service
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/root/wgdashboard/src
|
||||
ExecStart=/usr/bin/python3 /root/wgdashboard/src/dashboard.py
|
||||
Restart=always
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
**Be aware that after the value of `WorkingDirectory`, it does not have a `/` (slash).** And then save the file after you edited it
|
||||
|
||||
4. Copy the service file to systemd folder
|
||||
|
||||
```bash
|
||||
$ cp wg-dashboard.service /etc/systemd/system/wg-dashboard.service
|
||||
```
|
||||
|
||||
To make sure you copy the file successfully, you can use this command `cat /etc/systemd/system/wg-dashboard.service` to see if it will output the file you just edited.
|
||||
|
||||
5. Enable the service
|
||||
|
||||
```bash
|
||||
$ sudo chmod 664 /etc/systemd/system/wg-dashboard.service
|
||||
$ sudo systemctl daemon-reload
|
||||
$ sudo systemctl enable wg-dashboard.service
|
||||
$ sudo systemctl start wg-dashboard.service # <-- To start the service
|
||||
```
|
||||
|
||||
6. Check if the service run correctly
|
||||
|
||||
```bash
|
||||
$ sudo systemctl status wg-dashboard.service
|
||||
```
|
||||
|
||||
And you should see something like this
|
||||
|
||||
```shell
|
||||
● wg-dashboard.service
|
||||
Loaded: loaded (/etc/systemd/system/wg-dashboard.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Tue 2021-08-03 22:31:26 UTC; 4s ago
|
||||
Main PID: 6602 (python3)
|
||||
Tasks: 1 (limit: 453)
|
||||
Memory: 26.1M
|
||||
CGroup: /system.slice/wg-dashboard.service
|
||||
└─6602 /usr/bin/python3 /root/wgdashboard/src/dashboard.py
|
||||
|
||||
Aug 03 22:31:26 ubuntu-wg systemd[1]: Started wg-dashboard.service.
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: * Serving Flask app "WGDashboard" (lazy loading)
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: * Environment: production
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: WARNING: This is a development server. Do not use it in a production deployment.
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: Use a production WSGI server instead.
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: * Debug mode: off
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: * Running on all addresses.
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: WARNING: This is a development server. Do not use it in a production deployment.
|
||||
Aug 03 22:31:27 ubuntu-wg python3[6602]: * Running on http://0.0.0.0:10086/ (Press CTRL+C to quit)
|
||||
```
|
||||
|
||||
If you see `Active:` followed by `active (running) since...` then it means it run correctly.
|
||||
|
||||
7. Stop/Start/Restart the service
|
||||
|
||||
```bash
|
||||
sudo systemctl stop wg-dashboard.service # <-- To stop the service
|
||||
sudo systemctl start wg-dashboard.service # <-- To start the service
|
||||
sudo systemctl restart wg-dashboard.service # <-- To restart the service
|
||||
```
|
||||
|
||||
8. **And now you can reboot your system, and use the command at step 6 to see if it will auto start after the reboot, or just simply access the dashboard through your browser. If you have any questions or problem, please report it in the issue page.**
|
||||
|
||||
## ✂️ Dashboard Configuration
|
||||
|
||||
#### Dashboard Configuration file
|
||||
|
||||
Since version 2.0, WGDashboard will be using a configuration file called `wg-dashboard.ini`, (It will generate automatically after first time running the dashboard). More options will include in future versions, and for now it included the following configurations:
|
||||
|
||||
| | Description | Default | Edit Available |
|
||||
| ---------------------------- | ------------------------------------------------------------ | ---------------------------------------------------- | -------------- |
|
||||
| **`[Account]`** | *Configuration on account* | | |
|
||||
| `username` | Dashboard login username | `admin` | Yes |
|
||||
| `password` | Password, will be hash with SHA256 | `admin` hashed in SHA256 | Yes |
|
||||
| | | | |
|
||||
| **`[Server]`** | *Configuration on dashboard* | | |
|
||||
| `wg_conf_path` | The path of all the Wireguard configurations | `/etc/wireguard` | Yes |
|
||||
| `app_ip` | IP address the dashboard will run with | `0.0.0.0` | Yes |
|
||||
| `app_port` | Port the the dashboard will run with | `10086` | Yes |
|
||||
| `auth_req` | Does the dashboard need authentication to access, if `auth_req = false` , user will not be access the **Setting** tab due to security consideration. **User can only edit the file directly in system**. | `true` | **No** |
|
||||
| `version` | Dashboard Version | `v3.0` | **No** |
|
||||
| `dashboard_refresh_interval` | How frequent the dashboard will refresh on the configuration page | `60000ms` | Yes |
|
||||
| `dashboard_sort` | How configuration is sorting | `status` | Yes |
|
||||
| | | | |
|
||||
| **`[Peers]`** | *Default Settings on a new peer* | | |
|
||||
| `peer_global_dns` | DNS Server | `1.1.1.1` | Yes |
|
||||
| `peer_endpoint_allowed_ip` | Endpoint Allowed IP | `0.0.0.0/0` | Yes |
|
||||
| `peer_display_mode` | How peer will display | `grid` | Yes |
|
||||
| `remote_endpoint` | Remote Endpoint (i.e where your peers will connect to) | *depends on your server's default network interface* | Yes |
|
||||
| `peer_mtu` | Maximum Transmit Unit | `1420` | |
|
||||
| `peer_keep_alive` | Keep Alive | `21` | Yes |
|
||||
|
||||
#### Generating QR code and peer configuration file (.conf)
|
||||
|
||||
Starting version 2.2, dashboard can now generate QR code and configuration file for each peer. Here is a template of what each QR code encoded with and the same content will be inside the file:
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
PrivateKey = QWERTYUIOPO234567890YUSDAKFH10E1B12JE129U21=
|
||||
Address = 0.0.0.0/32
|
||||
DNS = 1.1.1.1
|
||||
|
||||
[Peer]
|
||||
PublicKey = QWERTYUIOPO234567890YUSDAKFH10E1B12JE129U21=
|
||||
AllowedIPs = 0.0.0.0/0
|
||||
Endpoint = 0.0.0.0:51820
|
||||
```
|
||||
|
||||
| | Description | Default Value | Available in Peer setting |
|
||||
| ----------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------- |
|
||||
| **`[Interface]`** | | | |
|
||||
| `PrivateKey` | The private key of this peer | Private key generated by WireGuard (`wg genkey`) or provided by user | Yes |
|
||||
| `Address` | The `allowed_ips` of your peer | N/A | Yes |
|
||||
| `DNS` | The DNS server your peer will use | `1.1.1.1` - Cloud flare DNS, you can change it when you adding the peer or in the peer setting. | Yes |
|
||||
| **`[Peer]`** | | | |
|
||||
| `PublicKey` | The public key of your server | N/A | No |
|
||||
| `AllowedIPs` | IP ranges for which a peer will route traffic | `0.0.0.0/0` - Indicated a default route to send all internet and VPN traffic through that peer. | Yes |
|
||||
| `Endpoint` | Your wireguard server ip and port, the dashboard will search for your server's default interface's ip. | `<your server default interface ip>:<listen port>` | Yes |
|
||||
|
||||
## ❓ How to update the dashboard?
|
||||
|
||||
#### **Please note for user who is using `v2.3.1` or below**
|
||||
|
||||
- For user who is using `v2.3.1` or below, please notice that all data that stored in the current database will **not** transfer to the new database. This is hard decision to move from TinyDB to SQLite. But SQLite does provide a thread-safe access and TinyDB doesn't. I couldn't find a safe way to transfer the data, so you need to do them manually... Sorry about that :pensive: . But I guess this would be a great start for future development :sunglasses:.
|
||||
|
||||
<hr>
|
||||
|
||||
1. Change your directory to `wgdashboard`
|
||||
|
||||
```shell
|
||||
cd wgdashboard
|
||||
```
|
||||
|
||||
2. Update the dashboard
|
||||
```shell
|
||||
git pull https://github.com/donaldzou/WGDashboard.git v3.0 --force
|
||||
```
|
||||
|
||||
Starting with `v3.0`, you can simply do `./wgd.sh update` !! (I hope, lol)
|
||||
|
||||
## 🔍 Screenshot
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## ⏰ Changelog
|
||||
|
||||
#### v2.3.1 - Sep 8, 2021
|
||||
|
||||
- Updated dashboard's name to **WGDashboard**!!
|
||||
|
||||
#### v2.3 - Sep 8, 2021
|
||||
|
||||
- 🎉 **New Features**
|
||||
- **Update directly from `wgd.sh`:** Now you can update WGDashboard directly from the bash script.
|
||||
- **Displaying Peers:** You can switch the display mode between list and table in the configuration page.
|
||||
- 🪚 **Bug Fixed**
|
||||
- [Peer DNS Validation Fails #67](issues/67): Added DNS format check. [❤️ @realfian]
|
||||
- [configparser.NoSectionError: No section: 'Interface' #66](issues/66): Changed permission requirement for `etc/wireguard` from `744` to `755`. [❤️ @ramalmaty]
|
||||
- [Feature request: Interface not loading when information missing #73](issues/73): Fixed when Configuration Address and Listen Port is missing will crash the dashboard. [❤️ @js32]
|
||||
- [Remote Peer, MTU and PersistentKeepalives added #70](pull/70): Added MTU, remote peer and Persistent Keepalive. [❤️ @realfian]
|
||||
- [Fixes DNS check to support search domain #65](pull/65): Added allow input domain into DNS. [❤️@davejlong]
|
||||
- **🧐 Other Changes**
|
||||
- Moved Add Peer Button into the right bottom corner.
|
||||
|
||||
#### v2.2.1 - Aug 16, 2021
|
||||
|
||||
Bug Fixed:
|
||||
- Added support for full subnet on Allowed IP
|
||||
- Peer setting Save button
|
||||
|
||||
#### v2.2 - Aug 14, 2021
|
||||
|
||||
- 🎉 **New Features**
|
||||
- **Add new peers**: Now you can add peers directly on dashboard, it will generate a pair of private key and public key. You can also set its DNS, endpoint allowed IPs. Both can set a default value in the setting page. [❤️ in [#44](https://github.com/donaldzou/wireguard-dashboard/issues/44)]
|
||||
- **QR Code:** You can add the private key in peer setting of your existed peer to create a QR code. Or just create a new one, dashboard will now be able to auto generate a private key and public key ;) Don't worry, all keys will be generated on your machine, and **will delete all key files after they got generated**. [❤️ in [#29](https://github.com/donaldzou/wireguard-dashboard/issues/29)]
|
||||
- **Peer configuration file download:** Same as QR code, you now can download the peer configuration file, so you don't need to manually input all the details on the peer machine! [❤️ in [#40](https://github.com/donaldzou/wireguard-dashboard/issues/40)]
|
||||
- **Search peers**: You can now search peers by their name.
|
||||
- **Autostart on boot:** Added a tutorial on how to start the dashboard to on boot! Please read the [tutorial below](#autostart-wireguard-dashboard-on-boot). [❤️ in [#29](https://github.com/donaldzou/wireguard-dashboard/issues/29)]
|
||||
- **Click to copy**: You can now click and copy all peer's public key and configuration's public key.
|
||||
- ....
|
||||
- 🪚 **Bug Fixed**
|
||||
- When there are comments in the wireguard config file, will cause the dashboard to crash.
|
||||
- Used regex to search for config files.
|
||||
- **🧐 Other Changes**
|
||||
- Moved all external CSS and JavaScript file to local hosting (Except Bootstrap Icon, due to large amount of SVG files).
|
||||
- Updated Python dependencies
|
||||
- Flask: `v1.1.2 => v2.0.1`
|
||||
- Jinja: `v2.10.1 => v3.0.1`
|
||||
- icmplib: `v2.1.1 => v3.0.1`
|
||||
- Updated CSS/JS dependencies
|
||||
- Bootstrap: `v4.5.3 => v4.6.0`
|
||||
- UI adjustment
|
||||
- Adjusted how peers will display in larger screens, used to be 1 row per peer, now is 3 peers in 1 row.
|
||||
|
||||
#### v2.1 - Jul 2, 2021
|
||||
|
||||
- Added **Ping** and **Traceroute** tools!
|
||||
- Adjusted the calculation of data usage on each peers
|
||||
- Added refresh interval of the dashboard
|
||||
- Bug fixed when no configuration on fresh install ([#23](https://github.com/donaldzou/wireguard-dashboard/issues/23))
|
||||
- Fixed crash when too many peers ([#22](https://github.com/donaldzou/wireguard-dashboard/issues/22))
|
||||
|
||||
#### v2.0 - May 5, 2021
|
||||
|
||||
- Added login function to dashboard
|
||||
- ***I'm not using the most ideal way to store the username and password, feel free to provide a better way to do this if you any good idea!***
|
||||
- Added a config file to the dashboard
|
||||
- Dashboard config can be change within the **Setting** tab on the side bar
|
||||
- Adjusted UI
|
||||
- And much more!
|
||||
|
||||
#### v1.1.2 - Apr 3, 2021
|
||||
|
||||
- Resolved issue [#3](https://github.com/donaldzou/wireguard-dashboard/issues/3).
|
||||
|
||||
#### v1.1.1 - Apr 2, 2021
|
||||
|
||||
- Able to add a friendly name to each peer. Thanks [#2](https://github.com/donaldzou/wireguard-dashboard/issues/2) !
|
||||
|
||||
#### v1.0 - Dec 27, 2020
|
||||
|
||||
- Added the function to remove peers
|
||||
|
||||
## 🛒 Dependencies
|
||||
|
||||
- CSS/JS
|
||||
- [Bootstrap](https://getbootstrap.com/docs/4.6/getting-started/introduction/) `v4.6.0`
|
||||
- [Bootstrap Icon](https://icons.getbootstrap.com) `v1.4.0`
|
||||
- [jQuery](https://jquery.com) `v3.5.1`
|
||||
- Python
|
||||
- [Flask](https://pypi.org/project/Flask/) `v2.0.1`
|
||||
- [TinyDB](https://pypi.org/project/tinydb/) `v4.3.0`
|
||||
- [ifcfg](https://pypi.org/project/ifcfg/) `v0.21`
|
||||
- [icmplib](https://pypi.org/project/icmplib/) `v2.1.1`
|
||||
- [flask-qrcode](https://pypi.org/project/Flask-QRcode/) `v3.0.0`
|
||||
|
||||
## ✨ Contributors
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
|
||||
<!-- prettier-ignore-start -->
|
||||
<!-- markdownlint-disable -->
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><a href="https://github.com/antonioag95"><img src="https://avatars.githubusercontent.com/u/30556866?v=4?s=100" width="100px;" alt=""/><br /><sub><b>antonioag95</b></sub></a><br /><a href="https://github.com/donaldzou/WGDashboard/commits?author=antonioag95" title="Tests">⚠️</a> <a href="https://github.com/donaldzou/WGDashboard/commits?author=antonioag95" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/tonjo"><img src="https://avatars.githubusercontent.com/u/4726289?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tonjo</b></sub></a><br /><a href="https://github.com/donaldzou/WGDashboard/commits?author=tonjo" title="Code">💻</a></td>
|
||||
<td align="center"><a href="https://github.com/reafian"><img src="https://avatars.githubusercontent.com/u/11992416?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Richard Newton</b></sub></a><br /><a href="https://github.com/donaldzou/WGDashboard/commits?author=reafian" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://www.davejlong.com"><img src="https://avatars.githubusercontent.com/u/175317?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Long</b></sub></a><br /><a href="https://github.com/donaldzou/WGDashboard/commits?author=davejlong" title="Code">💻</a></td>
|
||||
<td align="center"><a href="http://www.std-soft.com"><img src="https://avatars.githubusercontent.com/u/5978293?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Markus Neubauer</b></sub></a><br /><a href="https://github.com/donaldzou/WGDashboard/commits?author=marneu" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-restore -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
<h4 align="center">
|
||||
for more information, visit our
|
||||
</h4>
|
||||
<h1 align="center">
|
||||
<a href="https://wgdashboard.dev">Official Website</a>
|
||||
</h1>
|
||||
|
||||
|
||||
# Screenshots
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/sign-in.png" alt=""/>
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/cross-server.png" alt=""/>
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/index.png" alt=""/>
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/new-configuration.png" alt="" />
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/settings.png" alt="" />
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/light-dark.png" alt="" />
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/configuration.png" alt=""/>
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/add-peers.png" alt="" />
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/ping.png" alt=""/>
|
||||
<img src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/traceroute.png" alt=""/>
|
||||
|
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 5.1.x | :white_check_mark: |
|
||||
| 5.0.x | :x: |
|
||||
| 4.0.x | :white_check_mark: |
|
||||
| < 4.0 | :x: |
|
76
assets/legacy/Dockerfile-alpine-old
Normal file
@@ -0,0 +1,76 @@
|
||||
FROM golang:1.24 AS awg-go
|
||||
|
||||
RUN git clone https://github.com/WGDashboard/amneziawg-go /awg
|
||||
WORKDIR /awg
|
||||
RUN go mod download && \
|
||||
go mod verify && \
|
||||
go build -ldflags '-linkmode external -extldflags "-fno-PIC -static"' -v -o /usr/bin
|
||||
|
||||
FROM alpine:latest AS awg-tools
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
make git build-base linux-headers \
|
||||
&& git clone https://github.com/WGDashboard/amneziawg-tools \
|
||||
&& cd amneziawg-tools/src \
|
||||
&& make \
|
||||
&& chmod +x wg*
|
||||
|
||||
FROM alpine:latest
|
||||
LABEL maintainer="dselen@nerthus.nl"
|
||||
|
||||
RUN apk update && apk add --no-cache \
|
||||
iproute2 iptables bash curl wget unzip procps sudo \
|
||||
tzdata wireguard-tools python3 py3-psutil py3-bcrypt openresolv
|
||||
|
||||
COPY --from=awg-go /usr/bin/amneziawg-go /usr/bin/amneziawg-go
|
||||
COPY --from=awg-tools /amneziawg-tools/src/wg /usr/bin/awg
|
||||
COPY --from=awg-tools /amneziawg-tools/src/wg-quick/linux.bash /usr/bin/awg-quick
|
||||
|
||||
# Declaring environment variables, change Peernet to an address you like, standard is a 24 bit subnet.
|
||||
ARG wg_net="10.0.0.1" \
|
||||
wg_port="51820"
|
||||
|
||||
# Following ENV variables are changable on container runtime because /entrypoint.sh handles that. See compose.yaml for more info.
|
||||
ENV TZ="Europe/Amsterdam" \
|
||||
global_dns="9.9.9.9" \
|
||||
wgd_port="10086" \
|
||||
public_ip=""
|
||||
|
||||
# Using WGDASH -- like wg_net functionally as a ARG command. But it is needed in entrypoint.sh so it needs to be exported as environment variable.
|
||||
ENV WGDASH=/opt/wgdashboard
|
||||
|
||||
# Doing WireGuard Dashboard installation measures. Modify the git clone command to get the preferred version, with a specific branch for example.
|
||||
RUN mkdir /data \
|
||||
&& mkdir /configs \
|
||||
&& mkdir -p ${WGDASH}/src \
|
||||
&& mkdir -p /etc/amnezia/amneziawg
|
||||
COPY ./src ${WGDASH}/src
|
||||
|
||||
# Generate basic WireGuard interface. Echoing the WireGuard interface config for readability, adjust if you want it for efficiency.
|
||||
# Also setting the pipefail option, verbose: https://github.com/hadolint/hadolint/wiki/DL4006.
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
RUN out_adapt=$(ip -o -4 route show to default | awk '{print $NF}') \
|
||||
&& echo -e "[Interface]\n\
|
||||
Address = ${wg_net}/24\n\
|
||||
PrivateKey =\n\
|
||||
PostUp = iptables -t nat -I POSTROUTING 1 -s ${wg_net}/24 -o ${out_adapt} -j MASQUERADE\n\
|
||||
PostUp = iptables -I FORWARD -i wg0 -o wg0 -j DROP\n\
|
||||
PreDown = iptables -t nat -D POSTROUTING -s ${wg_net}/24 -o ${out_adapt} -j MASQUERADE\n\
|
||||
PreDown = iptables -D FORWARD -i wg0 -o wg0 -j DROP\n\
|
||||
ListenPort = ${wg_port}\n\
|
||||
SaveConfig = true\n\
|
||||
DNS = ${global_dns}" > /configs/wg0.conf.template \
|
||||
&& chmod 600 /configs/wg0.conf.template
|
||||
|
||||
# Defining a way for Docker to check the health of the container. In this case: checking the gunicorn process.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD sh -c 'pgrep gunicorn > /dev/null && pgrep tail > /dev/null' || exit 1
|
||||
|
||||
# Copy the basic entrypoint.sh script.
|
||||
COPY ./docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Exposing the default WireGuard Dashboard port for web access.
|
||||
EXPOSE 10086
|
||||
WORKDIR $WGDASH
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
143
docker/Dockerfile
Normal file
@@ -0,0 +1,143 @@
|
||||
#
|
||||
# AWG GOLANG BUILDING STAGE
|
||||
# Base: Alpine
|
||||
#
|
||||
|
||||
# Pull the current golang-alpine image.
|
||||
FROM golang:1.25-alpine AS awg-go
|
||||
|
||||
# Install build-dependencies.
|
||||
RUN apk add --no-cache \
|
||||
git \
|
||||
gcc \
|
||||
musl-dev
|
||||
|
||||
# Standard working directory for WGDashboard
|
||||
RUN mkdir -p /workspace && \
|
||||
git clone https://github.com/WGDashboard/amneziawg-go /workspace/awg
|
||||
|
||||
# Enable CGO compilation for AmneziaWG
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Change directory
|
||||
WORKDIR /workspace/awg
|
||||
# Compile the binaries
|
||||
RUN go mod download && \
|
||||
go mod verify && \
|
||||
go build -ldflags '-linkmode external -extldflags "-fno-PIC -static"' -v -o /usr/bin
|
||||
#
|
||||
# AWG TOOLS BUILDING STAGE
|
||||
# Base: Debian
|
||||
#
|
||||
FROM alpine:latest AS awg-tools
|
||||
|
||||
# Install needed dependencies.
|
||||
RUN apk add --no-cache \
|
||||
make \
|
||||
git \
|
||||
build-base \
|
||||
linux-headers \
|
||||
ca-certificates
|
||||
|
||||
# Get the workspace ready
|
||||
RUN mkdir -p /workspace && \
|
||||
git clone https://github.com/WGDashboard/amneziawg-tools /workspace/awg-tools
|
||||
|
||||
# Change directory
|
||||
WORKDIR /workspace/awg-tools/src
|
||||
# Compile and change permissions
|
||||
RUN make && chmod +x wg*
|
||||
|
||||
#
|
||||
# PIP DEPENDENCY BUILDING
|
||||
# Base: Alpine
|
||||
#
|
||||
|
||||
# Use the python-alpine image for building pip dependencies
|
||||
FROM python:3.13-alpine AS pip-builder
|
||||
|
||||
# Add the build dependencies and create a Python virtual environment.
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
pkgconfig \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
linux-headers \
|
||||
rust \
|
||||
cargo \
|
||||
&& mkdir -p /opt/wgdashboard/src \
|
||||
&& python3 -m venv /opt/wgdashboard/src/venv
|
||||
|
||||
# Copy the requirements file into the build layer.
|
||||
COPY ./src/requirements.txt /opt/wgdashboard/src
|
||||
# Install the pip packages
|
||||
RUN . /opt/wgdashboard/src/venv/bin/activate && \
|
||||
pip3 install --upgrade pip && \
|
||||
pip3 install -r /opt/wgdashboard/src/requirements.txt
|
||||
|
||||
#
|
||||
# WGDashboard RUNNING STAGE
|
||||
# Base: Alpine
|
||||
#
|
||||
|
||||
# Running with the python-alpine image.
|
||||
FROM python:3.13-alpine AS final
|
||||
LABEL maintainer="dselen@nerthus.nl"
|
||||
|
||||
# Install only the runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
iproute2 iptables \
|
||||
bash curl \
|
||||
wget unzip \
|
||||
procps sudo \
|
||||
tzdata wireguard-tools \
|
||||
openresolv openrc
|
||||
|
||||
# Copy only the final binaries from the AWG builder stages
|
||||
COPY --from=awg-go /usr/bin/amneziawg-go /usr/bin/amneziawg-go
|
||||
COPY --from=awg-tools /workspace/awg-tools/src/wg /usr/bin/awg
|
||||
COPY --from=awg-tools /workspace/awg-tools/src/wg-quick/linux.bash /usr/bin/awg-quick
|
||||
|
||||
# Environment variables
|
||||
ARG wg_net="10.0.0.1"
|
||||
ARG wg_port="51820"
|
||||
ENV TZ="Europe/Amsterdam" \
|
||||
global_dns="9.9.9.9" \
|
||||
wgd_port="10086" \
|
||||
public_ip="" \
|
||||
WGDASH=/opt/wgdashboard
|
||||
|
||||
# Create directories needed for operation
|
||||
RUN mkdir /data /configs -p ${WGDASH}/src /etc/amnezia/amneziawg
|
||||
|
||||
# Copy the python virtual environment from the pip-builder stage
|
||||
COPY ./src ${WGDASH}/src
|
||||
COPY --from=pip-builder /opt/wgdashboard/src/venv /opt/wgdashboard/src/venv
|
||||
|
||||
# First WireGuard interface template
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
RUN out_adapt=$(ip -o -4 route show to default | awk '{print $NF}') \
|
||||
&& echo -e "[Interface]\n\
|
||||
Address = ${wg_net}/24\n\
|
||||
PrivateKey =\n\
|
||||
PostUp = iptables -t nat -I POSTROUTING 1 -s ${wg_net}/24 -o ${out_adapt} -j MASQUERADE\n\
|
||||
PostUp = iptables -I FORWARD -i wg0 -o wg0 -j DROP\n\
|
||||
PreDown = iptables -t nat -D POSTROUTING -s ${wg_net}/24 -o ${out_adapt} -j MASQUERADE\n\
|
||||
PreDown = iptables -D FORWARD -i wg0 -o wg0 -j DROP\n\
|
||||
ListenPort = ${wg_port}\n\
|
||||
SaveConfig = true\n\
|
||||
DNS = ${global_dns}" > /configs/wg0.conf.template \
|
||||
&& chmod 600 /configs/wg0.conf.template
|
||||
|
||||
# Set a healthcheck to determine the container its health
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD sh -c 'pgrep gunicorn > /dev/null && pgrep tail > /dev/null' || exit 1
|
||||
|
||||
# Copy in the runtime script, essential.
|
||||
COPY ./docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
#
|
||||
EXPOSE 10086
|
||||
WORKDIR $WGDASH/src
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
213
docker/README.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# WGDashboard Docker Explanation:
|
||||
Author: @DaanSelen<br>
|
||||
|
||||
This document delves into how the WGDashboard Docker container has been built.<br>
|
||||
Of course there are two stages (simply said), one before run-time and one at/after run-time.<br>
|
||||
The `Dockerfile` describes how the container image is made, and the `entrypoint.sh` is executed after the container is started. <br>
|
||||
In this example, [WireGuard](https://www.wireguard.com/) is integrated into the container itself, so it should be a run-and-go(/out-of-the-box) experience.<br>
|
||||
For more details on the source-code specific to this Docker image, refer to the source files, they have lots of comments.
|
||||
|
||||
<br>
|
||||
<img
|
||||
src="https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Logos/Logo-2-Rounded-512x512.png"
|
||||
alt="WG-Dashboard Logo"
|
||||
title="WG-Dashboard Logo"
|
||||
style="display: block; margin: 0 auto;"
|
||||
width="150"
|
||||
height="150"
|
||||
/>
|
||||
<br>
|
||||
|
||||
To get the container running you either pull the pre-made image from a remote repository, there are 2 official options.<br>
|
||||
|
||||
- ghcr.io/wgdashboard/wgdashboard:<tag>
|
||||
- docker.io/donaldzou/wgdashboard:<tag>
|
||||
|
||||
> tags should be either: latest, main, <version> or <commit-sha>.
|
||||
|
||||
From there either use the environment variables described below as parameters or use the Docker Compose file: `compose.yaml`.<br>
|
||||
Be careful, the default generated WireGuard configuration file uses port 51820/udp. So make sure to use this port if you want to use it out of the box.<br>
|
||||
Otherwise edit the configuration file in WGDashboard under `Configuration Settings` -> `Edit Raw Configuration File`.
|
||||
|
||||
> Otherwise you need to enter the container and edit: `/etc/wireguard/wg0.conf`.
|
||||
|
||||
# WGDashboard: 🐳 Docker Deployment Guide
|
||||
|
||||
To run the container, you can either pull the image from the Github Container Registry (ghcr.io), Docker Hub (docker.io) or build it yourself. The image is available at:
|
||||
|
||||
> `docker.io` is in most cases automatically resolved by the Docker application. Therefor you can ofter specify: `donaldzou/wgdashboard:latest`
|
||||
|
||||
### 🔧 Quick Docker Run Command
|
||||
|
||||
Here's an example to get it up and running quickly:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name wgdashboard \
|
||||
--restart unless-stopped \
|
||||
-p 10086:10086/tcp \
|
||||
-p 51820:51820/udp \
|
||||
--cap-add NET_ADMIN \
|
||||
ghcr.io/wgdashboard/wgdashboard:latest
|
||||
```
|
||||
|
||||
> ⚠️ The default WireGuard port is `51820/udp`. If you change this, update the `/etc/wireguard/wg0.conf` accordingly.
|
||||
|
||||
---
|
||||
|
||||
### 📦 Docker Compose Alternative
|
||||
|
||||
You can also use Docker Compose for easier configuration:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
wgdashboard:
|
||||
image: ghcr.io/wgdashboard/wgdashboard:latest
|
||||
restart: unless-stopped
|
||||
container_name: wgdashboard
|
||||
|
||||
ports:
|
||||
- 10086:10086/tcp
|
||||
- 51820:51820/udp
|
||||
|
||||
volumes:
|
||||
- aconf:/etc/amnezia/amneziawg
|
||||
- conf:/etc/wireguard
|
||||
- data:/data
|
||||
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
volumes:
|
||||
aconf:
|
||||
conf:
|
||||
data:
|
||||
```
|
||||
|
||||
> 📁 You can customize the **volume paths** on the host to fit your needs. The example above uses Docker volumes.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Updating the Container
|
||||
|
||||
Updating the WGDashboard container should be through 'The Docker Way' - by pulling the newest/newer image and replacing this old one.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Variables
|
||||
|
||||
| Variable | Accepted Values | Default | Example | Description |
|
||||
| ------------------ | ---------------------------------------- | ----------------------- | --------------------- | ----------------------------------------------------------------------- |
|
||||
| `tz` | Timezone | `Europe/Amsterdam` | `America/New_York` | Sets the container's timezone. Useful for accurate logs and scheduling. |
|
||||
| `global_dns` | IPv4 and IPv6 addresses | `9.9.9.9` | `8.8.8.8`, `1.1.1.1` | Default DNS for WireGuard clients. |
|
||||
| `public_ip` | Public IP address | Retrieved automatically | `253.162.134.73` | Used to generate accurate client configs. Needed if container is NAT’d. |
|
||||
| `wgd_port` | Any port that is allowed for the process | `10086` | `443` | This port is used to set the WGDashboard web port. |
|
||||
| `username` | Any non‐empty string | `-` | `admin` | Username for the WGDashboard web interface account. |
|
||||
| `password` | Any non‐empty string | `-` | `s3cr3tP@ss` | Password for the WGDashboard web interface account (stored hashed). |
|
||||
| `enable_totp` | `true`, `false` | `true` | `false` | Enable TOTP‐based two‐factor authentication for the account. |
|
||||
| `wg_autostart` | Wireguard interface name | `false` | `true` | Auto‐start the WireGuard client when the container launches. |
|
||||
| `email_server` | SMTP server address | `-` | `smtp.gmail.com` | SMTP server for sending email notifications. |
|
||||
| `email_port` | SMTP port number | `-` | `587` | Port for connecting to the SMTP server. |
|
||||
| `email_encryption` | `TLS`, `SSL`, etc. | `-` | `TLS` | Encryption method for email communication. |
|
||||
| `email_username` | Any non-empty string | `-` | `user@example.com` | Username for SMTP authentication. |
|
||||
| `email_password` | Any non-empty string | `-` | `app_password` | Password for SMTP authentication. |
|
||||
| `email_from` | Valid email address | `-` | `noreply@example.com` | Email address used as the sender for notifications. |
|
||||
| `email_template` | Path to template file | `-` | `your-template` | Custom template for email notifications. |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Port Forwarding Note
|
||||
|
||||
When using multiple WireGuard interfaces, remember to **open their respective ports** on the host.
|
||||
|
||||
Examples:
|
||||
```yaml
|
||||
# Individual mapping
|
||||
- 51821:51821/udp
|
||||
|
||||
# Or port range
|
||||
- 51820-51830:51820-51830/udp
|
||||
```
|
||||
|
||||
> 🚨 **Security Tip:** Only expose ports you actually use.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Building the Image Yourself
|
||||
|
||||
To build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/WGDashboard/WGDashboard.git
|
||||
cd WGDashboard
|
||||
docker build . -f docker/Dockerfile -t yourname/wgdashboard:latest
|
||||
```
|
||||
|
||||
Example output:
|
||||
```shell
|
||||
docker images
|
||||
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
yourname/wgdashboard latest c96fd96ee3b3 42 minutes ago 314MB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Dockerfile Overview
|
||||
|
||||
Here's a brief overview of the Dockerfile stages used in the image build:
|
||||
|
||||
### 1. **Build Tools & Go Compilation**
|
||||
|
||||
```Dockerfile
|
||||
FROM golang:1.24 AS compiler
|
||||
WORKDIR /go
|
||||
|
||||
RUN apt-get update && apt-get install -y ...
|
||||
RUN git clone ... && make
|
||||
...
|
||||
```
|
||||
|
||||
### 2. **Binary Copy to Scratch**
|
||||
|
||||
```Dockerfile
|
||||
FROM scratch AS bins
|
||||
COPY --from=compiler /go/amneziawg-go/amneziawg-go /amneziawg-go
|
||||
...
|
||||
```
|
||||
|
||||
### 3. **Final Alpine Container Setup**
|
||||
|
||||
```Dockerfile
|
||||
FROM alpine:latest
|
||||
COPY --from=bins ...
|
||||
RUN apk update && apk add --no-cache ...
|
||||
COPY ./src ${WGDASH}/src
|
||||
COPY ./docker/entrypoint.sh /entrypoint.sh
|
||||
...
|
||||
EXPOSE 10086
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Entrypoint Overview
|
||||
|
||||
### Major Functions:
|
||||
|
||||
- **`ensure_installation`**: Sets up the app, database, and Python environment.
|
||||
- **`set_envvars`**: Writes `wg-dashboard.ini` and applies environment variables.
|
||||
- **`start_core`**: Starts the main WGDashboard service.
|
||||
- **`ensure_blocking`**: Tails the error log to keep the container process alive.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Notes
|
||||
|
||||
- Use `docker logs wgdashboard` for troubleshooting.
|
||||
- Access the web interface via `http://your-ip:10086` (or whichever port you specified in the compose).
|
||||
- The first time run will auto-generate WireGuard keys and configs (configs are generated from the template).
|
||||
|
||||
## Closing remarks:
|
||||
|
||||
For feedback please submit an issue to the repository. Or message dselen@nerthus.nl.
|
42
docker/compose.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
wgdashboard:
|
||||
# Since the github organisation we recommend the ghcr.io.
|
||||
# Alternatively we also still push to docker.io under donaldzou/wgdashboard.
|
||||
# Both share the exact same tags. So they should be interchangable.
|
||||
image: ghcr.io/wgdashboard/wgdashboard:latest
|
||||
|
||||
# Make sure to set the restart policy. Because for a VPN its important to come back IF it crashes.
|
||||
restart: unless-stopped
|
||||
container_name: wgdashboard
|
||||
|
||||
# Environment variables can be used to configure certain values at startup. Without having to configure it from the dashboard.
|
||||
# By default its all disabled, but uncomment the following lines to apply these. (uncommenting is removing the # character)
|
||||
# Refer to the documentation on https://wgdashboard.dev/ for more info on what everything means.
|
||||
#environment:
|
||||
#- tz= # <--- Set container timezone, default: Europe/Amsterdam.
|
||||
#- public_ip= # <--- Set public IP to ensure the correct one is chosen, defaulting to the IP give by ifconfig.me.
|
||||
#- wgd_port= # <--- Set the port WGDashboard will use for its web-server.
|
||||
|
||||
# The following section, ports is very important for exposing more than one Wireguard/AmneziaWireguard interfaces.
|
||||
# Once you create a new configuration and assign a port in the dashboard, don't forget to add it to the ports as well.
|
||||
# Quick-tip: most Wireguard VPN tunnels use UDP. WGDashboard uses HTTP, so tcp.
|
||||
ports:
|
||||
- 10086:10086/tcp
|
||||
- 51820:51820/udp
|
||||
|
||||
# Volumes can be configured however you'd like. The default is using docker volumes.
|
||||
# If you want to use local paths, replace the path before the : with your path.
|
||||
volumes:
|
||||
- aconf:/etc/amnezia/amneziawg
|
||||
- conf:/etc/wireguard
|
||||
- data:/data
|
||||
|
||||
# Needed for network administration.
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
# The following configuration is linked to the above default volumes.
|
||||
volumes:
|
||||
aconf:
|
||||
conf:
|
||||
data:
|
224
docker/entrypoint.sh
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/bin/bash
|
||||
|
||||
config_file="/data/wg-dashboard.ini"
|
||||
|
||||
trap 'stop_service' SIGTERM
|
||||
|
||||
# Hash password with bcrypt
|
||||
hash_password() {
|
||||
python3 -c "import bcrypt; print(bcrypt.hashpw('$1'.encode(), bcrypt.gensalt(12)).decode())"
|
||||
}
|
||||
|
||||
# Function to set or update section/key/value in the INI file
|
||||
set_ini() {
|
||||
local section="$1" key="$2" value="$3"
|
||||
local current_value
|
||||
|
||||
# Add section if it doesn't exist
|
||||
grep -q "^\[${section}\]" "$config_file" \
|
||||
|| printf "\n[%s]\n" "${section}" >> "$config_file"
|
||||
|
||||
# Check current value if key exists
|
||||
if grep -q "^[[:space:]]*${key}[[:space:]]*=" "$config_file"; then
|
||||
current_value=$(grep "^[[:space:]]*${key}[[:space:]]*=" "$config_file" | cut -d= -f2- | xargs)
|
||||
|
||||
# Don't display actual value if it's a password field
|
||||
if [[ "$key" == *"password"* ]]; then
|
||||
if [ "$current_value" = "$value" ]; then
|
||||
echo "- $key is already set correctly (value hidden)"
|
||||
return 0
|
||||
fi
|
||||
sed -i "/^\[${section}\]/,/^\[/{s|^[[:space:]]*${key}[[:space:]]*=.*|${key} = ${value}|}" "$config_file"
|
||||
echo "- Updated $key (value hidden)"
|
||||
else
|
||||
if [ "$current_value" = "$value" ]; then
|
||||
echo "- $key is already set correctly ($value)"
|
||||
return 0
|
||||
fi
|
||||
sed -i "/^\[${section}\]/,/^\[/{s|^[[:space:]]*${key}[[:space:]]*=.*|${key} = ${value}|}" "$config_file"
|
||||
echo "- Updated $key to: $value"
|
||||
fi
|
||||
else
|
||||
sed -i "/^\[${section}\]/a ${key} = ${value}" "$config_file"
|
||||
|
||||
# Don't display actual value if it's a password field
|
||||
if [[ "$key" == *"password"* ]]; then
|
||||
echo "- Added new setting $key (value hidden)"
|
||||
else
|
||||
echo "- Added new setting $key: $value"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
echo "[WGDashboard] Stopping WGDashboard..."
|
||||
/bin/bash ./wgd.sh stop
|
||||
exit 0
|
||||
}
|
||||
|
||||
echo "------------------------- START ----------------------------"
|
||||
echo "Starting the WGDashboard Docker container."
|
||||
|
||||
ensure_installation() {
|
||||
echo "Quick-installing..."
|
||||
|
||||
# Make the wgd.sh script executable.
|
||||
chmod +x "${WGDASH}"/src/wgd.sh
|
||||
cd "${WGDASH}"/src || exit
|
||||
|
||||
# Github issue: https://github.com/donaldzou/WGDashboard/issues/723
|
||||
echo "Checking for stale pids..."
|
||||
if [[ -f ${WGDASH}/src/gunicorn.pid ]]; then
|
||||
echo "Found stale pid, removing..."
|
||||
rm ${WGDASH}/src/gunicorn.pid
|
||||
fi
|
||||
|
||||
# Removing clear shell command from the wgd.sh script to enhance docker logging.
|
||||
echo "Removing clear command from wgd.sh for better Docker logging."
|
||||
sed -i '/clear/d' ./wgd.sh
|
||||
|
||||
# Create required directories and links
|
||||
if [ ! -d "/data/db" ]; then
|
||||
echo "Creating database dir"
|
||||
mkdir -p /data/db
|
||||
fi
|
||||
|
||||
if [ ! -d "${WGDASH}/src/db" ]; then
|
||||
ln -s /data/db "${WGDASH}/src/db"
|
||||
fi
|
||||
|
||||
if [ ! -f "${config_file}" ]; then
|
||||
echo "Creating wg-dashboard.ini file"
|
||||
touch "${config_file}"
|
||||
fi
|
||||
|
||||
if [ ! -f "${WGDASH}/src/wg-dashboard.ini" ]; then
|
||||
ln -s "${config_file}" "${WGDASH}/src/wg-dashboard.ini"
|
||||
fi
|
||||
|
||||
# Create the Python virtual environment.
|
||||
. "${WGDASH}/src/venv/bin/activate"
|
||||
|
||||
# Use the bash interpreter to install WGDashboard according to the wgd.sh script.
|
||||
/bin/bash ./wgd.sh install
|
||||
|
||||
echo "Looks like the installation succeeded. Moving on."
|
||||
|
||||
# Setup WireGuard if needed
|
||||
if [ -z "$(ls -A /etc/wireguard)" ]; then
|
||||
cp -a "/configs/wg0.conf.template" "/etc/wireguard/wg0.conf"
|
||||
|
||||
echo "Setting a secure private key."
|
||||
local privateKey
|
||||
privateKey=$(wg genkey)
|
||||
sed -i "s|^PrivateKey *=.*$|PrivateKey = ${privateKey}|g" /etc/wireguard/wg0.conf
|
||||
|
||||
echo "Done setting template."
|
||||
else
|
||||
echo "Existing wg0 configuration file found, using that."
|
||||
fi
|
||||
}
|
||||
|
||||
set_envvars() {
|
||||
printf "\n------------- SETTING ENVIRONMENT VARIABLES ----------------\n"
|
||||
|
||||
# Check if config file is empty
|
||||
if [ ! -s "${config_file}" ]; then
|
||||
echo "Config file is empty. Creating initial structure."
|
||||
fi
|
||||
|
||||
echo "Checking basic configuration:"
|
||||
set_ini Peers peer_global_dns "${global_dns}"
|
||||
|
||||
if [ -z "${public_ip}" ]; then
|
||||
public_ip=$(curl -s ifconfig.me)
|
||||
echo "Automatically detected public IP: ${public_ip}"
|
||||
fi
|
||||
|
||||
set_ini Peers remote_endpoint "${public_ip}"
|
||||
set_ini Server app_port "${wgd_port}"
|
||||
|
||||
# Account settings - process all parameters
|
||||
[[ -n "$username" ]] && echo "Configuring user account:"
|
||||
# Basic account variables
|
||||
[[ -n "$username" ]] && set_ini Account username "${username}"
|
||||
|
||||
if [[ -n "$password" ]]; then
|
||||
echo "- Setting password"
|
||||
set_ini Account password "$(hash_password "${password}")"
|
||||
fi
|
||||
|
||||
# Additional account variables
|
||||
[[ -n "$enable_totp" ]] && set_ini Account enable_totp "${enable_totp}"
|
||||
[[ -n "$totp_verified" ]] && set_ini Account totp_verified "${totp_verified}"
|
||||
[[ -n "$totp_key" ]] && set_ini Account totp_key "${totp_key}"
|
||||
|
||||
# Welcome session
|
||||
[[ -n "$welcome_session" ]] && set_ini Other welcome_session "${welcome_session}"
|
||||
# If username and password are set but welcome_session isn't, disable it
|
||||
if [[ -n "$username" && -n "$password" && -z "$welcome_session" ]]; then
|
||||
set_ini Other welcome_session "false"
|
||||
fi
|
||||
|
||||
# Autostart WireGuard
|
||||
if [[ -n "$wg_autostart" ]]; then
|
||||
echo "Configuring WireGuard autostart:"
|
||||
set_ini WireGuardConfiguration autostart "${wg_autostart}"
|
||||
fi
|
||||
|
||||
# Email (check if any settings need to be configured)
|
||||
email_vars=("email_server" "email_port" "email_encryption" "email_username" "email_password" "email_from" "email_template")
|
||||
for var in "${email_vars[@]}"; do
|
||||
if [ -n "${!var}" ]; then
|
||||
echo "Configuring email settings:"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Email (iterate through all possible fields)
|
||||
email_fields=("server:email_server" "port:email_port" "encryption:email_encryption"
|
||||
"username:email_username" "email_password:email_password"
|
||||
"send_from:email_from" "email_template:email_template")
|
||||
|
||||
for field_pair in "${email_fields[@]}"; do
|
||||
IFS=: read -r field var <<< "$field_pair"
|
||||
[[ -n "${!var}" ]] && set_ini Email "$field" "${!var}"
|
||||
done
|
||||
}
|
||||
|
||||
# Start service and monitor logs
|
||||
start_and_monitor() {
|
||||
printf "\n---------------------- STARTING CORE -----------------------\n"
|
||||
|
||||
# Due to some instances complaining about this, making sure its there every time.
|
||||
mkdir -p /dev/net
|
||||
mknod /dev/net/tun c 10 200
|
||||
chmod 600 /dev/net/tun
|
||||
|
||||
# Actually starting WGDashboard
|
||||
echo "Activating Python venv and executing the WireGuard Dashboard service."
|
||||
bash ./wgd.sh start
|
||||
|
||||
# Wait a second before continuing, to give the python program some time to get ready.
|
||||
sleep 1
|
||||
echo -e "\nEnsuring container continuation."
|
||||
|
||||
# Find and monitor log file
|
||||
local logdir="${WGDASH}/src/log"
|
||||
latestErrLog=$(find "$logdir" -name "error_*.log" -type f -print | sort -r | head -n 1)
|
||||
|
||||
# Only tail the logs if they are found
|
||||
if [ -n "$latestErrLog" ]; then
|
||||
tail -f "$latestErrLog" &
|
||||
# Wait for the tail process to end.
|
||||
wait $!
|
||||
else
|
||||
echo "No log files found to tail. Something went wrong, exiting..."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Main execution flow
|
||||
ensure_installation
|
||||
set_envvars
|
||||
start_and_monitor
|
BIN
img/AddPeer.png
Before Width: | Height: | Size: 156 KiB |
Before Width: | Height: | Size: 181 KiB |
Before Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 210 KiB |
Before Width: | Height: | Size: 211 KiB |
BIN
img/EditPeer.png
Before Width: | Height: | Size: 149 KiB |
BIN
img/HomePage.png
Before Width: | Height: | Size: 141 KiB |
BIN
img/Ping.png
Before Width: | Height: | Size: 229 KiB |
BIN
img/QRCode.png
Before Width: | Height: | Size: 239 KiB |
BIN
img/SearchIP.png
Before Width: | Height: | Size: 153 KiB |
BIN
img/SignIn.png
Before Width: | Height: | Size: 140 KiB |
Before Width: | Height: | Size: 240 KiB |
BIN
img/logo.png
Before Width: | Height: | Size: 180 KiB |
6
qodana.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: "1.0"
|
||||
linter: jetbrains/qodana-python:2024.3
|
||||
profile:
|
||||
name: qodana.recommended
|
||||
include:
|
||||
- name: CheckDependencyLicenses
|
232
src/client.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import datetime
|
||||
|
||||
from tzlocal import get_localzone
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, render_template, abort, request, Flask, current_app, session, redirect, url_for
|
||||
import os
|
||||
|
||||
from modules.WireguardConfiguration import WireguardConfiguration
|
||||
from modules.DashboardConfig import DashboardConfig
|
||||
from modules.Email import EmailSender
|
||||
|
||||
|
||||
def ResponseObject(status=True, message=None, data=None, status_code = 200) -> Flask.response_class:
|
||||
response = Flask.make_response(current_app, {
|
||||
"status": status,
|
||||
"message": message,
|
||||
"data": data
|
||||
})
|
||||
response.status_code = status_code
|
||||
response.content_type = "application/json"
|
||||
return response
|
||||
|
||||
|
||||
|
||||
from modules.DashboardClients import DashboardClients
|
||||
def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration], dashboardConfig: DashboardConfig, dashboardClients: DashboardClients):
|
||||
|
||||
client = Blueprint('client', __name__, template_folder=os.path.abspath("./static/dist/WGDashboardClient"))
|
||||
prefix = f'{dashboardConfig.GetConfig("Server", "app_prefix")[1]}/client'
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def func(*args, **kwargs):
|
||||
if session.get("Email") is None or session.get("TotpVerified") is None or not session.get("TotpVerified") or session.get("Role") != "client":
|
||||
return ResponseObject(False, "Unauthorized access.", data=None, status_code=401)
|
||||
|
||||
if not dashboardClients.GetClient(session.get("ClientID")):
|
||||
session.clear()
|
||||
return ResponseObject(False, "Unauthorized access.", data=None, status_code=401)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return func
|
||||
|
||||
@client.before_request
|
||||
def clientBeforeRequest():
|
||||
if not dashboardConfig.GetConfig("Clients", "enable")[1]:
|
||||
abort(404)
|
||||
|
||||
if request.method.lower() == 'options':
|
||||
return ResponseObject(True)
|
||||
|
||||
@client.post(f'{prefix}/api/signup')
|
||||
def ClientAPI_SignUp():
|
||||
data = request.get_json()
|
||||
status, msg = dashboardClients.SignUp(**data)
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.get(f'{prefix}/api/signin/oidc/providers')
|
||||
def ClientAPI_SignIn_OIDC_GetProviders():
|
||||
_, oidc = dashboardConfig.GetConfig("OIDC", "client_enable")
|
||||
if not oidc:
|
||||
return ResponseObject(status=False, message="OIDC is disabled")
|
||||
|
||||
return ResponseObject(data=dashboardClients.OIDC.GetProviders())
|
||||
|
||||
@client.post(f'{prefix}/api/signin/oidc')
|
||||
def ClientAPI_SignIn_OIDC():
|
||||
_, oidc = dashboardConfig.GetConfig("OIDC", "client_enable")
|
||||
if not oidc:
|
||||
return ResponseObject(status=False, message="OIDC is disabled")
|
||||
|
||||
data = request.get_json()
|
||||
status, oidcData = dashboardClients.SignIn_OIDC(**data)
|
||||
if not status:
|
||||
return ResponseObject(status, oidcData)
|
||||
|
||||
session['Email'] = oidcData.get('email')
|
||||
session['Role'] = 'client'
|
||||
session['TotpVerified'] = True
|
||||
|
||||
return ResponseObject()
|
||||
|
||||
@client.post(f'{prefix}/api/signin')
|
||||
def ClientAPI_SignIn():
|
||||
data = request.get_json()
|
||||
status, msg = dashboardClients.SignIn(**data)
|
||||
if status:
|
||||
session['Email'] = data.get('Email')
|
||||
session['Role'] = 'client'
|
||||
session['TotpVerified'] = False
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.post(f'{prefix}/api/resetPassword/generateResetToken')
|
||||
def ClientAPI_ResetPassword_GenerateResetToken():
|
||||
date = datetime.datetime.now(tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
|
||||
emailSender = EmailSender(dashboardConfig)
|
||||
if not emailSender.ready():
|
||||
return ResponseObject(False, "We can't send you an email due to your Administrator has not setup email service. Please contact your administrator.")
|
||||
|
||||
data = request.get_json()
|
||||
email = data.get('Email', None)
|
||||
if not email:
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
u = dashboardClients.SignIn_UserExistence(email)
|
||||
if not u:
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
token = dashboardClients.GenerateClientPasswordResetToken(u.get('ClientID'))
|
||||
|
||||
status, msg = emailSender.send(
|
||||
email, "[WGDashboard | Client] Reset Password",
|
||||
f"Hi {email}, \n\nIt looks like you're trying to reset your password at {date} \n\nEnter this 6 digits code on the Forgot Password to continue:\n\n{token}\n\nThis code will expire in 30 minutes for your security. If you didn’t request a password reset, you can safely ignore this email—your current password will remain unchanged.\n\nIf you need help, feel free to contact support.\n\nBest regards,\n\nWGDashboard"
|
||||
)
|
||||
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.post(f'{prefix}/api/resetPassword/validateResetToken')
|
||||
def ClientAPI_ResetPassword_ValidateResetToken():
|
||||
data = request.get_json()
|
||||
email = data.get('Email', None)
|
||||
token = data.get('Token', None)
|
||||
if not all([email, token]):
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
u = dashboardClients.SignIn_UserExistence(email)
|
||||
if not u:
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
return ResponseObject(status=dashboardClients.ValidateClientPasswordResetToken(u.get('ClientID'), token))
|
||||
|
||||
@client.post(f'{prefix}/api/resetPassword')
|
||||
def ClientAPI_ResetPassword():
|
||||
data = request.get_json()
|
||||
email = data.get('Email', None)
|
||||
token = data.get('Token', None)
|
||||
password = data.get('Password', None)
|
||||
confirmPassword = data.get('ConfirmPassword', None)
|
||||
if not all([email, token, password, confirmPassword]):
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
u = dashboardClients.SignIn_UserExistence(email)
|
||||
if not u:
|
||||
return ResponseObject(False, "Please provide a valid Email")
|
||||
|
||||
if not dashboardClients.ValidateClientPasswordResetToken(u.get('ClientID'), token):
|
||||
return ResponseObject(False, "Verification code is either invalid or expired")
|
||||
|
||||
status, msg = dashboardClients.ResetClientPassword(u.get('ClientID'), password, confirmPassword)
|
||||
|
||||
dashboardClients.RevokeClientPasswordResetToken(u.get('ClientID'), token)
|
||||
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
|
||||
@client.get(f'{prefix}/api/signout')
|
||||
def ClientAPI_SignOut():
|
||||
if session.get("SignInMethod") == "OIDC":
|
||||
dashboardClients.SignOut_OIDC()
|
||||
session.clear()
|
||||
return ResponseObject(True)
|
||||
|
||||
@client.get(f'{prefix}/api/signin/totp')
|
||||
def ClientAPI_SignIn_TOTP():
|
||||
token = request.args.get('Token', None)
|
||||
if not token:
|
||||
return ResponseObject(False, "Please provide TOTP token")
|
||||
|
||||
status, msg = dashboardClients.SignIn_GetTotp(token)
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.post(f'{prefix}/api/signin/totp')
|
||||
def ClientAPI_SignIn_ValidateTOTP():
|
||||
data = request.get_json()
|
||||
token = data.get('Token', None)
|
||||
userProvidedTotp = data.get('UserProvidedTOTP', None)
|
||||
if not all([token, userProvidedTotp]):
|
||||
return ResponseObject(False, "Please fill in all fields")
|
||||
status, msg = dashboardClients.SignIn_GetTotp(token, userProvidedTotp)
|
||||
if status:
|
||||
if session.get('Email') is None:
|
||||
return ResponseObject(False, "Sign in status is invalid", status_code=401)
|
||||
session['TotpVerified'] = True
|
||||
profile = dashboardClients.GetClientProfile(session.get("ClientID"))
|
||||
|
||||
return ResponseObject(True, data={
|
||||
"Email": session.get('Email'),
|
||||
"Profile": profile
|
||||
})
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.get(prefix)
|
||||
def ClientIndex():
|
||||
return render_template('client.html')
|
||||
|
||||
@client.get(f'{prefix}/api/serverInformation')
|
||||
def ClientAPI_ServerInformation():
|
||||
return ResponseObject(data={
|
||||
"ServerTimezone": str(get_localzone())
|
||||
})
|
||||
|
||||
@client.get(f'{prefix}/api/validateAuthentication')
|
||||
@login_required
|
||||
def ClientAPI_ValidateAuthentication():
|
||||
return ResponseObject(True)
|
||||
|
||||
@client.get(f'{prefix}/api/configurations')
|
||||
@login_required
|
||||
def ClientAPI_Configurations():
|
||||
return ResponseObject(True, data=dashboardClients.GetClientAssignedPeers(session['ClientID']))
|
||||
|
||||
@client.get(f'{prefix}/api/settings/getClientProfile')
|
||||
@login_required
|
||||
def ClientAPI_Settings_GetClientProfile():
|
||||
return ResponseObject(data={
|
||||
"Email": session.get("Email"),
|
||||
"SignInMethod": session.get("SignInMethod"),
|
||||
"Profile": dashboardClients.GetClientProfile(session.get("ClientID"))
|
||||
})
|
||||
|
||||
@client.post(f'{prefix}/api/settings/updatePassword')
|
||||
@login_required
|
||||
def ClientAPI_Settings_UpdatePassword():
|
||||
data = request.get_json()
|
||||
status, message = dashboardClients.UpdateClientPassword(session['ClientID'], **data)
|
||||
|
||||
return ResponseObject(status, message)
|
||||
|
||||
return client
|
3356
src/dashboard.py
@@ -1,26 +0,0 @@
|
||||
[Interface]
|
||||
Address = 10.200.100.1/24
|
||||
PostUp = iptables -A FORWARD -i wg1 -j ACCEPT; iptables -A FORWARD -o wg1 -j ACCEPT; iptables -t nat -A POSTROUTING -o ens3 -j MASQUERADE;tc qdisc add dev wg1 root tbf rate 50000kbit buffer 1600 limit 50000
|
||||
PreDown = tc qdisc del dev wg1 root
|
||||
PostDown = iptables -D FORWARD -i wg1 -j ACCEPT; iptables -D FORWARD -o wg1 -j ACCEPT; iptables -t nat -D POSTROUTING -o ens3 -j MASQUERADE;
|
||||
ListenPort = 60945
|
||||
PrivateKey = 8DsSMli3okgUx5frKbFQ0fMW5ZMyqyxOdOW7+g21L18=
|
||||
|
||||
[Peer]
|
||||
PublicKey = aXsYmR73LEuwVOJeqUjlsWbWPyvJPm3lg3zh7WkruWg=
|
||||
PresharedKey = GISQ6z6GMLocJQgdYOfi2XX7NQWkZBPPFiueRYLqnJE=
|
||||
AllowedIPs = 10.200.100.2/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = FxXXNXoKZcBNyZbq0nFsmBC5YM+7up3a4bYGU6q900w=
|
||||
PresharedKey = dqp44vullLVZQhIYE2VaY6WFQNfahnHum5kq3sWPsSc=
|
||||
AllowedIPs = 10.200.100.3/32
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Peer]
|
||||
PublicKey = FxXXNXoKZcBNyZbq0nFsmBC5YM+7up3a4bYGU6q900w=
|
||||
PresharedKey = dqp44vullLVZQhIYE2VaY6WFQNfahnHum5kq3sWPsSc=
|
||||
AllowedIPs = 10.200.100.3/32
|
@@ -1,11 +1,26 @@
|
||||
import multiprocessing
|
||||
import dashboard
|
||||
from datetime import datetime
|
||||
global sqldb, cursor, DashboardConfig, WireguardConfigurations, AllPeerJobs, JobLogger, Dash
|
||||
app_host, app_port = dashboard.gunicornConfig()
|
||||
date = datetime.today().strftime('%Y_%m_%d_%H_%M_%S')
|
||||
|
||||
app_host, app_port = dashboard.get_host_bind()
|
||||
def post_worker_init(worker):
|
||||
dashboard.startThreads()
|
||||
dashboard.DashboardPlugins.startThreads()
|
||||
|
||||
worker_class = 'gthread'
|
||||
workers = multiprocessing.cpu_count() * 2 + 1
|
||||
threads = 4
|
||||
workers = 1
|
||||
threads = 2
|
||||
bind = f"{app_host}:{app_port}"
|
||||
daemon = True
|
||||
pidfile = './gunicorn.pid'
|
||||
wsgi_app = "dashboard:app"
|
||||
accesslog = f"./log/access_{date}.log"
|
||||
loglevel = "info"
|
||||
capture_output = True
|
||||
errorlog = f"./log/error_{date}.log"
|
||||
pythonpath = "., ./modules"
|
||||
|
||||
print(f"[Gunicorn] WGDashboard w/ Gunicorn will be running on {bind}", flush=True)
|
||||
print(f"[Gunicorn] Access log file is at {accesslog}", flush=True)
|
||||
print(f"[Gunicorn] Error log file is at {errorlog}", flush=True)
|
92
src/modules/AmneziaWGPeer.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
from .Peer import Peer
|
||||
from .Utilities import ValidateIPAddressesWithRange, ValidateDNSAddress, GenerateWireguardPublicKey
|
||||
|
||||
|
||||
class AmneziaWGPeer(Peer):
|
||||
def __init__(self, tableData, configuration):
|
||||
self.advanced_security = tableData["advanced_security"]
|
||||
super().__init__(tableData, configuration)
|
||||
|
||||
|
||||
def updatePeer(self, name: str, private_key: str,
|
||||
preshared_key: str,
|
||||
dns_addresses: str, allowed_ip: str, endpoint_allowed_ip: str, mtu: int,
|
||||
keepalive: int, advanced_security: str) -> tuple[bool, str] or tuple[bool, None]:
|
||||
if not self.configuration.getStatus():
|
||||
self.configuration.toggleConfiguration()
|
||||
|
||||
existingAllowedIps = [item for row in list(
|
||||
map(lambda x: [q.strip() for q in x.split(',')],
|
||||
map(lambda y: y.allowed_ip,
|
||||
list(filter(lambda k: k.id != self.id, self.configuration.getPeersList()))))) for item in row]
|
||||
|
||||
if allowed_ip in existingAllowedIps:
|
||||
return False, "Allowed IP already taken by another peer"
|
||||
if not ValidateIPAddressesWithRange(endpoint_allowed_ip):
|
||||
return False, f"Endpoint Allowed IPs format is incorrect"
|
||||
if len(dns_addresses) > 0 and not ValidateDNSAddress(dns_addresses):
|
||||
return False, f"DNS format is incorrect"
|
||||
|
||||
if type(mtu) is str:
|
||||
mtu = 0
|
||||
|
||||
if type(keepalive) is str:
|
||||
keepalive = 0
|
||||
|
||||
if mtu < 0 or mtu > 1460:
|
||||
return False, "MTU format is not correct"
|
||||
if keepalive < 0:
|
||||
return False, "Persistent Keepalive format is not correct"
|
||||
if advanced_security != "on" and advanced_security != "off":
|
||||
return False, "Advanced Security can only be on or off"
|
||||
if len(private_key) > 0:
|
||||
pubKey = GenerateWireguardPublicKey(private_key)
|
||||
if not pubKey[0] or pubKey[1] != self.id:
|
||||
return False, "Private key does not match with the public key"
|
||||
try:
|
||||
rd = random.Random()
|
||||
uid = str(uuid.UUID(int=rd.getrandbits(128), version=4))
|
||||
pskExist = len(preshared_key) > 0
|
||||
|
||||
if pskExist:
|
||||
with open(uid, "w+") as f:
|
||||
f.write(preshared_key)
|
||||
newAllowedIPs = allowed_ip.replace(" ", "")
|
||||
updateAllowedIp = subprocess.check_output(
|
||||
f"{self.configuration.Protocol} set {self.configuration.Name} peer {self.id} allowed-ips {newAllowedIPs} {f'preshared-key {uid}' if pskExist else 'preshared-key /dev/null'}",
|
||||
shell=True, stderr=subprocess.STDOUT)
|
||||
|
||||
if pskExist: os.remove(uid)
|
||||
|
||||
if len(updateAllowedIp.decode().strip("\n")) != 0:
|
||||
return False, "Update peer failed when updating Allowed IPs"
|
||||
saveConfig = subprocess.check_output(f"{self.configuration.Protocol}-quick save {self.configuration.Name}",
|
||||
shell=True, stderr=subprocess.STDOUT)
|
||||
if f"wg showconf {self.configuration.Name}" not in saveConfig.decode().strip('\n'):
|
||||
return False, "Update peer failed when saving the configuration"
|
||||
|
||||
with self.configuration.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.configuration.peersTable.update().values({
|
||||
"name": name,
|
||||
"private_key": private_key,
|
||||
"DNS": dns_addresses,
|
||||
"endpoint_allowed_ip": endpoint_allowed_ip,
|
||||
"mtu": mtu,
|
||||
"keepalive": keepalive,
|
||||
"preshared_key": preshared_key,
|
||||
"advanced_security": advanced_security
|
||||
}).where(
|
||||
self.configuration.peersTable.c.id == self.id
|
||||
)
|
||||
)
|
||||
self.configuration.getPeers()
|
||||
return True, None
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return False, exc.output.decode("UTF-8").strip()
|
324
src/modules/AmneziaWireguardConfiguration.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
AmneziaWG Configuration
|
||||
"""
|
||||
import random, sqlalchemy, os, subprocess, re, uuid
|
||||
from flask import current_app
|
||||
from .PeerJobs import PeerJobs
|
||||
from .AmneziaWGPeer import AmneziaWGPeer
|
||||
from .PeerShareLinks import PeerShareLinks
|
||||
from .Utilities import RegexMatch
|
||||
from .WireguardConfiguration import WireguardConfiguration
|
||||
from .DashboardWebHooks import DashboardWebHooks
|
||||
|
||||
|
||||
class AmneziaWireguardConfiguration(WireguardConfiguration):
|
||||
def __init__(self, DashboardConfig,
|
||||
AllPeerJobs: PeerJobs,
|
||||
AllPeerShareLinks: PeerShareLinks,
|
||||
DashboardWebHooks: DashboardWebHooks,
|
||||
name: str = None, data: dict = None, backup: dict = None, startup: bool = False):
|
||||
self.Jc = 0
|
||||
self.Jmin = 0
|
||||
self.Jmax = 0
|
||||
self.S1 = 0
|
||||
self.S2 = 0
|
||||
self.H1 = 1
|
||||
self.H2 = 2
|
||||
self.H3 = 3
|
||||
self.H4 = 4
|
||||
|
||||
super().__init__(DashboardConfig, AllPeerJobs, AllPeerShareLinks, DashboardWebHooks, name, data, backup, startup, wg=False)
|
||||
|
||||
def toJson(self):
|
||||
self.Status = self.getStatus()
|
||||
return {
|
||||
"Status": self.Status,
|
||||
"Name": self.Name,
|
||||
"PrivateKey": self.PrivateKey,
|
||||
"PublicKey": self.PublicKey,
|
||||
"Address": self.Address,
|
||||
"ListenPort": self.ListenPort,
|
||||
"PreUp": self.PreUp,
|
||||
"PreDown": self.PreDown,
|
||||
"PostUp": self.PostUp,
|
||||
"PostDown": self.PostDown,
|
||||
"SaveConfig": self.SaveConfig,
|
||||
"Info": self.configurationInfo.model_dump(),
|
||||
"DataUsage": {
|
||||
"Total": sum(list(map(lambda x: x.cumu_data + x.total_data, self.Peers))),
|
||||
"Sent": sum(list(map(lambda x: x.cumu_sent + x.total_sent, self.Peers))),
|
||||
"Receive": sum(list(map(lambda x: x.cumu_receive + x.total_receive, self.Peers)))
|
||||
},
|
||||
"ConnectedPeers": len(list(filter(lambda x: x.status == "running", self.Peers))),
|
||||
"TotalPeers": len(self.Peers),
|
||||
"Protocol": self.Protocol,
|
||||
"Table": self.Table,
|
||||
"Jc": self.Jc,
|
||||
"Jmin": self.Jmin,
|
||||
"Jmax": self.Jmax,
|
||||
"S1": self.S1,
|
||||
"S2": self.S2,
|
||||
"H1": self.H1,
|
||||
"H2": self.H2,
|
||||
"H3": self.H3,
|
||||
"H4": self.H4
|
||||
}
|
||||
|
||||
def createDatabase(self, dbName = None):
|
||||
if dbName is None:
|
||||
dbName = self.Name
|
||||
|
||||
|
||||
self.peersTable = sqlalchemy.Table(
|
||||
dbName, self.metadata,
|
||||
sqlalchemy.Column('id', sqlalchemy.String(255), nullable=False, primary_key=True),
|
||||
sqlalchemy.Column('private_key', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('DNS', sqlalchemy.Text),
|
||||
sqlalchemy.Column('advanced_security', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('endpoint_allowed_ip', sqlalchemy.Text),
|
||||
sqlalchemy.Column('name', sqlalchemy.Text),
|
||||
sqlalchemy.Column('total_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('status', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('latest_handshake', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('allowed_ip', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('cumu_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('mtu', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('keepalive', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('remote_endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('preshared_key', sqlalchemy.String(255)),
|
||||
extend_existing=True
|
||||
)
|
||||
self.peersRestrictedTable = sqlalchemy.Table(
|
||||
f'{dbName}_restrict_access', self.metadata,
|
||||
sqlalchemy.Column('id', sqlalchemy.String(255), nullable=False, primary_key=True),
|
||||
sqlalchemy.Column('private_key', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('DNS', sqlalchemy.Text),
|
||||
sqlalchemy.Column('advanced_security', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('endpoint_allowed_ip', sqlalchemy.Text),
|
||||
sqlalchemy.Column('name', sqlalchemy.Text),
|
||||
sqlalchemy.Column('total_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('status', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('latest_handshake', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('allowed_ip', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('cumu_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('mtu', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('keepalive', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('remote_endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('preshared_key', sqlalchemy.String(255)),
|
||||
extend_existing=True
|
||||
)
|
||||
self.peersTransferTable = sqlalchemy.Table(
|
||||
f'{dbName}_transfer', self.metadata,
|
||||
sqlalchemy.Column('id', sqlalchemy.String(255), nullable=False),
|
||||
sqlalchemy.Column('total_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('time', (sqlalchemy.DATETIME if self.DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else sqlalchemy.TIMESTAMP),
|
||||
server_default=sqlalchemy.func.now()),
|
||||
extend_existing=True
|
||||
)
|
||||
self.peersDeletedTable = sqlalchemy.Table(
|
||||
f'{dbName}_deleted', self.metadata,
|
||||
sqlalchemy.Column('id', sqlalchemy.String(255), nullable=False),
|
||||
sqlalchemy.Column('private_key', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('DNS', sqlalchemy.Text),
|
||||
sqlalchemy.Column('advanced_security', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('endpoint_allowed_ip', sqlalchemy.Text),
|
||||
sqlalchemy.Column('name', sqlalchemy.Text),
|
||||
sqlalchemy.Column('total_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('total_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('status', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('latest_handshake', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('allowed_ip', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('cumu_receive', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_sent', sqlalchemy.Float),
|
||||
sqlalchemy.Column('cumu_data', sqlalchemy.Float),
|
||||
sqlalchemy.Column('mtu', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('keepalive', sqlalchemy.Integer),
|
||||
sqlalchemy.Column('remote_endpoint', sqlalchemy.String(255)),
|
||||
sqlalchemy.Column('preshared_key', sqlalchemy.String(255)),
|
||||
extend_existing=True
|
||||
)
|
||||
self.infoTable = sqlalchemy.Table(
|
||||
'ConfigurationsInfo', self.metadata,
|
||||
sqlalchemy.Column('ID', sqlalchemy.String(255), primary_key=True),
|
||||
sqlalchemy.Column('Info', sqlalchemy.Text),
|
||||
extend_existing=True
|
||||
)
|
||||
|
||||
self.peersHistoryEndpointTable = sqlalchemy.Table(
|
||||
f'{dbName}_history_endpoint', self.metadata,
|
||||
sqlalchemy.Column('id', sqlalchemy.String(255), nullable=False),
|
||||
sqlalchemy.Column('endpoint', sqlalchemy.String(255), nullable=False),
|
||||
sqlalchemy.Column('time',
|
||||
(sqlalchemy.DATETIME if self.DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else sqlalchemy.TIMESTAMP)),
|
||||
extend_existing=True
|
||||
)
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
|
||||
def getPeers(self):
|
||||
self.Peers.clear()
|
||||
current_app.logger.info(f"Refreshing {self.Name} peer list")
|
||||
|
||||
if self.configurationFileChanged():
|
||||
with open(self.configPath, 'r') as configFile:
|
||||
p = []
|
||||
pCounter = -1
|
||||
content = configFile.read().split('\n')
|
||||
try:
|
||||
if "[Peer]" not in content:
|
||||
current_app.logger.info(f"{self.Name} config has no [Peer] section")
|
||||
return
|
||||
|
||||
peerStarts = content.index("[Peer]")
|
||||
content = content[peerStarts:]
|
||||
for i in content:
|
||||
if not RegexMatch("#(.*)", i) and not RegexMatch(";(.*)", i):
|
||||
if i == "[Peer]":
|
||||
pCounter += 1
|
||||
p.append({})
|
||||
p[pCounter]["name"] = ""
|
||||
else:
|
||||
if len(i) > 0:
|
||||
split = re.split(r'\s*=\s*', i, 1)
|
||||
if len(split) == 2:
|
||||
p[pCounter][split[0]] = split[1]
|
||||
|
||||
if RegexMatch("#Name# = (.*)", i):
|
||||
split = re.split(r'\s*=\s*', i, 1)
|
||||
if len(split) == 2:
|
||||
p[pCounter]["name"] = split[1]
|
||||
with self.engine.begin() as conn:
|
||||
for i in p:
|
||||
if "PublicKey" in i.keys():
|
||||
tempPeer = conn.execute(self.peersTable.select().where(
|
||||
self.peersTable.columns.id == i['PublicKey']
|
||||
)).mappings().fetchone()
|
||||
if tempPeer is None:
|
||||
tempPeer = {
|
||||
"id": i['PublicKey'],
|
||||
"advanced_security": i.get('AdvancedSecurity', 'off'),
|
||||
"private_key": "",
|
||||
"DNS": self.DashboardConfig.GetConfig("Peers", "peer_global_DNS")[1],
|
||||
"endpoint_allowed_ip": self.DashboardConfig.GetConfig("Peers", "peer_endpoint_allowed_ip")[
|
||||
1],
|
||||
"name": i.get("name"),
|
||||
"total_receive": 0,
|
||||
"total_sent": 0,
|
||||
"total_data": 0,
|
||||
"endpoint": "N/A",
|
||||
"status": "stopped",
|
||||
"latest_handshake": "N/A",
|
||||
"allowed_ip": i.get("AllowedIPs", "N/A"),
|
||||
"cumu_receive": 0,
|
||||
"cumu_sent": 0,
|
||||
"cumu_data": 0,
|
||||
"mtu": self.DashboardConfig.GetConfig("Peers", "peer_mtu")[1],
|
||||
"keepalive": self.DashboardConfig.GetConfig("Peers", "peer_keep_alive")[1],
|
||||
"remote_endpoint": self.DashboardConfig.GetConfig("Peers", "remote_endpoint")[1],
|
||||
"preshared_key": i["PresharedKey"] if "PresharedKey" in i.keys() else ""
|
||||
}
|
||||
conn.execute(
|
||||
self.peersTable.insert().values(tempPeer)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
self.peersTable.update().values({
|
||||
"allowed_ip": i.get("AllowedIPs", "N/A")
|
||||
}).where(
|
||||
self.peersTable.columns.id == i['PublicKey']
|
||||
)
|
||||
)
|
||||
self.Peers.append(AmneziaWGPeer(tempPeer, self))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"{self.Name} getPeers() Error", e)
|
||||
else:
|
||||
with self.engine.connect() as conn:
|
||||
existingPeers = conn.execute(self.peersTable.select()).mappings().fetchall()
|
||||
for i in existingPeers:
|
||||
self.Peers.append(AmneziaWGPeer(i, self))
|
||||
|
||||
def addPeers(self, peers: list) -> tuple[bool, list, str]:
|
||||
result = {
|
||||
"message": None,
|
||||
"peers": []
|
||||
}
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
for i in peers:
|
||||
newPeer = {
|
||||
"id": i['id'],
|
||||
"private_key": i['private_key'],
|
||||
"DNS": i['DNS'],
|
||||
"endpoint_allowed_ip": i['endpoint_allowed_ip'],
|
||||
"name": i['name'],
|
||||
"total_receive": 0,
|
||||
"total_sent": 0,
|
||||
"total_data": 0,
|
||||
"endpoint": "N/A",
|
||||
"status": "stopped",
|
||||
"latest_handshake": "N/A",
|
||||
"allowed_ip": i.get("allowed_ip", "N/A"),
|
||||
"cumu_receive": 0,
|
||||
"cumu_sent": 0,
|
||||
"cumu_data": 0,
|
||||
"mtu": i['mtu'],
|
||||
"keepalive": i['keepalive'],
|
||||
"remote_endpoint": self.DashboardConfig.GetConfig("Peers", "remote_endpoint")[1],
|
||||
"preshared_key": i["preshared_key"],
|
||||
"advanced_security": i['advanced_security']
|
||||
}
|
||||
conn.execute(
|
||||
self.peersTable.insert().values(newPeer)
|
||||
)
|
||||
for p in peers:
|
||||
presharedKeyExist = len(p['preshared_key']) > 0
|
||||
rd = random.Random()
|
||||
uid = str(uuid.UUID(int=rd.getrandbits(128), version=4))
|
||||
if presharedKeyExist:
|
||||
with open(uid, "w+") as f:
|
||||
f.write(p['preshared_key'])
|
||||
|
||||
subprocess.check_output(
|
||||
f"{self.Protocol} set {self.Name} peer {p['id']} allowed-ips {p['allowed_ip'].replace(' ', '')}{f' preshared-key {uid}' if presharedKeyExist else ''}",
|
||||
shell=True, stderr=subprocess.STDOUT)
|
||||
if presharedKeyExist:
|
||||
os.remove(uid)
|
||||
subprocess.check_output(
|
||||
f"{self.Protocol}-quick save {self.Name}", shell=True, stderr=subprocess.STDOUT)
|
||||
self.getPeers()
|
||||
for p in peers:
|
||||
p = self.searchPeer(p['id'])
|
||||
if p[0]:
|
||||
result['peers'].append(p[1])
|
||||
self.DashboardWebHooks.RunWebHook("peer_created", {
|
||||
"configuration": self.Name,
|
||||
"peers": list(map(lambda k : k['id'], peers))
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error("Add peers error", e)
|
||||
return False, [], str(e)
|
||||
return True, result['peers'], ""
|
||||
|
||||
def getRestrictedPeers(self):
|
||||
self.RestrictedPeers = []
|
||||
with self.engine.connect() as conn:
|
||||
restricted = conn.execute(self.peersRestrictedTable.select()).mappings().fetchall()
|
||||
for i in restricted:
|
||||
self.RestrictedPeers.append(AmneziaWGPeer(i, self))
|
25
src/modules/ConnectionString.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import configparser
|
||||
import os
|
||||
from sqlalchemy_utils import database_exists, create_database
|
||||
from flask import current_app
|
||||
|
||||
def ConnectionString(database) -> str:
|
||||
parser = configparser.ConfigParser(strict=False)
|
||||
parser.read_file(open('wg-dashboard.ini', "r+"))
|
||||
sqlitePath = os.path.join("db")
|
||||
if not os.path.isdir(sqlitePath):
|
||||
os.mkdir(sqlitePath)
|
||||
if parser.get("Database", "type") == "postgresql":
|
||||
cn = f'postgresql+psycopg://{parser.get("Database", "username")}:{parser.get("Database", "password")}@{parser.get("Database", "host")}/{database}'
|
||||
elif parser.get("Database", "type") == "mysql":
|
||||
cn = f'mysql+pymysql://{parser.get("Database", "username")}:{parser.get("Database", "password")}@{parser.get("Database", "host")}/{database}'
|
||||
else:
|
||||
cn = f'sqlite:///{os.path.join(sqlitePath, f"{database}.db")}'
|
||||
try:
|
||||
if not database_exists(cn):
|
||||
create_database(cn)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Database error. Terminating...", e)
|
||||
exit(1)
|
||||
|
||||
return cn
|
11
src/modules/DashboardAPIKey.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Dashboard API Key
|
||||
"""
|
||||
class DashboardAPIKey:
|
||||
def __init__(self, Key: str, CreatedAt: str, ExpiredAt: str):
|
||||
self.Key = Key
|
||||
self.CreatedAt = CreatedAt
|
||||
self.ExpiredAt = ExpiredAt
|
||||
|
||||
def toJson(self):
|
||||
return self.__dict__
|
498
src/modules/DashboardClients.py
Normal file
@@ -0,0 +1,498 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import random
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
import pyotp
|
||||
import sqlalchemy as db
|
||||
import requests
|
||||
|
||||
from .ConnectionString import ConnectionString
|
||||
from .DashboardClientsPeerAssignment import DashboardClientsPeerAssignment
|
||||
from .DashboardClientsTOTP import DashboardClientsTOTP
|
||||
from .DashboardOIDC import DashboardOIDC
|
||||
from .Utilities import ValidatePasswordStrength
|
||||
from .DashboardLogger import DashboardLogger
|
||||
from flask import session
|
||||
|
||||
|
||||
class DashboardClients:
|
||||
def __init__(self, wireguardConfigurations):
|
||||
self.logger = DashboardLogger()
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.OIDC = DashboardOIDC("Client")
|
||||
|
||||
self.dashboardClientsTable = db.Table(
|
||||
'DashboardClients', self.metadata,
|
||||
db.Column('ClientID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('Email', db.String(255), nullable=False, index=True),
|
||||
db.Column('Password', db.String(500)),
|
||||
db.Column('TotpKey', db.String(500)),
|
||||
db.Column('TotpKeyVerified', db.Integer),
|
||||
db.Column('CreatedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('DeletedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP)),
|
||||
extend_existing=True,
|
||||
)
|
||||
|
||||
self.dashboardOIDCClientsTable = db.Table(
|
||||
'DashboardOIDCClients', self.metadata,
|
||||
db.Column('ClientID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('Email', db.String(255), nullable=False, index=True),
|
||||
db.Column('ProviderIssuer', db.String(500), nullable=False, index=True),
|
||||
db.Column('ProviderSubject', db.String(500), nullable=False, index=True),
|
||||
db.Column('CreatedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('DeletedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP)),
|
||||
extend_existing=True,
|
||||
)
|
||||
|
||||
self.dashboardClientsInfoTable = db.Table(
|
||||
'DashboardClientsInfo', self.metadata,
|
||||
db.Column('ClientID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('Name', db.String(500)),
|
||||
extend_existing=True,
|
||||
)
|
||||
|
||||
self.dashboardClientsPasswordResetLinkTable = db.Table(
|
||||
'DashboardClientsPasswordResetLinks', self.metadata,
|
||||
db.Column('ResetToken', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('ClientID', db.String(255), nullable=False),
|
||||
db.Column('CreatedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('ExpiryDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP)),
|
||||
extend_existing=True
|
||||
)
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.Clients = {}
|
||||
self.ClientsRaw = []
|
||||
self.__getClients()
|
||||
self.DashboardClientsTOTP = DashboardClientsTOTP()
|
||||
self.DashboardClientsPeerAssignment = DashboardClientsPeerAssignment(wireguardConfigurations)
|
||||
|
||||
def __getClients(self):
|
||||
with self.engine.connect() as conn:
|
||||
localClients = db.select(
|
||||
self.dashboardClientsTable.c.ClientID,
|
||||
self.dashboardClientsTable.c.Email,
|
||||
db.literal_column("'Local'").label("ClientGroup")
|
||||
).where(
|
||||
self.dashboardClientsTable.c.DeletedDate.is_(None)
|
||||
)
|
||||
|
||||
oidcClients = db.select(
|
||||
self.dashboardOIDCClientsTable.c.ClientID,
|
||||
self.dashboardOIDCClientsTable.c.Email,
|
||||
self.dashboardOIDCClientsTable.c.ProviderIssuer.label("ClientGroup"),
|
||||
).where(
|
||||
self.dashboardOIDCClientsTable.c.DeletedDate.is_(None)
|
||||
)
|
||||
|
||||
union = db.union(localClients, oidcClients).alias("U")
|
||||
|
||||
self.ClientsRaw = conn.execute(
|
||||
db.select(
|
||||
union,
|
||||
self.dashboardClientsInfoTable.c.Name
|
||||
).outerjoin(self.dashboardClientsInfoTable,
|
||||
union.c.ClientID == self.dashboardClientsInfoTable.c.ClientID)
|
||||
).mappings().fetchall()
|
||||
|
||||
groups = set(map(lambda c: c.get('ClientGroup'), self.ClientsRaw))
|
||||
gr = {}
|
||||
for g in groups:
|
||||
gr[(g if g == 'Local' else self.OIDC.GetProviderNameByIssuer(g))] = [
|
||||
dict(x) for x in list(
|
||||
filter(lambda c: c.get('ClientGroup') == g, self.ClientsRaw)
|
||||
)
|
||||
]
|
||||
self.Clients = gr
|
||||
|
||||
def GetAllClients(self):
|
||||
self.__getClients()
|
||||
return self.Clients
|
||||
|
||||
def GetAllClientsRaw(self):
|
||||
self.__getClients()
|
||||
return self.ClientsRaw
|
||||
|
||||
def GetClient(self, ClientID) -> dict[str, str] | None:
|
||||
c = filter(lambda x: x['ClientID'] == ClientID, self.ClientsRaw)
|
||||
client = next((dict(client) for client in c), None)
|
||||
if client is not None:
|
||||
client['ClientGroup'] = self.OIDC.GetProviderNameByIssuer(client['ClientGroup'])
|
||||
return client
|
||||
|
||||
def GetClientProfile(self, ClientID):
|
||||
with self.engine.connect() as conn:
|
||||
return dict(conn.execute(
|
||||
db.select(
|
||||
*[c for c in self.dashboardClientsInfoTable.c if c.name != 'ClientID']
|
||||
).where(
|
||||
self.dashboardClientsInfoTable.c.ClientID == ClientID
|
||||
)
|
||||
).mappings().fetchone())
|
||||
|
||||
def SignIn_ValidatePassword(self, Email, Password) -> bool:
|
||||
if not all([Email, Password]):
|
||||
return False
|
||||
existingClient = self.SignIn_UserExistence(Email)
|
||||
if existingClient:
|
||||
return bcrypt.checkpw(Password.encode("utf-8"), existingClient.get("Password").encode("utf-8"))
|
||||
return False
|
||||
|
||||
def SignIn_UserExistence(self, Email):
|
||||
with self.engine.connect() as conn:
|
||||
existingClient = conn.execute(
|
||||
self.dashboardClientsTable.select().where(
|
||||
self.dashboardClientsTable.c.Email == Email
|
||||
)
|
||||
).mappings().fetchone()
|
||||
return existingClient
|
||||
|
||||
def SignIn_OIDC_UserExistence(self, data: dict[str, str]):
|
||||
with self.engine.connect() as conn:
|
||||
existingClient = conn.execute(
|
||||
self.dashboardOIDCClientsTable.select().where(
|
||||
db.and_(
|
||||
self.dashboardOIDCClientsTable.c.ProviderIssuer == data.get('iss'),
|
||||
self.dashboardOIDCClientsTable.c.ProviderSubject == data.get('sub'),
|
||||
)
|
||||
)
|
||||
).mappings().fetchone()
|
||||
return existingClient
|
||||
|
||||
def SignUp_OIDC(self, data: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
|
||||
if not self.SignIn_OIDC_UserExistence(data):
|
||||
with self.engine.begin() as conn:
|
||||
newClientUUID = str(uuid.uuid4())
|
||||
conn.execute(
|
||||
self.dashboardOIDCClientsTable.insert().values({
|
||||
"ClientID": newClientUUID,
|
||||
"Email": data.get('email', ''),
|
||||
"ProviderIssuer": data.get('iss', ''),
|
||||
"ProviderSubject": data.get('sub', '')
|
||||
})
|
||||
)
|
||||
conn.execute(
|
||||
self.dashboardClientsInfoTable.insert().values({
|
||||
"ClientID": newClientUUID,
|
||||
"Name": data.get("name")
|
||||
})
|
||||
)
|
||||
self.logger.log(Message=f"User {data.get('email', '')} from {data.get('iss', '')} signed up")
|
||||
self.__getClients()
|
||||
return True, newClientUUID
|
||||
return False, "User already signed up"
|
||||
|
||||
def SignOut_OIDC(self):
|
||||
sessionPayload = session.get('OIDCPayload')
|
||||
status, oidc_config = self.OIDC.GetProviderConfiguration(session.get('SignInPayload').get("Provider"))
|
||||
signOut = requests.get(
|
||||
oidc_config.get("end_session_endpoint"),
|
||||
params={
|
||||
'id_token_hint': session.get('SignInPayload').get("Payload").get('sid')
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def SignIn_OIDC(self, **kwargs):
|
||||
status, data = self.OIDC.VerifyToken(**kwargs)
|
||||
if not status:
|
||||
return False, "Sign in failed. Reason: " + data
|
||||
existingClient = self.SignIn_OIDC_UserExistence(data)
|
||||
if not existingClient:
|
||||
status, newClientUUID = self.SignUp_OIDC(data)
|
||||
session['ClientID'] = newClientUUID
|
||||
else:
|
||||
session['ClientID'] = existingClient.get("ClientID")
|
||||
session['SignInMethod'] = 'OIDC'
|
||||
session['SignInPayload'] = {
|
||||
"Provider": kwargs.get('provider'),
|
||||
"Payload": data
|
||||
}
|
||||
return True, data
|
||||
|
||||
def SignIn(self, Email, Password) -> tuple[bool, str]:
|
||||
if not all([Email, Password]):
|
||||
return False, "Please fill in all fields"
|
||||
existingClient = self.SignIn_UserExistence(Email)
|
||||
if existingClient:
|
||||
checkPwd = self.SignIn_ValidatePassword(Email, Password)
|
||||
if checkPwd:
|
||||
session['SignInMethod'] = 'local'
|
||||
session['Email'] = Email
|
||||
session['ClientID'] = existingClient.get("ClientID")
|
||||
return True, self.DashboardClientsTOTP.GenerateToken(existingClient.get("ClientID"))
|
||||
return False, "Email or Password is incorrect"
|
||||
|
||||
def SignIn_GetTotp(self, Token: str, UserProvidedTotp: str = None) -> tuple[bool, str] or tuple[bool, None, str]:
|
||||
status, data = self.DashboardClientsTOTP.GetTotp(Token)
|
||||
|
||||
if not status:
|
||||
return False, "TOTP Token is invalid"
|
||||
if UserProvidedTotp is None:
|
||||
if data.get('TotpKeyVerified') is None:
|
||||
return True, pyotp.totp.TOTP(data.get('TotpKey')).provisioning_uri(name=data.get('Email'),
|
||||
issuer_name="WGDashboard Client")
|
||||
else:
|
||||
totpMatched = pyotp.totp.TOTP(data.get('TotpKey')).verify(UserProvidedTotp)
|
||||
if not totpMatched:
|
||||
return False, "TOTP is does not match"
|
||||
else:
|
||||
self.DashboardClientsTOTP.RevokeToken(Token)
|
||||
if data.get('TotpKeyVerified') is None:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.update().values({
|
||||
'TotpKeyVerified': 1
|
||||
}).where(
|
||||
self.dashboardClientsTable.c.ClientID == data.get('ClientID')
|
||||
)
|
||||
)
|
||||
|
||||
return True, None
|
||||
|
||||
def SignUp(self, Email, Password, ConfirmPassword) -> tuple[bool, str] or tuple[bool, None]:
|
||||
try:
|
||||
if not all([Email, Password, ConfirmPassword]):
|
||||
return False, "Please fill in all fields"
|
||||
if Password != ConfirmPassword:
|
||||
return False, "Passwords does not match"
|
||||
|
||||
existingClient = self.SignIn_UserExistence(Email)
|
||||
if existingClient:
|
||||
return False, "Email already signed up"
|
||||
|
||||
pwStrength, msg = ValidatePasswordStrength(Password)
|
||||
if not pwStrength:
|
||||
return pwStrength, msg
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
newClientUUID = str(uuid.uuid4())
|
||||
totpKey = pyotp.random_base32()
|
||||
encodePassword = Password.encode('utf-8')
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.insert().values({
|
||||
"ClientID": newClientUUID,
|
||||
"Email": Email,
|
||||
"Password": bcrypt.hashpw(encodePassword, bcrypt.gensalt()).decode("utf-8"),
|
||||
"TotpKey": totpKey
|
||||
})
|
||||
)
|
||||
conn.execute(
|
||||
self.dashboardClientsInfoTable.insert().values({
|
||||
"ClientID": newClientUUID
|
||||
})
|
||||
)
|
||||
self.logger.log(Message=f"User {Email} signed up")
|
||||
self.__getClients()
|
||||
except Exception as e:
|
||||
self.logger.log(Status="false", Message=f"Signed up failed, reason: {str(e)}")
|
||||
return False, "Signe up failed."
|
||||
|
||||
return True, None
|
||||
|
||||
def GetClientAssignedPeers(self, ClientID):
|
||||
return self.DashboardClientsPeerAssignment.GetAssignedPeers(ClientID)
|
||||
|
||||
def ResetClientPassword(self, ClientID, NewPassword, ConfirmNewPassword) -> tuple[bool, str] | tuple[bool, None]:
|
||||
c = self.GetClient(ClientID)
|
||||
if c is None:
|
||||
return False, "Client does not exist"
|
||||
|
||||
if NewPassword != ConfirmNewPassword:
|
||||
return False, "New passwords does not match"
|
||||
|
||||
pwStrength, msg = ValidatePasswordStrength(NewPassword)
|
||||
if not pwStrength:
|
||||
return pwStrength, msg
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.update().values({
|
||||
"TotpKeyVerified": None,
|
||||
"TotpKey": pyotp.random_base32(),
|
||||
"Password": bcrypt.hashpw(NewPassword.encode('utf-8'), bcrypt.gensalt()).decode("utf-8"),
|
||||
}).where(
|
||||
self.dashboardClientsTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
self.logger.log(Message=f"User {ClientID} reset password and TOTP")
|
||||
except Exception as e:
|
||||
self.logger.log(Status="false", Message=f"User {ClientID} reset password failed, reason: {str(e)}")
|
||||
return False, "Reset password failed."
|
||||
|
||||
|
||||
return True, None
|
||||
|
||||
def UpdateClientPassword(self, ClientID, CurrentPassword, NewPassword, ConfirmNewPassword) -> tuple[bool, str] | tuple[bool, None]:
|
||||
c = self.GetClient(ClientID)
|
||||
if c is None:
|
||||
return False, "Client does not exist"
|
||||
|
||||
if not all([CurrentPassword, NewPassword, ConfirmNewPassword]):
|
||||
return False, "Please fill in all fields"
|
||||
|
||||
if not self.SignIn_ValidatePassword(c.get('Email'), CurrentPassword):
|
||||
return False, "Current password does not match"
|
||||
|
||||
if NewPassword != ConfirmNewPassword:
|
||||
return False, "New passwords does not match"
|
||||
|
||||
pwStrength, msg = ValidatePasswordStrength(NewPassword)
|
||||
if not pwStrength:
|
||||
return pwStrength, msg
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.update().values({
|
||||
"Password": bcrypt.hashpw(NewPassword.encode('utf-8'), bcrypt.gensalt()).decode("utf-8"),
|
||||
}).where(
|
||||
self.dashboardClientsTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
self.logger.log(Message=f"User {ClientID} updated password")
|
||||
except Exception as e:
|
||||
self.logger.log(Status="false", Message=f"User {ClientID} update password failed, reason: {str(e)}")
|
||||
return False, "Update password failed."
|
||||
return True, None
|
||||
|
||||
def UpdateClientProfile(self, ClientID, Name):
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsInfoTable.update().values({
|
||||
"Name": Name
|
||||
}).where(
|
||||
self.dashboardClientsInfoTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
self.logger.log(Message=f"User {ClientID} updated name to {Name}")
|
||||
except Exception as e:
|
||||
self.logger.log(Status="false", Message=f"User {ClientID} updated name to {Name} failed")
|
||||
return False
|
||||
return True
|
||||
|
||||
def DeleteClient(self, ClientID):
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
client = self.GetClient(ClientID)
|
||||
if client.get("ClientGroup") == "Local":
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.delete().where(
|
||||
self.dashboardClientsTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
self.dashboardOIDCClientsTable.delete().where(
|
||||
self.dashboardOIDCClientsTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
self.dashboardClientsInfoTable.delete().where(
|
||||
self.dashboardClientsInfoTable.c.ClientID == ClientID
|
||||
)
|
||||
)
|
||||
self.DashboardClientsPeerAssignment.UnassignPeers(ClientID)
|
||||
self.__getClients()
|
||||
except Exception as e:
|
||||
self.logger.log(Status="false", Message=f"Failed to delete {ClientID}")
|
||||
return False
|
||||
return True
|
||||
|
||||
'''
|
||||
For WGDashboard Admin to Manage Clients
|
||||
'''
|
||||
|
||||
def GenerateClientPasswordResetToken(self, ClientID) -> bool | str:
|
||||
c = self.GetClient(ClientID)
|
||||
if c is None:
|
||||
return False
|
||||
|
||||
newToken = str(random.randint(0, 999999)).zfill(6)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsPasswordResetLinkTable.update().values({
|
||||
"ExpiryDate": datetime.datetime.now()
|
||||
|
||||
}).where(
|
||||
db.and_(
|
||||
self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID,
|
||||
self.dashboardClientsPasswordResetLinkTable.c.ExpiryDate > db.func.now()
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
self.dashboardClientsPasswordResetLinkTable.insert().values({
|
||||
"ResetToken": newToken,
|
||||
"ClientID": ClientID,
|
||||
"CreatedDate": datetime.datetime.now(),
|
||||
"ExpiryDate": datetime.datetime.now() + datetime.timedelta(minutes=30)
|
||||
})
|
||||
)
|
||||
|
||||
return newToken
|
||||
|
||||
def ValidateClientPasswordResetToken(self, ClientID, Token):
|
||||
c = self.GetClient(ClientID)
|
||||
if c is None:
|
||||
return False
|
||||
with self.engine.connect() as conn:
|
||||
t = conn.execute(
|
||||
self.dashboardClientsPasswordResetLinkTable.select().where(
|
||||
db.and_(self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID,
|
||||
self.dashboardClientsPasswordResetLinkTable.c.ResetToken == Token,
|
||||
self.dashboardClientsPasswordResetLinkTable.c.ExpiryDate > datetime.datetime.now())
|
||||
|
||||
)
|
||||
).mappings().fetchone()
|
||||
return t is not None
|
||||
|
||||
def RevokeClientPasswordResetToken(self, ClientID, Token):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsPasswordResetLinkTable.update().values({
|
||||
"ExpiryDate": datetime.datetime.now()
|
||||
}).where(
|
||||
db.and_(self.dashboardClientsPasswordResetLinkTable.c.ClientID == ClientID,
|
||||
self.dashboardClientsPasswordResetLinkTable.c.ResetToken == Token)
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def GetAssignedPeerClients(self, ConfigurationName, PeerID):
|
||||
c = self.DashboardClientsPeerAssignment.GetAssignedClients(ConfigurationName, PeerID)
|
||||
for a in c:
|
||||
client = self.GetClient(a.ClientID)
|
||||
if client is not None:
|
||||
a.Client = self.GetClient(a.ClientID)
|
||||
return c
|
||||
|
||||
def GetClientAssignedPeersGrouped(self, ClientID):
|
||||
client = self.GetClient(ClientID)
|
||||
if client is not None:
|
||||
p = self.DashboardClientsPeerAssignment.GetAssignedPeers(ClientID)
|
||||
configs = set(map(lambda x : x['configuration_name'], p))
|
||||
d = {}
|
||||
for i in configs:
|
||||
d[i] = list(filter(lambda x : x['configuration_name'] == i, p))
|
||||
return d
|
||||
return None
|
||||
|
||||
def AssignClient(self, ConfigurationName, PeerID, ClientID) -> tuple[bool, dict[str, str]] | tuple[bool, None]:
|
||||
return self.DashboardClientsPeerAssignment.AssignClient(ClientID, ConfigurationName, PeerID)
|
||||
|
||||
def UnassignClient(self, AssignmentID):
|
||||
return self.DashboardClientsPeerAssignment.UnassignClients(AssignmentID)
|
||||
|
159
src/modules/DashboardClientsPeerAssignment.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from .ConnectionString import ConnectionString
|
||||
from .DashboardLogger import DashboardLogger
|
||||
import sqlalchemy as db
|
||||
from .WireguardConfiguration import WireguardConfiguration
|
||||
|
||||
class Assignment:
|
||||
def __init__(self, **kwargs):
|
||||
self.AssignmentID: str = kwargs.get('AssignmentID')
|
||||
self.ClientID: str = kwargs.get('ClientID')
|
||||
self.ConfigurationName: str = kwargs.get('ConfigurationName')
|
||||
self.PeerID: str = kwargs.get('PeerID')
|
||||
self.AssignedDate: datetime.datetime = kwargs.get('AssignedDate')
|
||||
self.UnassignedDate: datetime.datetime = kwargs.get('UnassignedDate')
|
||||
self.Client: dict = {
|
||||
"ClientID": self.ClientID
|
||||
}
|
||||
|
||||
def toJson(self):
|
||||
return {
|
||||
"AssignmentID": self.AssignmentID,
|
||||
"Client": self.Client,
|
||||
"ConfigurationName": self.ConfigurationName,
|
||||
"PeerID": self.PeerID,
|
||||
"AssignedDate": self.AssignedDate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"UnassignedDate": self.UnassignedDate.strftime("%Y-%m-%d %H:%M:%S") if self.UnassignedDate is not None else self.UnassignedDate
|
||||
}
|
||||
|
||||
class DashboardClientsPeerAssignment:
|
||||
def __init__(self, wireguardConfigurations: dict[str, WireguardConfiguration]):
|
||||
self.logger = DashboardLogger()
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.wireguardConfigurations = wireguardConfigurations
|
||||
self.dashboardClientsPeerAssignmentTable = db.Table(
|
||||
'DashboardClientsPeerAssignment', self.metadata,
|
||||
db.Column('AssignmentID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('ClientID', db.String(255), nullable=False, index=True),
|
||||
db.Column('ConfigurationName', db.String(255)),
|
||||
db.Column('PeerID', db.String(500)),
|
||||
db.Column('AssignedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('UnassignedDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP)),
|
||||
extend_existing=True
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.assignments: list[Assignment] = []
|
||||
self.__getAssignments()
|
||||
|
||||
def __getAssignments(self):
|
||||
with self.engine.connect() as conn:
|
||||
assignments = []
|
||||
get = conn.execute(
|
||||
self.dashboardClientsPeerAssignmentTable.select().where(
|
||||
self.dashboardClientsPeerAssignmentTable.c.UnassignedDate.is_(None)
|
||||
)
|
||||
).mappings().fetchall()
|
||||
for a in get:
|
||||
assignments.append(Assignment(**a))
|
||||
self.assignments = assignments
|
||||
|
||||
|
||||
def AssignClient(self, ClientID, ConfigurationName, PeerID):
|
||||
existing = list(
|
||||
filter(lambda e:
|
||||
e.ClientID == ClientID and
|
||||
e.ConfigurationName == ConfigurationName and
|
||||
e.PeerID == PeerID, self.assignments)
|
||||
)
|
||||
if len(existing) == 0:
|
||||
if ConfigurationName in self.wireguardConfigurations.keys():
|
||||
config = self.wireguardConfigurations.get(ConfigurationName)
|
||||
peer = list(filter(lambda x : x.id == PeerID, config.Peers))
|
||||
if len(peer) == 1:
|
||||
with self.engine.begin() as conn:
|
||||
data = {
|
||||
"AssignmentID": str(uuid.uuid4()),
|
||||
"ClientID": ClientID,
|
||||
"ConfigurationName": ConfigurationName,
|
||||
"PeerID": PeerID
|
||||
}
|
||||
conn.execute(
|
||||
self.dashboardClientsPeerAssignmentTable.insert().values(data)
|
||||
)
|
||||
self.__getAssignments()
|
||||
return True, data
|
||||
return False, None
|
||||
|
||||
def UnassignClients(self, AssignmentID):
|
||||
existing = list(
|
||||
filter(lambda e:
|
||||
e.AssignmentID == AssignmentID, self.assignments)
|
||||
)
|
||||
if not existing:
|
||||
return False
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsPeerAssignmentTable.update().values({
|
||||
"UnassignedDate": datetime.datetime.now()
|
||||
}).where(
|
||||
self.dashboardClientsPeerAssignmentTable.c.AssignmentID == AssignmentID
|
||||
)
|
||||
)
|
||||
self.__getAssignments()
|
||||
return True
|
||||
|
||||
def UnassignPeers(self, ClientID):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsPeerAssignmentTable.update().values({
|
||||
"UnassignedDate": datetime.datetime.now()
|
||||
}).where(
|
||||
db.and_(
|
||||
self.dashboardClientsPeerAssignmentTable.c.ClientID == ClientID,
|
||||
self.dashboardClientsPeerAssignmentTable.c.UnassignedDate.is_(db.null())
|
||||
)
|
||||
)
|
||||
)
|
||||
self.__getAssignments()
|
||||
return True
|
||||
|
||||
def GetAssignedClients(self, ConfigurationName, PeerID) -> list[Assignment]:
|
||||
self.__getAssignments()
|
||||
return list(filter(
|
||||
lambda c : c.ConfigurationName == ConfigurationName and
|
||||
c.PeerID == PeerID, self.assignments))
|
||||
|
||||
def GetAssignedPeers(self, ClientID):
|
||||
self.__getAssignments()
|
||||
|
||||
peers = []
|
||||
assigned = filter(lambda e:
|
||||
e.ClientID == ClientID, self.assignments)
|
||||
|
||||
for a in assigned:
|
||||
peer = filter(lambda e : e.id == a.PeerID,
|
||||
self.wireguardConfigurations[a.ConfigurationName].Peers)
|
||||
for p in peer:
|
||||
peers.append({
|
||||
'assignment_id': a.AssignmentID,
|
||||
'protocol': self.wireguardConfigurations[a.ConfigurationName].Protocol,
|
||||
'id': p.id,
|
||||
'private_key': p.private_key,
|
||||
'name': p.name,
|
||||
'received_data': p.total_receive + p.cumu_receive,
|
||||
'sent_data': p.total_sent + p.cumu_sent,
|
||||
'data': p.total_data + p.cumu_data,
|
||||
'status': p.status,
|
||||
'latest_handshake': p.latest_handshake,
|
||||
'allowed_ip': p.allowed_ip,
|
||||
'jobs': p.jobs,
|
||||
'configuration_name': a.ConfigurationName,
|
||||
'peer_configuration_data': p.downloadPeer()
|
||||
})
|
||||
return peers
|
82
src/modules/DashboardClientsTOTP.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import sqlalchemy as db
|
||||
from .ConnectionString import ConnectionString
|
||||
|
||||
|
||||
class DashboardClientsTOTP:
|
||||
def __init__(self):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.dashboardClientsTOTPTable = db.Table(
|
||||
'DashboardClientsTOTPTokens', self.metadata,
|
||||
db.Column("Token", db.String(500), primary_key=True, index=True),
|
||||
db.Column("ClientID", db.String(500), index=True),
|
||||
db.Column(
|
||||
"ExpireTime", (db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP)
|
||||
)
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.metadata.reflect(self.engine)
|
||||
self.dashboardClientsTable = self.metadata.tables['DashboardClients']
|
||||
|
||||
def GenerateToken(self, ClientID) -> str:
|
||||
token = hashlib.sha512(f"{ClientID}_{datetime.datetime.now()}_{uuid.uuid4()}".encode()).hexdigest()
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTOTPTable.update().values({
|
||||
"ExpireTime": datetime.datetime.now()
|
||||
}).where(
|
||||
db.and_(self.dashboardClientsTOTPTable.c.ClientID == ClientID, self.dashboardClientsTOTPTable.c.ExpireTime > datetime.datetime.now())
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
self.dashboardClientsTOTPTable.insert().values({
|
||||
"Token": token,
|
||||
"ClientID": ClientID,
|
||||
"ExpireTime": datetime.datetime.now() + datetime.timedelta(minutes=10)
|
||||
})
|
||||
)
|
||||
return token
|
||||
|
||||
def RevokeToken(self, Token) -> bool:
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTOTPTable.update().values({
|
||||
"ExpireTime": datetime.datetime.now()
|
||||
}).where(
|
||||
self.dashboardClientsTOTPTable.c.Token == Token
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
return False
|
||||
return True
|
||||
|
||||
def GetTotp(self, token: str) -> tuple[bool, dict] or tuple[bool, None]:
|
||||
with self.engine.connect() as conn:
|
||||
totp = conn.execute(
|
||||
db.select(
|
||||
self.dashboardClientsTable.c.ClientID,
|
||||
self.dashboardClientsTable.c.Email,
|
||||
self.dashboardClientsTable.c.TotpKey,
|
||||
self.dashboardClientsTable.c.TotpKeyVerified,
|
||||
).select_from(
|
||||
self.dashboardClientsTOTPTable
|
||||
).where(
|
||||
db.and_(
|
||||
self.dashboardClientsTOTPTable.c.Token == token,
|
||||
self.dashboardClientsTOTPTable.c.ExpireTime > datetime.datetime.now()
|
||||
)
|
||||
).join(
|
||||
self.dashboardClientsTable,
|
||||
self.dashboardClientsTOTPTable.c.ClientID == self.dashboardClientsTable.c.ClientID
|
||||
)
|
||||
).mappings().fetchone()
|
||||
if totp:
|
||||
return True, dict(totp)
|
||||
return False, None
|
||||
|
||||
|
285
src/modules/DashboardConfig.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Dashboard Configuration
|
||||
"""
|
||||
import configparser, secrets, os, pyotp, ipaddress, bcrypt
|
||||
from sqlalchemy_utils import database_exists, create_database
|
||||
import sqlalchemy as db
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from flask import current_app
|
||||
from .ConnectionString import ConnectionString
|
||||
from .Utilities import (
|
||||
GetRemoteEndpoint, ValidateDNSAddress
|
||||
)
|
||||
from .DashboardAPIKey import DashboardAPIKey
|
||||
|
||||
|
||||
|
||||
class DashboardConfig:
|
||||
DashboardVersion = 'v4.3.0.1'
|
||||
ConfigurationPath = os.getenv('CONFIGURATION_PATH', '.')
|
||||
ConfigurationFilePath = os.path.join(ConfigurationPath, 'wg-dashboard.ini')
|
||||
|
||||
def __init__(self):
|
||||
if not os.path.exists(DashboardConfig.ConfigurationFilePath):
|
||||
open(DashboardConfig.ConfigurationFilePath, "x")
|
||||
self.__config = configparser.RawConfigParser(strict=False)
|
||||
self.__config.read_file(open(DashboardConfig.ConfigurationFilePath, "r+"))
|
||||
self.hiddenAttribute = ["totp_key", "auth_req"]
|
||||
self.__default = {
|
||||
"Account": {
|
||||
"username": "admin",
|
||||
"password": "admin",
|
||||
"enable_totp": "false",
|
||||
"totp_verified": "false",
|
||||
"totp_key": pyotp.random_base32()
|
||||
},
|
||||
"Server": {
|
||||
"wg_conf_path": "/etc/wireguard",
|
||||
"awg_conf_path": "/etc/amnezia/amneziawg",
|
||||
"app_prefix": "",
|
||||
"app_ip": "0.0.0.0",
|
||||
"app_port": "10086",
|
||||
"auth_req": "true",
|
||||
"version": DashboardConfig.DashboardVersion,
|
||||
"dashboard_refresh_interval": "60000",
|
||||
"dashboard_peer_list_display": "grid",
|
||||
"dashboard_sort": "status",
|
||||
"dashboard_theme": "dark",
|
||||
"dashboard_api_key": "false",
|
||||
"dashboard_language": "en-US"
|
||||
},
|
||||
"Peers": {
|
||||
"peer_global_DNS": "1.1.1.1",
|
||||
"peer_endpoint_allowed_ip": "0.0.0.0/0",
|
||||
"peer_display_mode": "grid",
|
||||
"remote_endpoint": GetRemoteEndpoint(),
|
||||
"peer_MTU": "1420",
|
||||
"peer_keep_alive": "21"
|
||||
},
|
||||
"Other": {
|
||||
"welcome_session": "true"
|
||||
},
|
||||
"Database":{
|
||||
"type": "sqlite",
|
||||
"host": "",
|
||||
"port": "",
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"Email":{
|
||||
"server": "",
|
||||
"port": "",
|
||||
"encryption": "",
|
||||
"username": "",
|
||||
"email_password": "",
|
||||
"authentication_required": "true",
|
||||
"send_from": "",
|
||||
"email_template": ""
|
||||
},
|
||||
"OIDC": {
|
||||
"admin_enable": "false",
|
||||
"client_enable": "false"
|
||||
},
|
||||
"Clients": {
|
||||
"enable": "true",
|
||||
},
|
||||
"WireGuardConfiguration": {
|
||||
"autostart": ""
|
||||
}
|
||||
}
|
||||
|
||||
for section, keys in self.__default.items():
|
||||
for key, value in keys.items():
|
||||
exist, currentData = self.GetConfig(section, key)
|
||||
if not exist:
|
||||
self.SetConfig(section, key, value, True)
|
||||
|
||||
self.engine = db.create_engine(ConnectionString('wgdashboard'))
|
||||
self.dbMetadata = db.MetaData()
|
||||
self.__createAPIKeyTable()
|
||||
self.DashboardAPIKeys = self.__getAPIKeys()
|
||||
self.APIAccessed = False
|
||||
self.SetConfig("Server", "version", DashboardConfig.DashboardVersion)
|
||||
|
||||
def getConnectionString(self, database) -> str or None:
|
||||
sqlitePath = os.path.join(DashboardConfig.ConfigurationPath, "db")
|
||||
|
||||
if not os.path.isdir(sqlitePath):
|
||||
os.mkdir(sqlitePath)
|
||||
|
||||
if self.GetConfig("Database", "type")[1] == "postgresql":
|
||||
cn = f'postgresql+psycopg2://{self.GetConfig("Database", "username")[1]}:{self.GetConfig("Database", "password")[1]}@{self.GetConfig("Database", "host")[1]}/{database}'
|
||||
elif self.GetConfig("Database", "type")[1] == "mysql":
|
||||
cn = f'mysql+mysqldb://{self.GetConfig("Database", "username")[1]}:{self.GetConfig("Database", "password")[1]}@{self.GetConfig("Database", "host")[1]}/{database}'
|
||||
else:
|
||||
cn = f'sqlite:///{os.path.join(sqlitePath, f"{database}.db")}'
|
||||
if not database_exists(cn):
|
||||
create_database(cn)
|
||||
return cn
|
||||
|
||||
def __createAPIKeyTable(self):
|
||||
self.apiKeyTable = db.Table('DashboardAPIKeys', self.dbMetadata,
|
||||
db.Column("Key", db.String(255), nullable=False, primary_key=True),
|
||||
db.Column("CreatedAt",
|
||||
(db.DATETIME if self.GetConfig('Database', 'type')[1] == 'sqlite' else db.TIMESTAMP),
|
||||
server_default=db.func.now()
|
||||
),
|
||||
db.Column("ExpiredAt",
|
||||
(db.DATETIME if self.GetConfig('Database', 'type')[1] == 'sqlite' else db.TIMESTAMP)
|
||||
)
|
||||
)
|
||||
self.dbMetadata.create_all(self.engine)
|
||||
def __getAPIKeys(self) -> list[DashboardAPIKey]:
|
||||
try:
|
||||
with self.engine.connect() as conn:
|
||||
keys = conn.execute(self.apiKeyTable.select().where(
|
||||
db.or_(self.apiKeyTable.columns.ExpiredAt.is_(None), self.apiKeyTable.columns.ExpiredAt > datetime.now())
|
||||
)).fetchall()
|
||||
fKeys = []
|
||||
for k in keys:
|
||||
fKeys.append(DashboardAPIKey(k[0], k[1].strftime("%Y-%m-%d %H:%M:%S"), (k[2].strftime("%Y-%m-%d %H:%M:%S") if k[2] else None)))
|
||||
return fKeys
|
||||
except Exception as e:
|
||||
current_app.logger.error("API Keys error", e)
|
||||
return []
|
||||
|
||||
def createAPIKeys(self, ExpiredAt = None):
|
||||
newKey = secrets.token_urlsafe(32)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.apiKeyTable.insert().values({
|
||||
"Key": newKey,
|
||||
"ExpiredAt": ExpiredAt
|
||||
})
|
||||
)
|
||||
|
||||
self.DashboardAPIKeys = self.__getAPIKeys()
|
||||
|
||||
def deleteAPIKey(self, key):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.apiKeyTable.update().values({
|
||||
"ExpiredAt": datetime.now(),
|
||||
}).where(self.apiKeyTable.columns.Key == key)
|
||||
)
|
||||
|
||||
self.DashboardAPIKeys = self.__getAPIKeys()
|
||||
|
||||
def __configValidation(self, section : str, key: str, value: Any) -> tuple[bool, str]:
|
||||
if (type(value) is str and len(value) == 0
|
||||
and section not in ['Email', 'WireGuardConfiguration'] and
|
||||
(section == 'Peer' and key == 'peer_global_dns')):
|
||||
return False, "Field cannot be empty!"
|
||||
if section == "Peers" and key == "peer_global_dns" and len(value) > 0:
|
||||
return ValidateDNSAddress(value)
|
||||
if section == "Peers" and key == "peer_endpoint_allowed_ip":
|
||||
value = value.split(",")
|
||||
for i in value:
|
||||
i = i.strip()
|
||||
try:
|
||||
ipaddress.ip_network(i, strict=False)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
if section == "Server" and key == "wg_conf_path":
|
||||
if not os.path.exists(value):
|
||||
return False, f"{value} is not a valid path"
|
||||
if section == "Account" and key == "password":
|
||||
if self.GetConfig("Account", "password")[0]:
|
||||
if not self.__checkPassword(
|
||||
value["currentPassword"], self.GetConfig("Account", "password")[1].encode("utf-8")):
|
||||
return False, "Current password does not match."
|
||||
if value["newPassword"] != value["repeatNewPassword"]:
|
||||
return False, "New passwords does not match"
|
||||
return True, ""
|
||||
|
||||
def generatePassword(self, plainTextPassword: str):
|
||||
return bcrypt.hashpw(plainTextPassword.encode("utf-8"), bcrypt.gensalt())
|
||||
|
||||
def __checkPassword(self, plainTextPassword: str, hashedPassword: bytes):
|
||||
return bcrypt.checkpw(plainTextPassword.encode("utf-8"), hashedPassword)
|
||||
|
||||
def SetConfig(self, section: str, key: str, value: str | bool | list[str] | dict[str, str], init: bool = False) -> tuple[bool, str] | tuple[bool, None]:
|
||||
if key in self.hiddenAttribute and not init:
|
||||
return False, None
|
||||
|
||||
if not init:
|
||||
valid, msg = self.__configValidation(section, key, value)
|
||||
if not valid:
|
||||
return False, msg
|
||||
|
||||
if section == "Account" and key == "password":
|
||||
if not init:
|
||||
value = self.generatePassword(value["newPassword"]).decode("utf-8")
|
||||
else:
|
||||
value = self.generatePassword(value).decode("utf-8")
|
||||
|
||||
if section == "Email" and key == "email_template":
|
||||
value = value.encode('unicode_escape').decode('utf-8')
|
||||
|
||||
if section == "Server" and key == "wg_conf_path":
|
||||
if not os.path.exists(value):
|
||||
return False, "Path does not exist"
|
||||
|
||||
if section not in self.__config:
|
||||
if init:
|
||||
self.__config[section] = {}
|
||||
else:
|
||||
return False, "Section does not exist"
|
||||
|
||||
if ((key not in self.__config[section].keys() and init) or
|
||||
(key in self.__config[section].keys())):
|
||||
if type(value) is bool:
|
||||
if value:
|
||||
self.__config[section][key] = "true"
|
||||
else:
|
||||
self.__config[section][key] = "false"
|
||||
elif type(value) in [int, float]:
|
||||
self.__config[section][key] = str(value)
|
||||
elif type(value) is list:
|
||||
self.__config[section][key] = "||".join(value).strip("||")
|
||||
else:
|
||||
self.__config[section][key] = fr"{value}"
|
||||
return self.SaveConfig(), ""
|
||||
else:
|
||||
return False, f"{key} does not exist under {section}"
|
||||
|
||||
def SaveConfig(self) -> bool:
|
||||
try:
|
||||
with open(DashboardConfig.ConfigurationFilePath, "w+", encoding='utf-8') as configFile:
|
||||
self.__config.write(configFile)
|
||||
return True
|
||||
except Exception as e:
|
||||
return False
|
||||
|
||||
def GetConfig(self, section, key) ->tuple[bool, bool] | tuple[bool, str] | tuple[bool, list[str]] | tuple[bool, None]:
|
||||
if section not in self.__config:
|
||||
return False, None
|
||||
|
||||
if key not in self.__config[section]:
|
||||
return False, None
|
||||
|
||||
if section == "Email" and key == "email_template":
|
||||
return True, self.__config[section][key].encode('utf-8').decode('unicode_escape')
|
||||
|
||||
if section == "WireGuardConfiguration" and key == "autostart":
|
||||
return True, list(filter(lambda x: len(x) > 0, self.__config[section][key].split("||")))
|
||||
|
||||
if self.__config[section][key] in ["1", "yes", "true", "on"]:
|
||||
return True, True
|
||||
|
||||
if self.__config[section][key] in ["0", "no", "false", "off"]:
|
||||
return True, False
|
||||
|
||||
|
||||
return True, self.__config[section][key]
|
||||
|
||||
def toJson(self) -> dict[str, dict[Any, Any]]:
|
||||
the_dict = {}
|
||||
|
||||
for section in self.__config.sections():
|
||||
the_dict[section] = {}
|
||||
for key, val in self.__config.items(section):
|
||||
if key not in self.hiddenAttribute:
|
||||
the_dict[section][key] = self.GetConfig(section, key)[1]
|
||||
return the_dict
|
44
src/modules/DashboardLogger.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Dashboard Logger Class
|
||||
"""
|
||||
import uuid
|
||||
import sqlalchemy as db
|
||||
from flask import current_app
|
||||
from .ConnectionString import ConnectionString
|
||||
|
||||
|
||||
class DashboardLogger:
|
||||
def __init__(self):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard_log"))
|
||||
self.metadata = db.MetaData()
|
||||
self.dashboardLoggerTable = db.Table('DashboardLog', self.metadata,
|
||||
|
||||
db.Column('LogID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('LogDate',
|
||||
(db.DATETIME if 'sqlite:///' in ConnectionString("wgdashboard") else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('URL', db.String(255)),
|
||||
db.Column('IP', db.String(255)),
|
||||
|
||||
db.Column('Status', db.String(255), nullable=False),
|
||||
db.Column('Message', db.Text), extend_existing=True,
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.log(Message="WGDashboard started")
|
||||
|
||||
def log(self, URL: str = "", IP: str = "", Status: str = "true", Message: str = "") -> bool:
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardLoggerTable.insert().values(
|
||||
LogID=str(uuid.uuid4()),
|
||||
URL=URL,
|
||||
IP=IP,
|
||||
Status=Status,
|
||||
Message=Message
|
||||
)
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Access Log Error", e)
|
||||
return False
|
142
src/modules/DashboardOIDC.py
Normal file
@@ -0,0 +1,142 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
from jose import jwt
|
||||
import certifi
|
||||
from flask import current_app
|
||||
|
||||
class DashboardOIDC:
|
||||
ConfigurationPath = os.getenv('CONFIGURATION_PATH', '.')
|
||||
ConfigurationFilePath = os.path.join(ConfigurationPath, 'wg-dashboard-oidc-providers.json')
|
||||
def __init__(self, mode):
|
||||
self.mode = mode
|
||||
self.providers: dict[str, dict] = {}
|
||||
self.provider_secret: dict[str, str] = {}
|
||||
self.__default = {
|
||||
"Admin": {
|
||||
'Provider': {
|
||||
'client_id': '',
|
||||
'client_secret': '',
|
||||
'issuer': '',
|
||||
},
|
||||
},
|
||||
"Client": {
|
||||
'Provider': {
|
||||
'client_id': '',
|
||||
'client_secret': '',
|
||||
'issuer': '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if not os.path.exists(DashboardOIDC.ConfigurationFilePath):
|
||||
with open(DashboardOIDC.ConfigurationFilePath, "w+") as f:
|
||||
encoder = json.JSONEncoder(indent=4)
|
||||
f.write(encoder.encode(self.__default))
|
||||
|
||||
self.ReadFile()
|
||||
|
||||
def GetProviders(self):
|
||||
return self.providers
|
||||
|
||||
def GetProviderNameByIssuer(self, issuer):
|
||||
for (key, val) in self.providers.items():
|
||||
if val.get('openid_configuration').get('issuer') == issuer:
|
||||
return key
|
||||
return issuer
|
||||
|
||||
def VerifyToken(self, provider, code, redirect_uri):
|
||||
try:
|
||||
if not all([provider, code, redirect_uri]):
|
||||
return False, "Please provide all parameters"
|
||||
|
||||
if provider not in self.providers.keys():
|
||||
return False, "Provider does not exist"
|
||||
|
||||
secrete = self.provider_secret.get(provider)
|
||||
oidc_config_status, oidc_config = self.GetProviderConfiguration(provider)
|
||||
provider_info = self.providers.get(provider)
|
||||
|
||||
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": provider_info.get('client_id'),
|
||||
"client_secret": secrete
|
||||
}
|
||||
|
||||
try:
|
||||
tokens = requests.post(oidc_config.get('token_endpoint'), data=data).json()
|
||||
if not all([tokens.get('access_token'), tokens.get('id_token')]):
|
||||
return False, tokens.get('error_description', None)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Verify token failed", e)
|
||||
return False, str(e)
|
||||
|
||||
access_token = tokens.get('access_token')
|
||||
id_token = tokens.get('id_token')
|
||||
jwks_uri = oidc_config.get("jwks_uri")
|
||||
issuer = oidc_config.get("issuer")
|
||||
jwks = requests.get(jwks_uri, verify=certifi.where()).json()
|
||||
|
||||
headers = jwt.get_unverified_header(id_token)
|
||||
kid = headers["kid"]
|
||||
|
||||
key = next(k for k in jwks["keys"] if k["kid"] == kid)
|
||||
|
||||
payload = jwt.decode(
|
||||
id_token,
|
||||
key,
|
||||
algorithms=[key["alg"]],
|
||||
audience=provider_info.get('client_id'),
|
||||
issuer=issuer,
|
||||
access_token=access_token
|
||||
)
|
||||
print(payload)
|
||||
return True, payload
|
||||
except Exception as e:
|
||||
current_app.logger.error('Read OIDC file failed. Reason: ' + str(e), provider, code, redirect_uri)
|
||||
return False, str(e)
|
||||
|
||||
def GetProviderConfiguration(self, provider_name):
|
||||
if not all([provider_name]):
|
||||
return False, None
|
||||
provider = self.providers.get(provider_name)
|
||||
try:
|
||||
oidc_config = requests.get(
|
||||
f"{provider.get('issuer').strip('/')}/.well-known/openid-configuration",
|
||||
verify=certifi.where()
|
||||
).json()
|
||||
except Exception as e:
|
||||
current_app.logger.error("Failed to get OpenID Configuration of " + provider.get('issuer'), exc_info=e)
|
||||
return False, None
|
||||
return True, oidc_config
|
||||
|
||||
def ReadFile(self):
|
||||
decoder = json.JSONDecoder()
|
||||
try:
|
||||
providers = decoder.decode(
|
||||
open(DashboardOIDC.ConfigurationFilePath, 'r').read()
|
||||
)
|
||||
providers = providers[self.mode]
|
||||
for k in providers.keys():
|
||||
if all([providers[k]['client_id'], providers[k]['client_secret'], providers[k]['issuer']]):
|
||||
try:
|
||||
oidc_config = requests.get(
|
||||
f"{providers[k]['issuer'].strip('/')}/.well-known/openid-configuration",
|
||||
timeout=3,
|
||||
verify=certifi.where()
|
||||
).json()
|
||||
self.providers[k] = {
|
||||
'client_id': providers[k]['client_id'],
|
||||
'issuer': providers[k]['issuer'].strip('/'),
|
||||
'openid_configuration': oidc_config
|
||||
}
|
||||
self.provider_secret[k] = providers[k]['client_secret']
|
||||
current_app.logger.info(f"Registered OIDC Provider: {k}")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to register OIDC config for {k}", exc_info=e)
|
||||
except Exception as e:
|
||||
current_app.logger.error('Read OIDC file failed. Reason: ' + str(e))
|
||||
return False
|
117
src/modules/DashboardPlugins.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import os
|
||||
import sys
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Dict, Callable, List, Optional
|
||||
import threading
|
||||
|
||||
|
||||
class DashboardPlugins:
|
||||
|
||||
def __init__(self, app, WireguardConfigurations, directory: str = 'plugins'):
|
||||
self.directory = Path('plugins')
|
||||
self.loadedPlugins: dict[str, Callable] = {}
|
||||
self.errorPlugins: List[str] = []
|
||||
self.logger = app.logger
|
||||
self.WireguardConfigurations = WireguardConfigurations
|
||||
|
||||
def startThreads(self):
|
||||
self.loadAllPlugins()
|
||||
self.executeAllPlugins()
|
||||
|
||||
def preparePlugins(self) -> list[Path]:
|
||||
|
||||
readyPlugins = []
|
||||
|
||||
if not self.directory.exists():
|
||||
os.mkdir(self.directory)
|
||||
return []
|
||||
|
||||
for plugin in self.directory.iterdir():
|
||||
if plugin.is_dir():
|
||||
codeFile = plugin / "main.py"
|
||||
if codeFile.exists():
|
||||
self.logger.info(f"Prepared plugin: {plugin.name}")
|
||||
readyPlugins.append(plugin)
|
||||
|
||||
return readyPlugins
|
||||
|
||||
def loadPlugin(self, path: Path) -> Optional[Callable]:
|
||||
pluginName = path.name
|
||||
codeFile = path / "main.py"
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"WGDashboardPlugin_{pluginName}",
|
||||
codeFile
|
||||
)
|
||||
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Failed to create spec for {pluginName}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
||||
plugin_dir_str = str(path)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
if plugin_dir_str in sys.path:
|
||||
sys.path.remove(plugin_dir_str)
|
||||
|
||||
if hasattr(module, 'main'):
|
||||
main_func = getattr(module, 'main')
|
||||
if callable(main_func):
|
||||
self.logger.info(f"Successfully loaded plugin [{pluginName}]")
|
||||
return main_func
|
||||
else:
|
||||
raise AttributeError(f"'main' in {pluginName} is not callable")
|
||||
else:
|
||||
raise AttributeError(f"Plugin {pluginName} does not have a 'main' function")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load the plugin [{pluginName}]. Reason: {str(e)}")
|
||||
self.errorPlugins.append(pluginName)
|
||||
return None
|
||||
|
||||
def loadAllPlugins(self):
|
||||
self.loadedPlugins.clear()
|
||||
self.errorPlugins.clear()
|
||||
|
||||
preparedPlugins = self.preparePlugins()
|
||||
|
||||
for plugin in preparedPlugins:
|
||||
pluginName = plugin.name
|
||||
mainFunction = self.loadPlugin(plugin)
|
||||
|
||||
if mainFunction:
|
||||
self.loadedPlugins[pluginName] = mainFunction
|
||||
if self.errorPlugins:
|
||||
self.logger.warning(f"Failed to load {len(self.errorPlugins)} plugin(s): {self.errorPlugins}")
|
||||
|
||||
def executePlugin(self, pluginName: str):
|
||||
if pluginName not in self.loadedPlugins.keys():
|
||||
self.logger.error(f"Failed to execute plugin [{pluginName}]. Reason: Not loaded")
|
||||
return False
|
||||
|
||||
plugin = self.loadedPlugins.get(pluginName)
|
||||
|
||||
try:
|
||||
t = threading.Thread(target=plugin, args=(self.WireguardConfigurations,), daemon=True)
|
||||
t.name = f'WGDashboardPlugin_{pluginName}'
|
||||
t.start()
|
||||
|
||||
if t.is_alive():
|
||||
self.logger.info(f"Execute plugin [{pluginName}] success. PID: {t.native_id}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to execute plugin [{pluginName}]. Reason: {str(e)}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def executeAllPlugins(self):
|
||||
for plugin in self.loadedPlugins.keys():
|
||||
self.executePlugin(plugin)
|
287
src/modules/DashboardWebHooks.py
Normal file
@@ -0,0 +1,287 @@
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, field_serializer
|
||||
import sqlalchemy as db
|
||||
from .ConnectionString import ConnectionString
|
||||
from flask import current_app
|
||||
|
||||
WebHookActions = ['peer_created', 'peer_deleted', 'peer_updated']
|
||||
class WebHook(BaseModel):
|
||||
WebHookID: str = ''
|
||||
PayloadURL: str = ''
|
||||
ContentType: str = 'application/json'
|
||||
Headers: dict[str, dict[str, str]] = {}
|
||||
VerifySSL: bool = True
|
||||
SubscribedActions: list[str] = WebHookActions
|
||||
IsActive: bool = True
|
||||
CreationDate: datetime = ''
|
||||
Notes: str = ''
|
||||
|
||||
class WebHookSessionLog(BaseModel):
|
||||
LogTime: datetime
|
||||
Status: int
|
||||
Message: str = ''
|
||||
|
||||
@field_serializer('LogTime')
|
||||
def logTimeSerializer(self, LogTime: datetime):
|
||||
return LogTime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
class WebHookSessionLogs(BaseModel):
|
||||
Logs: list[WebHookSessionLog] = []
|
||||
|
||||
def addLog(self, status: int, message: str):
|
||||
self.Logs.append(WebHookSessionLog(LogTime=datetime.now(), Status=status, Message=message))
|
||||
|
||||
class DashboardWebHooks:
|
||||
def __init__(self, DashboardConfig):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.webHooksTable = db.Table(
|
||||
'DashboardWebHooks', self.metadata,
|
||||
db.Column('WebHookID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('PayloadURL', db.Text, nullable=False),
|
||||
db.Column('ContentType', db.String(255), nullable=False),
|
||||
db.Column('Headers', db.JSON),
|
||||
db.Column('VerifySSL', db.Boolean, nullable=False),
|
||||
db.Column('SubscribedActions', db.JSON),
|
||||
db.Column('IsActive', db.Boolean, nullable=False),
|
||||
db.Column('CreationDate',
|
||||
(db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP),
|
||||
server_default=db.func.now(),
|
||||
nullable=False),
|
||||
db.Column('Notes', db.Text),
|
||||
extend_existing=True
|
||||
)
|
||||
self.webHookSessionsTable = db.Table(
|
||||
'DashboardWebHookSessions', self.metadata,
|
||||
db.Column('WebHookSessionID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('WebHookID', db.String(255), nullable=False),
|
||||
db.Column('StartDate',
|
||||
(db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP),
|
||||
server_default=db.func.now(),
|
||||
nullable=False
|
||||
),
|
||||
db.Column('EndDate',
|
||||
(db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP),
|
||||
),
|
||||
db.Column('Data', db.JSON),
|
||||
db.Column('Status', db.INTEGER),
|
||||
db.Column('Logs', db.JSON)
|
||||
)
|
||||
|
||||
self.metadata.create_all(self.engine)
|
||||
self.WebHooks: list[WebHook] = []
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.webHookSessionsTable.update().values({
|
||||
"EndDate": datetime.now(),
|
||||
"Status": 2
|
||||
}).where(
|
||||
self.webHookSessionsTable.c.Status == -1
|
||||
)
|
||||
)
|
||||
|
||||
self.__getWebHooks()
|
||||
|
||||
def __getWebHooks(self):
|
||||
with self.engine.connect() as conn:
|
||||
webhooks = conn.execute(
|
||||
self.webHooksTable.select().order_by(
|
||||
self.webHooksTable.c.CreationDate
|
||||
)
|
||||
).mappings().fetchall()
|
||||
self.WebHooks.clear()
|
||||
self.WebHooks = [WebHook(**webhook) for webhook in webhooks]
|
||||
|
||||
def GetWebHooks(self):
|
||||
self.__getWebHooks()
|
||||
return list(map(lambda x : x.model_dump(), self.WebHooks))
|
||||
|
||||
def GetWebHookSessions(self, webHook: WebHook):
|
||||
with self.engine.connect() as conn:
|
||||
sessions = conn.execute(
|
||||
self.webHookSessionsTable.select().where(
|
||||
self.webHookSessionsTable.c.WebHookID == webHook.WebHookID
|
||||
).order_by(
|
||||
db.desc(self.webHookSessionsTable.c.StartDate)
|
||||
)
|
||||
).mappings().fetchall()
|
||||
return sessions
|
||||
|
||||
def CreateWebHook(self) -> WebHook:
|
||||
return WebHook(WebHookID=str(uuid.uuid4()))
|
||||
|
||||
def SearchWebHook(self, webHook: WebHook) -> WebHook | None:
|
||||
try:
|
||||
first = next(filter(lambda x : x.WebHookID == webHook.WebHookID, self.WebHooks))
|
||||
except StopIteration:
|
||||
return None
|
||||
return first
|
||||
|
||||
def SearchWebHookByID(self, webHookID: str) -> WebHook | None:
|
||||
try:
|
||||
first = next(filter(lambda x : x.WebHookID == webHookID, self.WebHooks))
|
||||
except StopIteration:
|
||||
return None
|
||||
return first
|
||||
|
||||
def UpdateWebHook(self, webHook: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
webHook = WebHook(**webHook)
|
||||
|
||||
if len(webHook.PayloadURL) == 0:
|
||||
return False, "Payload URL cannot be empty"
|
||||
|
||||
if len(webHook.ContentType) == 0 or webHook.ContentType not in [
|
||||
'application/json', 'application/x-www-form-urlencoded'
|
||||
]:
|
||||
return False, "Content Type is invalid"
|
||||
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
if self.SearchWebHook(webHook):
|
||||
conn.execute(
|
||||
self.webHooksTable.update().values(
|
||||
webHook.model_dump(exclude={'WebHookID'})
|
||||
).where(
|
||||
self.webHooksTable.c.WebHookID == webHook.WebHookID
|
||||
)
|
||||
)
|
||||
else:
|
||||
webHook.CreationDate = datetime.now()
|
||||
conn.execute(
|
||||
self.webHooksTable.insert().values(
|
||||
webHook.model_dump()
|
||||
)
|
||||
)
|
||||
self.__getWebHooks()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, None
|
||||
|
||||
def DeleteWebHook(self, webHook) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
webHook = WebHook(**webHook)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.webHooksTable.delete().where(
|
||||
self.webHooksTable.c.WebHookID == webHook.WebHookID
|
||||
)
|
||||
)
|
||||
self.__getWebHooks()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, None
|
||||
|
||||
def RunWebHook(self, action: str, data):
|
||||
try:
|
||||
if action not in WebHookActions:
|
||||
return False
|
||||
self.__getWebHooks()
|
||||
subscribedWebHooks = filter(lambda webhook: action in webhook.SubscribedActions and webhook.IsActive,
|
||||
self.WebHooks)
|
||||
data['action'] = action
|
||||
for i in subscribedWebHooks:
|
||||
try:
|
||||
ws = WebHookSession(i, data)
|
||||
t = threading.Thread(target=ws.Execute, daemon=True)
|
||||
t.start()
|
||||
current_app.logger.info(f"Requesting {i.PayloadURL}")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Requesting {i.PayloadURL} error", e)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Error when running WebHook")
|
||||
|
||||
class WebHookSession:
|
||||
def __init__(self, webHook: WebHook, data: dict[str, str]):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.webHookSessionsTable = db.Table('DashboardWebHookSessions', self.metadata, autoload_with=self.engine)
|
||||
self.webHook = webHook
|
||||
self.sessionID = str(uuid.uuid4())
|
||||
self.webHookSessionLogs: WebHookSessionLogs = WebHookSessionLogs()
|
||||
self.time = datetime.now()
|
||||
data['time'] = self.time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
data['webhook_id'] = webHook.WebHookID
|
||||
data['webhook_session'] = self.sessionID
|
||||
self.data = data
|
||||
self.Prepare()
|
||||
|
||||
def Prepare(self):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.webHookSessionsTable.insert().values({
|
||||
"WebHookSessionID": self.sessionID,
|
||||
"WebHookID": self.webHook.WebHookID,
|
||||
"Data": self.data,
|
||||
"StartDate": self.time,
|
||||
"Status": -1,
|
||||
"Logs": self.webHookSessionLogs.model_dump()
|
||||
})
|
||||
)
|
||||
self.UpdateSessionLog(-1, "Preparing webhook session")
|
||||
|
||||
def UpdateSessionLog(self, status, message):
|
||||
self.webHookSessionLogs.addLog(status, message)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.webHookSessionsTable.update().values({
|
||||
"Logs": self.webHookSessionLogs.model_dump()
|
||||
}).where(
|
||||
self.webHookSessionsTable.c.WebHookSessionID == self.sessionID
|
||||
)
|
||||
)
|
||||
|
||||
def UpdateStatus(self, status: int):
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.webHookSessionsTable.update().values({
|
||||
"Status": status,
|
||||
"EndDate": datetime.now()
|
||||
}).where(
|
||||
self.webHookSessionsTable.c.WebHookSessionID == self.sessionID
|
||||
)
|
||||
)
|
||||
|
||||
def Execute(self):
|
||||
success = False
|
||||
|
||||
for i in range(5):
|
||||
headerDictionary = {
|
||||
'Content-Type': self.webHook.ContentType
|
||||
}
|
||||
for header in self.webHook.Headers.values():
|
||||
if header['key'] not in ['Content-Type']:
|
||||
headerDictionary[header['key']] = header['value']
|
||||
|
||||
if self.webHook.ContentType == "application/json":
|
||||
reqData = json.dumps(self.data)
|
||||
else:
|
||||
for (key, val) in self.data.items():
|
||||
if type(self.data[key]) not in [str, int]:
|
||||
self.data[key] = json.dumps(self.data[key])
|
||||
reqData = urllib.parse.urlencode(self.data)
|
||||
try:
|
||||
req = requests.post(
|
||||
self.webHook.PayloadURL, headers=headerDictionary, timeout=10, data=reqData, verify=self.webHook.VerifySSL
|
||||
)
|
||||
req.raise_for_status()
|
||||
success = True
|
||||
self.UpdateSessionLog(0, "Webhook request finished")
|
||||
self.UpdateSessionLog(0, json.dumps({"returned_data": req.text}))
|
||||
self.UpdateStatus(0)
|
||||
break
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.UpdateSessionLog(1, f"Attempt #{i + 1}/5. Request errored. Reason: " + str(e))
|
||||
time.sleep(10)
|
||||
|
||||
if not success:
|
||||
self.UpdateSessionLog(1, "Webhook request failed & terminated.")
|
||||
self.UpdateStatus(1)
|
76
src/modules/Email.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import os.path
|
||||
import smtplib
|
||||
from email import encoders
|
||||
from email.header import Header
|
||||
from email.mime.base import MIMEBase
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
class EmailSender:
|
||||
def __init__(self, DashboardConfig):
|
||||
self.smtp = None
|
||||
self.DashboardConfig = DashboardConfig
|
||||
if not os.path.exists('./attachments'):
|
||||
os.mkdir('./attachments')
|
||||
|
||||
def Server(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "server")[1]
|
||||
|
||||
def Port(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "port")[1]
|
||||
|
||||
def Encryption(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "encryption")[1]
|
||||
|
||||
def Username(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "username")[1]
|
||||
|
||||
def Password(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "email_password")[1]
|
||||
|
||||
def SendFrom(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "send_from")[1]
|
||||
|
||||
# Thank you, @gdeeble from GitHub
|
||||
def AuthenticationRequired(self):
|
||||
return self.DashboardConfig.GetConfig("Email", "authentication_required")[1]
|
||||
|
||||
def ready(self):
|
||||
if self.AuthenticationRequired():
|
||||
return all([self.Server(), self.Port(), self.Encryption(), self.Username(), self.Password(), self.SendFrom()])
|
||||
return all([self.Server(), self.Port(), self.Encryption(), self.SendFrom()])
|
||||
|
||||
def send(self, receiver, subject, body, includeAttachment = False, attachmentName = "") -> tuple[bool, str] | tuple[bool, None]:
|
||||
if self.ready():
|
||||
try:
|
||||
self.smtp = smtplib.SMTP(self.Server(), port=int(self.Port()))
|
||||
self.smtp.ehlo()
|
||||
if self.Encryption() == "STARTTLS":
|
||||
self.smtp.starttls()
|
||||
if self.AuthenticationRequired():
|
||||
self.smtp.login(self.Username(), self.Password())
|
||||
message = MIMEMultipart()
|
||||
message['Subject'] = subject
|
||||
message['From'] = self.SendFrom()
|
||||
message["To"] = receiver
|
||||
message.attach(MIMEText(body, "plain"))
|
||||
|
||||
if includeAttachment and len(attachmentName) > 0:
|
||||
attachmentPath = os.path.join('./attachments', attachmentName)
|
||||
if os.path.exists(attachmentPath):
|
||||
attachment = MIMEBase("application", "octet-stream")
|
||||
with open(os.path.join('./attachments', attachmentName), 'rb') as f:
|
||||
attachment.set_payload(f.read())
|
||||
encoders.encode_base64(attachment)
|
||||
attachment.add_header("Content-Disposition", f"attachment; filename= {attachmentName}",)
|
||||
message.attach(attachment)
|
||||
else:
|
||||
self.smtp.close()
|
||||
return False, "Attachment does not exist"
|
||||
self.smtp.sendmail(self.SendFrom(), receiver, message.as_string())
|
||||
self.smtp.close()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, f"Send failed | Reason: {e}"
|
||||
return False, "SMTP not configured"
|
22
src/modules/Log.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Log Class
|
||||
"""
|
||||
class Log:
|
||||
def __init__(self, LogID: str, JobID: str, LogDate: str, Status: str, Message: str):
|
||||
self.LogID = LogID
|
||||
self.JobID = JobID
|
||||
self.LogDate = LogDate
|
||||
self.Status = Status
|
||||
self.Message = Message
|
||||
|
||||
def toJson(self):
|
||||
return {
|
||||
"LogID": self.LogID,
|
||||
"JobID": self.JobID,
|
||||
"LogDate": self.LogDate,
|
||||
"Status": self.Status,
|
||||
"Message": self.Message
|
||||
}
|
||||
|
||||
def __dict__(self):
|
||||
return self.toJson()
|
88
src/modules/NewConfigurationTemplates.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel, field_serializer
|
||||
import sqlalchemy as db
|
||||
from .ConnectionString import ConnectionString
|
||||
|
||||
|
||||
class NewConfigurationTemplate(BaseModel):
|
||||
TemplateID: str = ''
|
||||
Subnet: str = ''
|
||||
ListenPortStart: int = 0
|
||||
ListenPortEnd: int = 0
|
||||
Notes: str = ""
|
||||
|
||||
class NewConfigurationTemplates:
|
||||
def __init__(self):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.templatesTable = db.Table(
|
||||
'NewConfigurationTemplates', self.metadata,
|
||||
db.Column('TemplateID', db.String(255), primary_key=True),
|
||||
db.Column('Subnet', db.String(255)),
|
||||
db.Column('ListenPortStart', db.Integer),
|
||||
db.Column('ListenPortEnd', db.Integer),
|
||||
db.Column('Notes', db.Text),
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.Templates: list[NewConfigurationTemplate] = []
|
||||
self.__getTemplates()
|
||||
|
||||
def GetTemplates(self):
|
||||
self.__getTemplates()
|
||||
return list(map(lambda x : x.model_dump(), self.Templates))
|
||||
|
||||
def __getTemplates(self):
|
||||
with self.engine.connect() as conn:
|
||||
templates = conn.execute(
|
||||
self.templatesTable.select()
|
||||
).mappings().fetchall()
|
||||
self.Templates.clear()
|
||||
self.Templates = [NewConfigurationTemplate(**template) for template in templates]
|
||||
|
||||
def CreateTemplate(self) -> NewConfigurationTemplate:
|
||||
return NewConfigurationTemplate(TemplateID=str(uuid.uuid4()))
|
||||
|
||||
def SearchTemplate(self, template: NewConfigurationTemplate):
|
||||
try:
|
||||
first = next(filter(lambda x : x.TemplateID == template.TemplateID, self.Templates))
|
||||
except StopIteration:
|
||||
return None
|
||||
return first
|
||||
|
||||
def UpdateTemplate(self, template: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
template = NewConfigurationTemplate(**template)
|
||||
with self.engine.begin() as conn:
|
||||
if self.SearchTemplate(template):
|
||||
conn.execute(
|
||||
self.templatesTable.update().values(
|
||||
template.model_dump(exclude={'TemplateID'})
|
||||
).where(
|
||||
self.templatesTable.c.TemplateID == template.TemplateID
|
||||
)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
self.templatesTable.insert().values(
|
||||
template.model_dump()
|
||||
)
|
||||
)
|
||||
self.__getTemplates()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, None
|
||||
|
||||
def DeleteTemplate(self, template: dict[str, str]) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
template = NewConfigurationTemplate(**template)
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.templatesTable.delete().where(
|
||||
self.templatesTable.c.TemplateID == template.TemplateID
|
||||
)
|
||||
)
|
||||
self.__getTemplates()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, None
|
354
src/modules/Peer.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Peer
|
||||
"""
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os, subprocess, uuid, random, re
|
||||
from datetime import timedelta
|
||||
|
||||
import jinja2
|
||||
import sqlalchemy as db
|
||||
from .PeerJob import PeerJob
|
||||
from .PeerShareLink import PeerShareLink
|
||||
from .Utilities import GenerateWireguardPublicKey, ValidateIPAddressesWithRange, ValidateDNSAddress
|
||||
|
||||
|
||||
class Peer:
|
||||
def __init__(self, tableData, configuration):
|
||||
self.configuration = configuration
|
||||
self.id = tableData["id"]
|
||||
self.private_key = tableData["private_key"]
|
||||
self.DNS = tableData["DNS"]
|
||||
self.endpoint_allowed_ip = tableData["endpoint_allowed_ip"]
|
||||
self.name = tableData["name"]
|
||||
self.total_receive = tableData["total_receive"]
|
||||
self.total_sent = tableData["total_sent"]
|
||||
self.total_data = tableData["total_data"]
|
||||
self.endpoint = tableData["endpoint"]
|
||||
self.status = tableData["status"]
|
||||
self.latest_handshake = tableData["latest_handshake"]
|
||||
self.allowed_ip = tableData["allowed_ip"]
|
||||
self.cumu_receive = tableData["cumu_receive"]
|
||||
self.cumu_sent = tableData["cumu_sent"]
|
||||
self.cumu_data = tableData["cumu_data"]
|
||||
self.mtu = tableData["mtu"]
|
||||
self.keepalive = tableData["keepalive"]
|
||||
self.remote_endpoint = tableData["remote_endpoint"]
|
||||
self.preshared_key = tableData["preshared_key"]
|
||||
self.jobs: list[PeerJob] = []
|
||||
self.ShareLink: list[PeerShareLink] = []
|
||||
self.getJobs()
|
||||
self.getShareLink()
|
||||
|
||||
def toJson(self):
|
||||
# self.getJobs()
|
||||
# self.getShareLink()
|
||||
return self.__dict__
|
||||
|
||||
def __repr__(self):
|
||||
return str(self.toJson())
|
||||
|
||||
def updatePeer(self, name: str, private_key: str,
|
||||
preshared_key: str,
|
||||
dns_addresses: str, allowed_ip: str, endpoint_allowed_ip: str, mtu: int,
|
||||
keepalive: int) -> tuple[bool, str] or tuple[bool, None]:
|
||||
if not self.configuration.getStatus():
|
||||
self.configuration.toggleConfiguration()
|
||||
|
||||
existingAllowedIps = [item for row in list(
|
||||
map(lambda x: [q.strip() for q in x.split(',')],
|
||||
map(lambda y: y.allowed_ip,
|
||||
list(filter(lambda k: k.id != self.id, self.configuration.getPeersList()))))) for item in row]
|
||||
|
||||
if allowed_ip in existingAllowedIps:
|
||||
return False, "Allowed IP already taken by another peer"
|
||||
|
||||
if not ValidateIPAddressesWithRange(endpoint_allowed_ip):
|
||||
return False, f"Endpoint Allowed IPs format is incorrect"
|
||||
|
||||
if len(dns_addresses) > 0 and not ValidateDNSAddress(dns_addresses):
|
||||
return False, f"DNS format is incorrect"
|
||||
|
||||
if type(mtu) is str or mtu is None:
|
||||
mtu = 0
|
||||
|
||||
if mtu < 0 or mtu > 1460:
|
||||
return False, "MTU format is not correct"
|
||||
|
||||
if type(keepalive) is str or keepalive is None:
|
||||
keepalive = 0
|
||||
|
||||
if keepalive < 0:
|
||||
return False, "Persistent Keepalive format is not correct"
|
||||
if len(private_key) > 0:
|
||||
pubKey = GenerateWireguardPublicKey(private_key)
|
||||
if not pubKey[0] or pubKey[1] != self.id:
|
||||
return False, "Private key does not match with the public key"
|
||||
try:
|
||||
rd = random.Random()
|
||||
uid = str(uuid.UUID(int=rd.getrandbits(128), version=4))
|
||||
pskExist = len(preshared_key) > 0
|
||||
|
||||
if pskExist:
|
||||
with open(uid, "w+") as f:
|
||||
f.write(preshared_key)
|
||||
newAllowedIPs = allowed_ip.replace(" ", "")
|
||||
updateAllowedIp = subprocess.check_output(
|
||||
f"{self.configuration.Protocol} set {self.configuration.Name} peer {self.id} allowed-ips {newAllowedIPs} {f'preshared-key {uid}' if pskExist else 'preshared-key /dev/null'}",
|
||||
shell=True, stderr=subprocess.STDOUT)
|
||||
|
||||
if pskExist: os.remove(uid)
|
||||
if len(updateAllowedIp.decode().strip("\n")) != 0:
|
||||
return False, "Update peer failed when updating Allowed IPs"
|
||||
saveConfig = subprocess.check_output(f"{self.configuration.Protocol}-quick save {self.configuration.Name}",
|
||||
shell=True, stderr=subprocess.STDOUT)
|
||||
if f"wg showconf {self.configuration.Name}" not in saveConfig.decode().strip('\n'):
|
||||
return False, "Update peer failed when saving the configuration"
|
||||
with self.configuration.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.configuration.peersTable.update().values({
|
||||
"name": name,
|
||||
"private_key": private_key,
|
||||
"DNS": dns_addresses,
|
||||
"endpoint_allowed_ip": endpoint_allowed_ip,
|
||||
"mtu": mtu,
|
||||
"keepalive": keepalive,
|
||||
"preshared_key": preshared_key
|
||||
}).where(
|
||||
self.configuration.peersTable.c.id == self.id
|
||||
)
|
||||
)
|
||||
return True, None
|
||||
except subprocess.CalledProcessError as exc:
|
||||
return False, exc.output.decode("UTF-8").strip()
|
||||
|
||||
def downloadPeer(self) -> dict[str, str]:
|
||||
final = {
|
||||
"fileName": "",
|
||||
"file": ""
|
||||
}
|
||||
filename = self.name
|
||||
if len(filename) == 0:
|
||||
filename = "UntitledPeer"
|
||||
filename = "".join(filename.split(' '))
|
||||
filename = f"{filename}"
|
||||
illegal_filename = [".", ",", "/", "?", "<", ">", "\\", ":", "*", '|' '\"', "com1", "com2", "com3",
|
||||
"com4", "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4",
|
||||
"lpt5", "lpt6", "lpt7", "lpt8", "lpt9", "con", "nul", "prn"]
|
||||
for i in illegal_filename:
|
||||
filename = filename.replace(i, "")
|
||||
|
||||
for i in filename:
|
||||
if re.match("^[a-zA-Z0-9_=+.-]$", i):
|
||||
final["fileName"] += i
|
||||
|
||||
interfaceSection = {
|
||||
"PrivateKey": self.private_key,
|
||||
"Address": self.allowed_ip,
|
||||
"MTU": (
|
||||
self.configuration.configurationInfo.OverridePeerSettings.MTU
|
||||
if self.configuration.configurationInfo.OverridePeerSettings.MTU else self.mtu
|
||||
),
|
||||
"DNS": (
|
||||
self.configuration.configurationInfo.OverridePeerSettings.DNS
|
||||
if self.configuration.configurationInfo.OverridePeerSettings.DNS else self.DNS
|
||||
)
|
||||
}
|
||||
|
||||
if self.configuration.Protocol == "awg":
|
||||
interfaceSection.update({
|
||||
"Jc": self.configuration.Jc,
|
||||
"Jmin": self.configuration.Jmin,
|
||||
"Jmax": self.configuration.Jmax,
|
||||
"S1": self.configuration.S1,
|
||||
"S2": self.configuration.S2,
|
||||
"H1": self.configuration.H1,
|
||||
"H2": self.configuration.H2,
|
||||
"H3": self.configuration.H3,
|
||||
"H4": self.configuration.H4
|
||||
})
|
||||
|
||||
peerSection = {
|
||||
"PublicKey": self.configuration.PublicKey,
|
||||
"AllowedIPs": (
|
||||
self.configuration.configurationInfo.OverridePeerSettings.EndpointAllowedIPs
|
||||
if self.configuration.configurationInfo.OverridePeerSettings.EndpointAllowedIPs else self.endpoint_allowed_ip
|
||||
),
|
||||
"Endpoint": f'{(self.configuration.configurationInfo.OverridePeerSettings.PeerRemoteEndpoint if self.configuration.configurationInfo.OverridePeerSettings.PeerRemoteEndpoint else self.configuration.DashboardConfig.GetConfig("Peers", "remote_endpoint")[1])}:{(self.configuration.configurationInfo.OverridePeerSettings.ListenPort if self.configuration.configurationInfo.OverridePeerSettings.ListenPort else self.configuration.ListenPort)}',
|
||||
"PersistentKeepalive": (
|
||||
self.configuration.configurationInfo.OverridePeerSettings.PersistentKeepalive
|
||||
if self.configuration.configurationInfo.OverridePeerSettings.PersistentKeepalive
|
||||
else self.keepalive
|
||||
),
|
||||
"PresharedKey": self.preshared_key
|
||||
}
|
||||
combine = [interfaceSection.items(), peerSection.items()]
|
||||
for s in range(len(combine)):
|
||||
if s == 0:
|
||||
final["file"] += "[Interface]\n"
|
||||
else:
|
||||
final["file"] += "\n[Peer]\n"
|
||||
for (key, val) in combine[s]:
|
||||
if val is not None and ((type(val) is str and len(val) > 0) or (type(val) is int and val > 0)):
|
||||
final["file"] += f"{key} = {val}\n"
|
||||
|
||||
final["file"] = jinja2.Template(final["file"]).render(configuration=self.configuration)
|
||||
|
||||
|
||||
if self.configuration.Protocol == "awg":
|
||||
final["amneziaVPN"] = json.dumps({
|
||||
"containers": [{
|
||||
"awg": {
|
||||
"isThirdPartyConfig": True,
|
||||
"last_config": final['file'],
|
||||
"port": self.configuration.ListenPort,
|
||||
"transport_proto": "udp"
|
||||
},
|
||||
"container": "amnezia-awg"
|
||||
}],
|
||||
"defaultContainer": "amnezia-awg",
|
||||
"description": self.name,
|
||||
"hostName": (
|
||||
self.configuration.configurationInfo.OverridePeerSettings.PeerRemoteEndpoint
|
||||
if self.configuration.configurationInfo.OverridePeerSettings.PeerRemoteEndpoint
|
||||
else self.configuration.DashboardConfig.GetConfig("Peers", "remote_endpoint")[1])
|
||||
})
|
||||
return final
|
||||
|
||||
def getJobs(self):
|
||||
self.jobs = self.configuration.AllPeerJobs.searchJob(self.configuration.Name, self.id)
|
||||
|
||||
def getShareLink(self):
|
||||
self.ShareLink = self.configuration.AllPeerShareLinks.getLink(self.configuration.Name, self.id)
|
||||
|
||||
def resetDataUsage(self, mode: str):
|
||||
try:
|
||||
with self.configuration.engine.begin() as conn:
|
||||
if mode == "total":
|
||||
conn.execute(
|
||||
self.configuration.peersTable.update().values({
|
||||
"total_data": 0,
|
||||
"cumu_data": 0,
|
||||
"total_receive": 0,
|
||||
"cumu_receive": 0,
|
||||
"total_sent": 0,
|
||||
"cumu_sent": 0
|
||||
}).where(
|
||||
self.configuration.peersTable.c.id == self.id
|
||||
)
|
||||
)
|
||||
self.total_data = 0
|
||||
self.total_receive = 0
|
||||
self.total_sent = 0
|
||||
self.cumu_data = 0
|
||||
self.cumu_sent = 0
|
||||
self.cumu_receive = 0
|
||||
elif mode == "receive":
|
||||
conn.execute(
|
||||
self.configuration.peersTable.update().values({
|
||||
"total_receive": 0,
|
||||
"cumu_receive": 0,
|
||||
}).where(
|
||||
self.configuration.peersTable.c.id == self.id
|
||||
)
|
||||
)
|
||||
self.cumu_receive = 0
|
||||
self.total_receive = 0
|
||||
elif mode == "sent":
|
||||
conn.execute(
|
||||
self.configuration.peersTable.update().values({
|
||||
"total_sent": 0,
|
||||
"cumu_sent": 0
|
||||
}).where(
|
||||
self.configuration.peersTable.c.id == self.id
|
||||
)
|
||||
)
|
||||
self.cumu_sent = 0
|
||||
self.total_sent = 0
|
||||
else:
|
||||
return False
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def getEndpoints(self):
|
||||
result = []
|
||||
with self.configuration.engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
db.select(
|
||||
self.configuration.peersHistoryEndpointTable.c.endpoint
|
||||
).group_by(
|
||||
self.configuration.peersHistoryEndpointTable.c.endpoint
|
||||
).where(
|
||||
self.configuration.peersHistoryEndpointTable.c.id == self.id
|
||||
)
|
||||
).mappings().fetchall()
|
||||
return list(result)
|
||||
|
||||
def getTraffics(self, interval: int = 30, startDate: datetime.datetime = None, endDate: datetime.datetime = None):
|
||||
if startDate is None and endDate is None:
|
||||
endDate = datetime.datetime.now()
|
||||
startDate = endDate - timedelta(minutes=interval)
|
||||
else:
|
||||
endDate = endDate.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
startDate = startDate.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
with self.configuration.engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
db.select(
|
||||
self.configuration.peersTransferTable.c.cumu_data,
|
||||
self.configuration.peersTransferTable.c.total_data,
|
||||
self.configuration.peersTransferTable.c.cumu_receive,
|
||||
self.configuration.peersTransferTable.c.total_receive,
|
||||
self.configuration.peersTransferTable.c.cumu_sent,
|
||||
self.configuration.peersTransferTable.c.total_sent,
|
||||
self.configuration.peersTransferTable.c.time
|
||||
).where(
|
||||
db.and_(
|
||||
self.configuration.peersTransferTable.c.id == self.id,
|
||||
self.configuration.peersTransferTable.c.time <= endDate,
|
||||
self.configuration.peersTransferTable.c.time >= startDate,
|
||||
)
|
||||
).order_by(
|
||||
self.configuration.peersTransferTable.c.time
|
||||
)
|
||||
).mappings().fetchall()
|
||||
return list(result)
|
||||
|
||||
|
||||
def getSessions(self, startDate: datetime.datetime = None, endDate: datetime.datetime = None):
|
||||
if endDate is None:
|
||||
endDate = datetime.datetime.now()
|
||||
|
||||
if startDate is None:
|
||||
startDate = endDate
|
||||
|
||||
endDate = endDate.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
startDate = startDate.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
with self.configuration.engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
db.select(
|
||||
self.configuration.peersTransferTable.c.time
|
||||
).where(
|
||||
db.and_(
|
||||
self.configuration.peersTransferTable.c.id == self.id,
|
||||
self.configuration.peersTransferTable.c.time <= endDate,
|
||||
self.configuration.peersTransferTable.c.time >= startDate,
|
||||
)
|
||||
).order_by(
|
||||
self.configuration.peersTransferTable.c.time
|
||||
)
|
||||
).fetchall()
|
||||
time = list(map(lambda x : x[0], result))
|
||||
return time
|
||||
|
||||
def __duration(self, t1: datetime.datetime, t2: datetime.datetime):
|
||||
delta = t1 - t2
|
||||
|
||||
hours, remainder = divmod(delta.total_seconds(), 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}"
|
32
src/modules/PeerJob.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
Peer Job
|
||||
"""
|
||||
from datetime import datetime
|
||||
class PeerJob:
|
||||
def __init__(self, JobID: str, Configuration: str, Peer: str,
|
||||
Field: str, Operator: str, Value: str, CreationDate: datetime, ExpireDate: datetime, Action: str):
|
||||
self.Action = Action
|
||||
self.ExpireDate = ExpireDate
|
||||
self.CreationDate = CreationDate
|
||||
self.Value = Value
|
||||
self.Operator = Operator
|
||||
self.Field = Field
|
||||
self.Configuration = Configuration
|
||||
self.Peer = Peer
|
||||
self.JobID = JobID
|
||||
|
||||
def toJson(self):
|
||||
return {
|
||||
"JobID": self.JobID,
|
||||
"Configuration": self.Configuration,
|
||||
"Peer": self.Peer,
|
||||
"Field": self.Field,
|
||||
"Operator": self.Operator,
|
||||
"Value": self.Value,
|
||||
"CreationDate": self.CreationDate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"ExpireDate": (self.ExpireDate.strftime("%Y-%m-%d %H:%M:%S") if self.ExpireDate is not None else None),
|
||||
"Action": self.Action
|
||||
}
|
||||
|
||||
def __dict__(self):
|
||||
return self.toJson()
|
59
src/modules/PeerJobLogger.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Peer Job Logger
|
||||
"""
|
||||
import uuid
|
||||
import sqlalchemy as db
|
||||
from flask import current_app
|
||||
from .ConnectionString import ConnectionString
|
||||
from .Log import Log
|
||||
|
||||
class PeerJobLogger:
|
||||
def __init__(self, AllPeerJobs, DashboardConfig):
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard_log"))
|
||||
self.metadata = db.MetaData()
|
||||
self.jobLogTable = db.Table('JobLog', self.metadata,
|
||||
db.Column('LogID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('JobID', db.String(255), nullable=False),
|
||||
db.Column('LogDate', (db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
db.Column('Status', db.String(255), nullable=False),
|
||||
db.Column('Message', db.Text)
|
||||
)
|
||||
self.logs: list[Log] = []
|
||||
self.metadata.create_all(self.engine)
|
||||
self.AllPeerJobs = AllPeerJobs
|
||||
def log(self, JobID: str, Status: bool = True, Message: str = "") -> bool:
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.jobLogTable.insert().values(
|
||||
{
|
||||
"LogID": str(uuid.uuid4()),
|
||||
"JobID": JobID,
|
||||
"Status": Status,
|
||||
"Message": Message
|
||||
}
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Peer Job Log Error", e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def getLogs(self, configName = None) -> list[Log]:
|
||||
logs: list[Log] = []
|
||||
try:
|
||||
allJobs = self.AllPeerJobs.getAllJobs(configName)
|
||||
allJobsID = [x.JobID for x in allJobs]
|
||||
stmt = self.jobLogTable.select().where(self.jobLogTable.columns.JobID.in_(
|
||||
allJobsID
|
||||
))
|
||||
with self.engine.connect() as conn:
|
||||
table = conn.execute(stmt).fetchall()
|
||||
for l in table:
|
||||
logs.append(
|
||||
Log(l.LogID, l.JobID, l.LogDate.strftime("%Y-%m-%d %H:%M:%S"), l.Status, l.Message))
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Getting Peer Job Log Error", e)
|
||||
return logs
|
||||
return logs
|
202
src/modules/PeerJobs.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Peer Jobs
|
||||
"""
|
||||
from .ConnectionString import ConnectionString
|
||||
from .PeerJob import PeerJob
|
||||
from .PeerJobLogger import PeerJobLogger
|
||||
import sqlalchemy as db
|
||||
from datetime import datetime
|
||||
from flask import current_app
|
||||
|
||||
class PeerJobs:
|
||||
def __init__(self, DashboardConfig, WireguardConfigurations):
|
||||
self.Jobs: list[PeerJob] = []
|
||||
self.engine = db.create_engine(ConnectionString('wgdashboard_job'))
|
||||
self.metadata = db.MetaData()
|
||||
self.peerJobTable = db.Table('PeerJobs', self.metadata,
|
||||
db.Column('JobID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('Configuration', db.String(255), nullable=False),
|
||||
db.Column('Peer', db.String(255), nullable=False),
|
||||
db.Column('Field', db.String(255), nullable=False),
|
||||
db.Column('Operator', db.String(255), nullable=False),
|
||||
db.Column('Value', db.String(255), nullable=False),
|
||||
db.Column('CreationDate', (db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP), nullable=False),
|
||||
db.Column('ExpireDate', (db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP)),
|
||||
db.Column('Action', db.String(255), nullable=False),
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.__getJobs()
|
||||
self.JobLogger: PeerJobLogger = PeerJobLogger(self, DashboardConfig)
|
||||
self.WireguardConfigurations = WireguardConfigurations
|
||||
|
||||
def __getJobs(self):
|
||||
self.Jobs.clear()
|
||||
with self.engine.connect() as conn:
|
||||
jobs = conn.execute(self.peerJobTable.select().where(
|
||||
self.peerJobTable.columns.ExpireDate.is_(None)
|
||||
)).mappings().fetchall()
|
||||
for job in jobs:
|
||||
self.Jobs.append(PeerJob(
|
||||
job['JobID'], job['Configuration'], job['Peer'], job['Field'], job['Operator'], job['Value'],
|
||||
job['CreationDate'], job['ExpireDate'], job['Action']))
|
||||
|
||||
def getAllJobs(self, configuration: str = None):
|
||||
if configuration is not None:
|
||||
with self.engine.connect() as conn:
|
||||
jobs = conn.execute(self.peerJobTable.select().where(
|
||||
self.peerJobTable.columns.Configuration == configuration
|
||||
)).mappings().fetchall()
|
||||
j = []
|
||||
for job in jobs:
|
||||
j.append(PeerJob(
|
||||
job['JobID'], job['Configuration'], job['Peer'], job['Field'], job['Operator'], job['Value'],
|
||||
job['CreationDate'], job['ExpireDate'], job['Action']))
|
||||
return j
|
||||
return []
|
||||
|
||||
def toJson(self):
|
||||
return [x.toJson() for x in self.Jobs]
|
||||
|
||||
def searchJob(self, Configuration: str, Peer: str):
|
||||
return list(filter(lambda x: x.Configuration == Configuration and x.Peer == Peer, self.Jobs))
|
||||
|
||||
def searchJobById(self, JobID):
|
||||
return list(filter(lambda x: x.JobID == JobID, self.Jobs))
|
||||
|
||||
def saveJob(self, Job: PeerJob) -> tuple[bool, list] | tuple[bool, str]:
|
||||
import traceback
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
currentJob = self.searchJobById(Job.JobID)
|
||||
if len(currentJob) == 0:
|
||||
conn.execute(
|
||||
self.peerJobTable.insert().values(
|
||||
{
|
||||
"JobID": Job.JobID,
|
||||
"Configuration": Job.Configuration,
|
||||
"Peer": Job.Peer,
|
||||
"Field": Job.Field,
|
||||
"Operator": Job.Operator,
|
||||
"Value": Job.Value,
|
||||
"CreationDate": datetime.now(),
|
||||
"ExpireDate": None,
|
||||
"Action": Job.Action
|
||||
}
|
||||
)
|
||||
)
|
||||
self.JobLogger.log(Job.JobID, Message=f"Job is created if {Job.Field} {Job.Operator} {Job.Value} then {Job.Action}")
|
||||
else:
|
||||
conn.execute(
|
||||
self.peerJobTable.update().values({
|
||||
"Field": Job.Field,
|
||||
"Operator": Job.Operator,
|
||||
"Value": Job.Value,
|
||||
"Action": Job.Action
|
||||
}).where(self.peerJobTable.columns.JobID == Job.JobID)
|
||||
)
|
||||
self.JobLogger.log(Job.JobID, Message=f"Job is updated from if {currentJob[0].Field} {currentJob[0].Operator} {currentJob[0].Value} then {currentJob[0].Action}; to if {Job.Field} {Job.Operator} {Job.Value} then {Job.Action}")
|
||||
self.__getJobs()
|
||||
self.WireguardConfigurations.get(Job.Configuration).searchPeer(Job.Peer)[1].getJobs()
|
||||
return True, list(
|
||||
filter(lambda x: x.Configuration == Job.Configuration and x.Peer == Job.Peer and x.JobID == Job.JobID,
|
||||
self.Jobs))
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return False, str(e)
|
||||
|
||||
def deleteJob(self, Job: PeerJob) -> tuple[bool, None] | tuple[bool, str]:
|
||||
try:
|
||||
if len(self.searchJobById(Job.JobID)) == 0:
|
||||
return False, "Job does not exist"
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.peerJobTable.update().values(
|
||||
{
|
||||
"ExpireDate": datetime.now()
|
||||
}
|
||||
).where(self.peerJobTable.columns.JobID == Job.JobID)
|
||||
)
|
||||
self.JobLogger.log(Job.JobID, Message=f"Job is removed due to being deleted or finshed.")
|
||||
self.__getJobs()
|
||||
self.WireguardConfigurations.get(Job.Configuration).searchPeer(Job.Peer)[1].getJobs()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def updateJobConfigurationName(self, ConfigurationName: str, NewConfigurationName: str) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.peerJobTable.update().values({
|
||||
"Configuration": NewConfigurationName
|
||||
}).where(self.peerJobTable.columns.Configuration == ConfigurationName)
|
||||
)
|
||||
self.__getJobs()
|
||||
return True, None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def getPeerJobLogs(self, configurationName):
|
||||
return self.JobLogger.getLogs(configurationName)
|
||||
|
||||
|
||||
def runJob(self):
|
||||
current_app.logger.info("Running scheduled jobs")
|
||||
needToDelete = []
|
||||
self.__getJobs()
|
||||
for job in self.Jobs:
|
||||
c = self.WireguardConfigurations.get(job.Configuration)
|
||||
if c is not None:
|
||||
f, fp = c.searchPeer(job.Peer)
|
||||
if f:
|
||||
if job.Field in ["total_receive", "total_sent", "total_data"]:
|
||||
s = job.Field.split("_")[1]
|
||||
x: float = getattr(fp, f"total_{s}") + getattr(fp, f"cumu_{s}")
|
||||
y: float = float(job.Value)
|
||||
else:
|
||||
x: datetime = datetime.now()
|
||||
y: datetime = datetime.strptime(job.Value, "%Y-%m-%d %H:%M:%S")
|
||||
runAction: bool = self.__runJob_Compare(x, y, job.Operator)
|
||||
if runAction:
|
||||
s = False
|
||||
if job.Action == "restrict":
|
||||
s, msg = c.restrictPeers([fp.id])
|
||||
elif job.Action == "delete":
|
||||
s, msg = c.deletePeers([fp.id])
|
||||
elif job.Action == "reset_total_data_usage":
|
||||
s = fp.resetDataUsage("total")
|
||||
c.restrictPeers([fp.id])
|
||||
c.allowAccessPeers([fp.id])
|
||||
if s is True:
|
||||
self.JobLogger.log(job.JobID, s,
|
||||
f"Peer {fp.id} from {c.Name} is successfully {job.Action}ed."
|
||||
)
|
||||
current_app.logger.info(f"Peer {fp.id} from {c.Name} is successfully {job.Action}ed.")
|
||||
needToDelete.append(job)
|
||||
else:
|
||||
current_app.logger.info(f"Peer {fp.id} from {c.Name} is failed {job.Action}ed.")
|
||||
self.JobLogger.log(job.JobID, s,
|
||||
f"Peer {fp.id} from {c.Name} failed {job.Action}ed."
|
||||
)
|
||||
else:
|
||||
current_app.logger.warning(f"Somehow can't find this peer {job.Peer} from {c.Name} failed {job.Action}ed.")
|
||||
self.JobLogger.log(job.JobID, False,
|
||||
f"Somehow can't find this peer {job.Peer} from {c.Name} failed {job.Action}ed."
|
||||
)
|
||||
else:
|
||||
current_app.logger.warning(f"Somehow can't find this peer {job.Peer} from {job.Configuration} failed {job.Action}ed.")
|
||||
self.JobLogger.log(job.JobID, False,
|
||||
f"Somehow can't find this peer {job.Peer} from {job.Configuration} failed {job.Action}ed."
|
||||
)
|
||||
for j in needToDelete:
|
||||
self.deleteJob(j)
|
||||
|
||||
def __runJob_Compare(self, x: float | datetime, y: float | datetime, operator: str):
|
||||
if operator == "eq":
|
||||
return x == y
|
||||
if operator == "neq":
|
||||
return x != y
|
||||
if operator == "lgt":
|
||||
return x > y
|
||||
if operator == "lst":
|
||||
return x < y
|
22
src/modules/PeerShareLink.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
"""
|
||||
Peer Share Link
|
||||
"""
|
||||
class PeerShareLink:
|
||||
def __init__(self, ShareID:str, Configuration: str, Peer: str, ExpireDate: datetime, SharedDate: datetime):
|
||||
self.ShareID = ShareID
|
||||
self.Peer = Peer
|
||||
self.Configuration = Configuration
|
||||
self.SharedDate = SharedDate
|
||||
self.ExpireDate = ExpireDate
|
||||
if not self.ExpireDate:
|
||||
self.ExpireDate = datetime.strptime("2199-12-31","%Y-%m-%d")
|
||||
|
||||
def toJson(self):
|
||||
return {
|
||||
"ShareID": self.ShareID,
|
||||
"Peer": self.Peer,
|
||||
"Configuration": self.Configuration,
|
||||
"ExpireDate": self.ExpireDate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"SharedDate": self.SharedDate.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
89
src/modules/PeerShareLinks.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from .ConnectionString import ConnectionString
|
||||
from .PeerShareLink import PeerShareLink
|
||||
import sqlalchemy as db
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
"""
|
||||
Peer Share Links
|
||||
"""
|
||||
class PeerShareLinks:
|
||||
def __init__(self, DashboardConfig, WireguardConfigurations):
|
||||
self.Links: list[PeerShareLink] = []
|
||||
self.engine = db.create_engine(ConnectionString("wgdashboard"))
|
||||
self.metadata = db.MetaData()
|
||||
self.peerShareLinksTable = db.Table(
|
||||
'PeerShareLinks', self.metadata,
|
||||
db.Column('ShareID', db.String(255), nullable=False, primary_key=True),
|
||||
db.Column('Configuration', db.String(255), nullable=False),
|
||||
db.Column('Peer', db.String(255), nullable=False),
|
||||
db.Column('ExpireDate', (db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP)),
|
||||
db.Column('SharedDate', (db.DATETIME if DashboardConfig.GetConfig("Database", "type")[1] == 'sqlite' else db.TIMESTAMP),
|
||||
server_default=db.func.now()),
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.__getSharedLinks()
|
||||
self.wireguardConfigurations = WireguardConfigurations
|
||||
def __getSharedLinks(self):
|
||||
self.Links.clear()
|
||||
with self.engine.connect() as conn:
|
||||
allLinks = conn.execute(
|
||||
self.peerShareLinksTable.select().where(
|
||||
db.or_(self.peerShareLinksTable.columns.ExpireDate.is_(None), self.peerShareLinksTable.columns.ExpireDate > datetime.now())
|
||||
)
|
||||
).mappings().fetchall()
|
||||
for link in allLinks:
|
||||
self.Links.append(PeerShareLink(**link))
|
||||
|
||||
|
||||
|
||||
def getLink(self, Configuration: str, Peer: str) -> list[PeerShareLink]:
|
||||
self.__getSharedLinks()
|
||||
return list(filter(lambda x : x.Configuration == Configuration and x.Peer == Peer, self.Links))
|
||||
|
||||
def getLinkByID(self, ShareID: str) -> list[PeerShareLink]:
|
||||
self.__getSharedLinks()
|
||||
return list(filter(lambda x : x.ShareID == ShareID, self.Links))
|
||||
|
||||
def addLink(self, Configuration: str, Peer: str, ExpireDate: datetime = None) -> tuple[bool, str]:
|
||||
try:
|
||||
newShareID = str(uuid.uuid4())
|
||||
with self.engine.begin() as conn:
|
||||
if len(self.getLink(Configuration, Peer)) > 0:
|
||||
conn.execute(
|
||||
self.peerShareLinksTable.update().values(
|
||||
{
|
||||
"ExpireDate": datetime.now()
|
||||
}
|
||||
).where(db.and_(self.peerShareLinksTable.columns.Configuration == Configuration, self.peerShareLinksTable.columns.Peer == Peer))
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
self.peerShareLinksTable.insert().values(
|
||||
{
|
||||
"ShareID": newShareID,
|
||||
"Configuration": Configuration,
|
||||
"Peer": Peer,
|
||||
"ExpireDate": ExpireDate
|
||||
}
|
||||
)
|
||||
)
|
||||
self.__getSharedLinks()
|
||||
self.wireguardConfigurations.get(Configuration).searchPeer(Peer)[1].getShareLink()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
return True, newShareID
|
||||
|
||||
def updateLinkExpireDate(self, ShareID, ExpireDate: datetime = None) -> tuple[bool, str]:
|
||||
with self.engine.begin() as conn:
|
||||
updated = conn.execute(
|
||||
self.peerShareLinksTable.update().values(
|
||||
{
|
||||
"ExpireDate": ExpireDate
|
||||
}
|
||||
).returning(self.peerShareLinksTable.c.Configuration, self.peerShareLinksTable.c.Peer)
|
||||
.where(self.peerShareLinksTable.columns.ShareID == ShareID)
|
||||
).mappings().fetchone()
|
||||
self.__getSharedLinks()
|
||||
self.wireguardConfigurations.get(updated.Configuration).searchPeer(updated.Peer)[1].getShareLink()
|
||||
return True, ""
|
181
src/modules/SystemStatus.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import shutil, subprocess, time, threading, psutil
|
||||
from flask import current_app
|
||||
|
||||
class SystemStatus:
|
||||
def __init__(self):
|
||||
self.CPU = CPU()
|
||||
self.MemoryVirtual = Memory('virtual')
|
||||
self.MemorySwap = Memory('swap')
|
||||
self.Disks = Disks()
|
||||
self.NetworkInterfaces = NetworkInterfaces()
|
||||
self.Processes = Processes()
|
||||
def toJson(self):
|
||||
process = [
|
||||
threading.Thread(target=self.CPU.getCPUPercent),
|
||||
threading.Thread(target=self.CPU.getPerCPUPercent),
|
||||
threading.Thread(target=self.NetworkInterfaces.getData)
|
||||
]
|
||||
for p in process:
|
||||
p.start()
|
||||
for p in process:
|
||||
p.join()
|
||||
|
||||
|
||||
return {
|
||||
"CPU": self.CPU,
|
||||
"Memory": {
|
||||
"VirtualMemory": self.MemoryVirtual,
|
||||
"SwapMemory": self.MemorySwap
|
||||
},
|
||||
"Disks": self.Disks,
|
||||
"NetworkInterfaces": self.NetworkInterfaces,
|
||||
"NetworkInterfacesPriority": self.NetworkInterfaces.getInterfacePriorities(),
|
||||
"Processes": self.Processes
|
||||
}
|
||||
|
||||
|
||||
class CPU:
|
||||
def __init__(self):
|
||||
self.cpu_percent: float = 0
|
||||
self.cpu_percent_per_cpu: list[float] = []
|
||||
|
||||
def getCPUPercent(self):
|
||||
try:
|
||||
self.cpu_percent = psutil.cpu_percent(interval=1)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get CPU Percent error", e)
|
||||
|
||||
def getPerCPUPercent(self):
|
||||
try:
|
||||
self.cpu_percent_per_cpu = psutil.cpu_percent(interval=1, percpu=True)
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get Per CPU Percent error", e)
|
||||
|
||||
def toJson(self):
|
||||
return self.__dict__
|
||||
|
||||
class Memory:
|
||||
def __init__(self, memoryType: str):
|
||||
self.__memoryType__ = memoryType
|
||||
self.total = 0
|
||||
self.available = 0
|
||||
self.percent = 0
|
||||
def getData(self):
|
||||
try:
|
||||
if self.__memoryType__ == "virtual":
|
||||
memory = psutil.virtual_memory()
|
||||
self.available = memory.available
|
||||
else:
|
||||
memory = psutil.swap_memory()
|
||||
self.available = memory.free
|
||||
self.total = memory.total
|
||||
|
||||
self.percent = memory.percent
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get Memory percent error", e)
|
||||
def toJson(self):
|
||||
self.getData()
|
||||
return self.__dict__
|
||||
|
||||
class Disks:
|
||||
def __init__(self):
|
||||
self.disks : list[Disk] = []
|
||||
def getData(self):
|
||||
try:
|
||||
self.disks = list(map(lambda x : Disk(x.mountpoint), psutil.disk_partitions()))
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get Disk percent error", e)
|
||||
def toJson(self):
|
||||
self.getData()
|
||||
return self.disks
|
||||
|
||||
class Disk:
|
||||
def __init__(self, mountPoint: str):
|
||||
self.total = 0
|
||||
self.used = 0
|
||||
self.free = 0
|
||||
self.percent = 0
|
||||
self.mountPoint = mountPoint
|
||||
def getData(self):
|
||||
try:
|
||||
disk = psutil.disk_usage(self.mountPoint)
|
||||
self.total = disk.total
|
||||
self.free = disk.free
|
||||
self.used = disk.used
|
||||
self.percent = disk.percent
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get Disk percent error", e)
|
||||
def toJson(self):
|
||||
self.getData()
|
||||
return self.__dict__
|
||||
|
||||
class NetworkInterfaces:
|
||||
def __init__(self):
|
||||
self.interfaces = {}
|
||||
|
||||
def getInterfacePriorities(self):
|
||||
if shutil.which("ip"):
|
||||
result = subprocess.check_output(["ip", "route", "show"]).decode()
|
||||
priorities = {}
|
||||
for line in result.splitlines():
|
||||
if "metric" in line and "dev" in line:
|
||||
parts = line.split()
|
||||
dev = parts[parts.index("dev")+1]
|
||||
metric = int(parts[parts.index("metric")+1])
|
||||
if dev not in priorities:
|
||||
priorities[dev] = metric
|
||||
return priorities
|
||||
return {}
|
||||
|
||||
def getData(self):
|
||||
self.interfaces.clear()
|
||||
try:
|
||||
network = psutil.net_io_counters(pernic=True, nowrap=True)
|
||||
for i in network.keys():
|
||||
self.interfaces[i] = network[i]._asdict()
|
||||
time.sleep(1)
|
||||
network = psutil.net_io_counters(pernic=True, nowrap=True)
|
||||
for i in network.keys():
|
||||
self.interfaces[i]['realtime'] = {
|
||||
'sent': round((network[i].bytes_sent - self.interfaces[i]['bytes_sent']) / 1024 / 1024, 4),
|
||||
'recv': round((network[i].bytes_recv - self.interfaces[i]['bytes_recv']) / 1024 / 1024, 4)
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get network error", e)
|
||||
|
||||
def toJson(self):
|
||||
return self.interfaces
|
||||
|
||||
class Process:
|
||||
def __init__(self, name, command, pid, percent):
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.pid = pid
|
||||
self.percent = percent
|
||||
def toJson(self):
|
||||
return self.__dict__
|
||||
|
||||
class Processes:
|
||||
def __init__(self):
|
||||
self.CPU_Top_10_Processes: list[Process] = []
|
||||
self.Memory_Top_10_Processes: list[Process] = []
|
||||
def getData(self):
|
||||
while True:
|
||||
try:
|
||||
processes = list(psutil.process_iter())
|
||||
self.CPU_Top_10_Processes = sorted(
|
||||
list(map(lambda x : Process(x.name(), " ".join(x.cmdline()), x.pid, x.cpu_percent()), processes)),
|
||||
key=lambda x : x.percent, reverse=True)[:20]
|
||||
self.Memory_Top_10_Processes = sorted(
|
||||
list(map(lambda x : Process(x.name(), " ".join(x.cmdline()), x.pid, x.memory_percent()), processes)),
|
||||
key=lambda x : x.percent, reverse=True)[:20]
|
||||
break
|
||||
except Exception as e:
|
||||
current_app.logger.error("Get processes error", e)
|
||||
|
||||
def toJson(self):
|
||||
self.getData()
|
||||
return {
|
||||
"cpu_top_10": self.CPU_Top_10_Processes,
|
||||
"memory_top_10": self.Memory_Top_10_Processes
|
||||
}
|
104
src/modules/Utilities.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import re, ipaddress
|
||||
import subprocess
|
||||
|
||||
|
||||
def RegexMatch(regex, text) -> bool:
|
||||
"""
|
||||
Regex Match
|
||||
@param regex: Regex patter
|
||||
@param text: Text to match
|
||||
@return: Boolean indicate if the text match the regex pattern
|
||||
"""
|
||||
pattern = re.compile(regex)
|
||||
return pattern.search(text) is not None
|
||||
|
||||
def GetRemoteEndpoint() -> str:
|
||||
"""
|
||||
Using socket to determine default interface IP address. Thanks, @NOXICS
|
||||
@return:
|
||||
"""
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("1.1.1.1", 80)) # Connecting to a public IP
|
||||
wgd_remote_endpoint = s.getsockname()[0]
|
||||
return str(wgd_remote_endpoint)
|
||||
|
||||
|
||||
def StringToBoolean(value: str):
|
||||
"""
|
||||
Convert string boolean to boolean
|
||||
@param value: Boolean value in string came from Configuration file
|
||||
@return: Boolean value
|
||||
"""
|
||||
return (value.strip().replace(" ", "").lower() in
|
||||
("yes", "true", "t", "1", 1))
|
||||
|
||||
def ValidateIPAddressesWithRange(ips: str) -> bool:
|
||||
s = ips.replace(" ", "").split(",")
|
||||
for ip in s:
|
||||
try:
|
||||
ipaddress.ip_network(ip)
|
||||
except ValueError as e:
|
||||
return False
|
||||
return True
|
||||
|
||||
def ValidateIPAddresses(ips) -> bool:
|
||||
s = ips.replace(" ", "").split(",")
|
||||
for ip in s:
|
||||
try:
|
||||
ipaddress.ip_address(ip)
|
||||
except ValueError as e:
|
||||
return False
|
||||
return True
|
||||
|
||||
def ValidateDNSAddress(addresses) -> tuple[bool, str]:
|
||||
s = addresses.replace(" ", "").split(",")
|
||||
for address in s:
|
||||
if not ValidateIPAddresses(address) and not RegexMatch(
|
||||
r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z]{0,61}[a-z]", address):
|
||||
return False, f"{address} does not appear to be an valid DNS address"
|
||||
return True, ""
|
||||
|
||||
def ValidateEndpointAllowedIPs(IPs) -> tuple[bool, str] | tuple[bool, None]:
|
||||
ips = IPs.replace(" ", "").split(",")
|
||||
for ip in ips:
|
||||
try:
|
||||
ipaddress.ip_network(ip, strict=False)
|
||||
except ValueError as e:
|
||||
return False, str(e)
|
||||
return True, None
|
||||
|
||||
def GenerateWireguardPublicKey(privateKey: str) -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
publicKey = subprocess.check_output(f"wg pubkey", input=privateKey.encode(), shell=True,
|
||||
stderr=subprocess.STDOUT)
|
||||
return True, publicKey.decode().strip('\n')
|
||||
except subprocess.CalledProcessError:
|
||||
return False, None
|
||||
|
||||
def GenerateWireguardPrivateKey() -> tuple[bool, str] | tuple[bool, None]:
|
||||
try:
|
||||
publicKey = subprocess.check_output(f"wg genkey", shell=True,
|
||||
stderr=subprocess.STDOUT)
|
||||
return True, publicKey.decode().strip('\n')
|
||||
except subprocess.CalledProcessError:
|
||||
return False, None
|
||||
|
||||
def ValidatePasswordStrength(password: str) -> tuple[bool, str] | tuple[bool, None]:
|
||||
# Rules:
|
||||
# - Must be over 8 characters & numbers
|
||||
# - Must contain at least 1 Uppercase & Lowercase letters
|
||||
# - Must contain at least 1 Numbers (0-9)
|
||||
# - Must contain at least 1 special characters from $&+,:;=?@#|'<>.-^*()%!~_-
|
||||
if len(password) < 8:
|
||||
return False, "Password must be 8 characters or more"
|
||||
if not re.search(r'[a-z]', password):
|
||||
return False, "Password must contain at least 1 lowercase character"
|
||||
if not re.search(r'[A-Z]', password):
|
||||
return False, "Password must contain at least 1 uppercase character"
|
||||
if not re.search(r'\d', password):
|
||||
return False, "Password must contain at least 1 number"
|
||||
if not re.search(r'[$&+,:;=?@#|\'<>.\-^*()%!~_-]', password):
|
||||
return False, "Password must contain at least 1 special character from $&+,:;=?@#|'<>.-^*()%!~_-"
|
||||
|
||||
return True, None
|
1229
src/modules/WireguardConfiguration.py
Normal file
21
src/modules/WireguardConfigurationInfo.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
class OverridePeerSettingsClass(BaseModel):
|
||||
DNS: str = ''
|
||||
EndpointAllowedIPs: str = ''
|
||||
MTU: str | int = ''
|
||||
PersistentKeepalive: int | str = ''
|
||||
PeerRemoteEndpoint: str = ''
|
||||
ListenPort: int | str = ''
|
||||
|
||||
class PeerGroupsClass(BaseModel):
|
||||
GroupName: str = ''
|
||||
Description: str = ''
|
||||
BackgroundColor: str = ''
|
||||
Icon: str = ''
|
||||
Peers: list[str] = []
|
||||
|
||||
class WireguardConfigurationInfo(BaseModel):
|
||||
Description: str = ''
|
||||
OverridePeerSettings: OverridePeerSettingsClass = OverridePeerSettingsClass(**{})
|
||||
PeerGroups: dict[str, PeerGroupsClass] = {}
|
@@ -1,6 +1,17 @@
|
||||
Flask
|
||||
bcrypt
|
||||
ifcfg
|
||||
psutil
|
||||
pyotp
|
||||
Flask
|
||||
flask-cors
|
||||
icmplib
|
||||
flask-qrcode
|
||||
gunicorn
|
||||
certbot
|
||||
requests
|
||||
tcconfig
|
||||
sqlalchemy
|
||||
sqlalchemy_utils
|
||||
psycopg
|
||||
PyMySQL
|
||||
tzlocal
|
||||
python-jose
|
||||
pydantic
|
BIN
src/static/.DS_Store
vendored
30
src/static/app/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
.vite/*
|
27
src/static/app/build.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
echo "Running vite build..."
|
||||
if vite build; then
|
||||
echo "Vite build successful."
|
||||
else
|
||||
echo "Vite build failed. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking for changes to commit..."
|
||||
if git diff-index --quiet HEAD --; then
|
||||
|
||||
if git commit -a; then
|
||||
echo "Git commit successful."
|
||||
else
|
||||
echo "Git commit failed. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No changes to commit. Skipping commit."
|
||||
fi
|
||||
echo "Pushing changes to remote..."
|
||||
if git push; then
|
||||
echo "Git push successful."
|
||||
else
|
||||
echo "Git push failed. Exiting."
|
||||
exit 1
|
||||
fi
|
19
src/static/app/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="application-name" content="WGDashboard">
|
||||
<meta name="apple-mobile-web-app-title" content="WGDashboard">
|
||||
<link rel="manifest" href="/json/manifest.json">
|
||||
<link rel="icon" href="/img/Logo-2-512x512.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WGDashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
8
src/static/app/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
8962
src/static/app/package-lock.json
generated
Normal file
46
src/static/app/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "app",
|
||||
"version": "4.3.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "es2022",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --emptyOutDir",
|
||||
"buildcommitpush": "./build.sh",
|
||||
"build electron": "vite build --emptyOutDir && vite build --mode electron && cd ../../../../WGDashboard-Desktop && /opt/homebrew/bin/npm run \"electron dist\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@volar/language-server": "2.4.23",
|
||||
"@vue/language-server": "3.1.0",
|
||||
"@vuepic/vue-datepicker": "^11.0.2",
|
||||
"@vueuse/core": "^13.5.0",
|
||||
"@vueuse/shared": "^13.5.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"cidr-tools": "^11.0.3",
|
||||
"css-color-converter": "^2.0.0",
|
||||
"dayjs": "^1.11.12",
|
||||
"electron-builder": "^26.0.12",
|
||||
"fuse.js": "^7.0.0",
|
||||
"i": "^0.3.7",
|
||||
"is-cidr": "^6.0.1",
|
||||
"npm": "^11.6.1",
|
||||
"ol": "^10.2.1",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"qrcodejs": "^1.0.0",
|
||||
"simple-code-editor": "^2.0.9",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
1
src/static/app/proxy.js
Normal file
@@ -0,0 +1 @@
|
||||
export const proxy = "http://wg.local:10086/"
|
BIN
src/static/app/public/img/Logo-1-128x128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/static/app/public/img/Logo-1-256x256.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
src/static/app/public/img/Logo-1-384x384.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
src/static/app/public/img/Logo-1-512x512.png
Normal file
After Width: | Height: | Size: 134 KiB |
BIN
src/static/app/public/img/Logo-1-Maskable-512x512.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
src/static/app/public/img/Logo-1-Rounded-128x128.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
src/static/app/public/img/Logo-1-Rounded-256x256.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
src/static/app/public/img/Logo-1-Rounded-384x384.png
Normal file
After Width: | Height: | Size: 72 KiB |
BIN
src/static/app/public/img/Logo-1-Rounded-512x512.png
Normal file
After Width: | Height: | Size: 135 KiB |
BIN
src/static/app/public/img/Logo-2-128x128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/static/app/public/img/Logo-2-256x256.png
Normal file
After Width: | Height: | Size: 33 KiB |
BIN
src/static/app/public/img/Logo-2-384x384.png
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
src/static/app/public/img/Logo-2-512x512.png
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
src/static/app/public/img/Logo-2-Rounded-128x128.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/static/app/public/img/Logo-2-Rounded-256x256.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
src/static/app/public/img/Logo-2-Rounded-384x384.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
src/static/app/public/img/Logo-2-Rounded-512x512.png
Normal file
After Width: | Height: | Size: 126 KiB |
48
src/static/app/public/json/manifest.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"theme_color": "#343a40",
|
||||
"background_color": "#343a40",
|
||||
"display": "fullscreen",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"name": "WGDashboard",
|
||||
"short_name": "WGDashboard",
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/sign-in.png",
|
||||
"sizes": "2880x1826",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide"
|
||||
},
|
||||
{
|
||||
"src": "https://wgdashboard-resources.tor1.cdn.digitaloceanspaces.com/Documentation%20Images/index.png",
|
||||
"sizes": "2880x1826",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"icons": [
|
||||
{
|
||||
"src": "../img/Logo-2-Rounded-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "../img/Logo-2-Rounded-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "../img/Logo-2-Rounded-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "../img/Logo-2-Rounded-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
75
src/static/app/src/App.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup async>
|
||||
import {RouterView, useRoute} from 'vue-router'
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import {computed, watch} from "vue";
|
||||
const store = DashboardConfigurationStore();
|
||||
import "@/utilities/wireguard.js"
|
||||
import {fetchGet} from "@/utilities/fetch.js";
|
||||
store.initCrossServerConfiguration();
|
||||
if (window.IS_WGDASHBOARD_DESKTOP){
|
||||
store.IsElectronApp = true;
|
||||
store.CrossServerConfiguration.Enable = true;
|
||||
if (store.ActiveServerConfiguration){
|
||||
fetchGet("/api/locale", {}, (res) => {
|
||||
store.Locale = res.data
|
||||
})
|
||||
}
|
||||
}else{
|
||||
fetchGet("/api/locale", {}, (res) => {
|
||||
store.Locale = res.data
|
||||
})
|
||||
}
|
||||
watch(store.CrossServerConfiguration, () => {
|
||||
store.syncCrossServerConfiguration()
|
||||
}, {
|
||||
deep: true
|
||||
});
|
||||
const route = useRoute()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-100 bg-body" :data-bs-theme="store.Configuration?.Server.dashboard_theme">
|
||||
<div style="z-index: 9999; height: 5px" class="position-absolute loadingBar top-0 start-0"></div>
|
||||
<nav class="navbar bg-dark sticky-top" data-bs-theme="dark" v-if="!route.meta.hideTopNav">
|
||||
<div class="container-fluid d-flex text-body align-items-center">
|
||||
<RouterLink to="/" class="navbar-brand mb-0 h1">
|
||||
<img src="/img/Logo-2-Rounded-512x512.png" alt="WGDashboard Logo" style="width: 32px">
|
||||
</RouterLink>
|
||||
<a role="button" class="navbarBtn text-body"
|
||||
@click="store.ShowNavBar = !store.ShowNavBar"
|
||||
style="line-height: 0; font-size: 2rem">
|
||||
<Transition name="fade2" mode="out-in">
|
||||
<i class="bi bi-list" v-if="!store.ShowNavBar"></i>
|
||||
<i class="bi bi-x-lg" v-else></i>
|
||||
</Transition>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
<Suspense>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="app" mode="out-in" type="transition" appear>
|
||||
<Component :is="Component"></Component>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</Suspense>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-enter-active,
|
||||
.app-leave-active {
|
||||
transition: all 0.7s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
.app-enter-from,
|
||||
.app-leave-to{
|
||||
opacity: 0;
|
||||
transform: scale(1.05);
|
||||
filter: blur(8px);
|
||||
}
|
||||
@media screen and (min-width: 768px) {
|
||||
.navbar{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref} from "vue";
|
||||
import {DashboardClientAssignmentStore} from "@/stores/DashboardClientAssignmentStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
|
||||
const props = defineProps(['configuration', 'peers', 'clientAssignedPeers', 'availablePeerSearchString'])
|
||||
const emits = defineEmits(['assign', 'unassign'])
|
||||
const assignmentStore = DashboardClientAssignmentStore()
|
||||
const available = computed(() => {
|
||||
if (props.clientAssignedPeers){
|
||||
if (Object.keys(props.clientAssignedPeers).includes(props.configuration)){
|
||||
return props.peers.filter(
|
||||
x => {
|
||||
return !props.clientAssignedPeers[props.configuration].map(
|
||||
x => x.id
|
||||
).includes(x.id) &&
|
||||
(!props.availablePeerSearchString ||
|
||||
(props.availablePeerSearchString &&
|
||||
(x.id.includes(props.availablePeerSearchString) || x.name.includes(props.availablePeerSearchString))))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
return props.availablePeerSearchString ? props.peers.filter(
|
||||
x => x.id.includes(props.availablePeerSearchString) || x.name.includes(props.availablePeerSearchString)
|
||||
) : props.peers
|
||||
})
|
||||
const confirmDelete = ref(false)
|
||||
const collapse = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card rounded-0 border-0">
|
||||
<div
|
||||
@click="collapse = !collapse"
|
||||
role="button"
|
||||
class="card-header rounded-0 sticky-top bg-body-secondary border-0 border-bottom text-white d-flex">
|
||||
<small><samp>{{ configuration }}</samp></small>
|
||||
<a role="button" class="ms-auto text-white" >
|
||||
<i class="bi bi-chevron-compact-down" v-if="collapse"></i>
|
||||
<i class="bi bi-chevron-compact-up" v-else></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0" v-if="!collapse">
|
||||
<div class="list-group list-group-flush" >
|
||||
<div
|
||||
class="list-group-item d-flex border-bottom list-group-item-action d-flex align-items-center gap-3"
|
||||
:key="peer.id"
|
||||
v-for="peer in available" >
|
||||
<div v-if="!confirmDelete">
|
||||
<small class="text-body">
|
||||
<RouterLink
|
||||
class="text-decoration-none"
|
||||
target="_blank"
|
||||
:to="'/configuration/' + configuration +'/peers?id=' + encodeURIComponent(peer.id)">
|
||||
<samp>{{ peer.id }}</samp>
|
||||
</RouterLink>
|
||||
</small><br>
|
||||
<small class="text-muted">
|
||||
{{ peer.name ? peer.name : 'Untitled Peer'}}
|
||||
</small>
|
||||
</div>
|
||||
<div v-else>
|
||||
<small class="text-body">
|
||||
<LocaleText t="Are you sure to remove this peer?"></LocaleText>
|
||||
</small><br>
|
||||
<small class="text-muted">
|
||||
<samp>{{ peer.id }}</samp>
|
||||
</small>
|
||||
</div>
|
||||
<template v-if="clientAssignedPeers">
|
||||
<button
|
||||
@click="emits('assign', peer.id)"
|
||||
:class="{disabled: assignmentStore.assigning}"
|
||||
class="btn bg-success-subtle text-success-emphasis ms-auto">
|
||||
<i class="bi bi-plus-circle-fill" ></i>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
@click="emits('unassign', peer.assignment_id)"
|
||||
:class="{disabled: assignmentStore.unassigning}"
|
||||
aria-label="Delete Assignment"
|
||||
class="btn bg-danger-subtle text-danger-emphasis ms-auto">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts" async>
|
||||
import {onMounted, ref, watch, watchEffect} from "vue";
|
||||
import { fetchGet } from "@/utilities/fetch.js"
|
||||
import {DashboardClientAssignmentStore} from "@/stores/DashboardClientAssignmentStore.js";
|
||||
import AvailablePeersGroup from "@/components/clientComponents/availablePeersGroup.vue";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
const props = defineProps(['client', 'clientAssignedPeers'])
|
||||
const loading = ref(false)
|
||||
const assignmentStore = DashboardClientAssignmentStore()
|
||||
const manage = ref(false)
|
||||
const emits = defineEmits(['refresh'])
|
||||
|
||||
const assign = async (ConfigurationName, Peer, ClientID) => {
|
||||
await assignmentStore.assignClient(ConfigurationName, Peer, ClientID, false)
|
||||
emits('refresh')
|
||||
}
|
||||
|
||||
const unassign = async (AssignmentID) => {
|
||||
await assignmentStore.unassignClient(undefined, undefined, AssignmentID)
|
||||
emits('refresh')
|
||||
}
|
||||
|
||||
const availablePeerSearchString = ref("")
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex rounded-0 border-0 flex-column d-flex flex-column border-bottom pb-1" v-if="!loading">
|
||||
<div class="d-flex flex-column p-3 gap-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<LocaleText t="Assigned Peers"></LocaleText>
|
||||
<span class="text-bg-primary badge ms-2">
|
||||
{{ Object.keys(clientAssignedPeers).length }} <LocaleText :t="Object.keys(clientAssignedPeers).length > 1 ? 'Configurations' : 'Configuration'"></LocaleText>
|
||||
</span>
|
||||
<span class="text-bg-info badge ms-2">
|
||||
{{ Object.values(clientAssignedPeers).flat().length }} <LocaleText :t="Object.values(clientAssignedPeers).flat().length > 1 ? 'Peers' : 'Peer'"></LocaleText>
|
||||
</span>
|
||||
</h6>
|
||||
<button class="btn btn-sm bg-primary-subtle text-primary-emphasis rounded-3 ms-auto"
|
||||
@click="manage = !manage">
|
||||
<template v-if="!manage">
|
||||
<i class="bi bi-list-check me-2"></i>
|
||||
<LocaleText t="Manage"></LocaleText>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="bi bi-check me-2"></i>
|
||||
<LocaleText t="Done"></LocaleText>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-3 availablePeers border h-100 overflow-scroll flex-grow-1 d-flex flex-column">
|
||||
<AvailablePeersGroup
|
||||
:configuration="configuration"
|
||||
:peers="peers"
|
||||
@unassign="async (id) => await unassign(id)"
|
||||
v-for="(peers, configuration) in clientAssignedPeers">
|
||||
</AvailablePeersGroup>
|
||||
<h6 class="text-muted m-auto p-3" v-if="Object.keys(clientAssignedPeers).length === 0">
|
||||
<LocaleText t="No peer assigned to this client"></LocaleText>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height: 500px" class="d-flex flex-column p-3" v-if="manage">
|
||||
<div class="availablePeers border h-100 card rounded-3">
|
||||
<div class="card-header sticky-top p-3">
|
||||
<h6 class="mb-0 d-flex align-items-center">
|
||||
<LocaleText t="Available Peers"></LocaleText>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0 overflow-scroll">
|
||||
<AvailablePeersGroup
|
||||
:availablePeerSearchString="availablePeerSearchString"
|
||||
:configuration="configuration"
|
||||
:clientAssignedPeers="clientAssignedPeers"
|
||||
:peers="peers"
|
||||
:key="configuration"
|
||||
@assign="async (id) => await assign(configuration, id, props.client.ClientID)"
|
||||
v-for="(peers, configuration) in assignmentStore.allConfigurationsPeers">
|
||||
</AvailablePeersGroup>
|
||||
<h6 class="text-muted m-auto" v-if="Object.keys(assignmentStore.allConfigurationsPeers).length === 0">
|
||||
<LocaleText t="No peer is available to assign"></LocaleText>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2 p-3 align-items-center justify-content-end">
|
||||
<label for="availablePeerSearchString">
|
||||
<i class="bi bi-search me-2"></i>
|
||||
</label>
|
||||
<input
|
||||
id="availablePeerSearchString"
|
||||
v-model="availablePeerSearchString"
|
||||
class="form-control form-control-sm rounded-3 w-auto" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="p-3 placeholder-glow border-bottom">
|
||||
<h6 class="placeholder w-100 rounded-3"></h6>
|
||||
<div class="placeholder w-100 rounded-3" style="height: 400px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import { fetchPost } from "@/utilities/fetch"
|
||||
import {ref} from "vue";
|
||||
import { DashboardConfigurationStore } from "@/stores/DashboardConfigurationStore.js"
|
||||
|
||||
const props = defineProps(['client'])
|
||||
const deleting = ref(false)
|
||||
const confirmDelete = ref(false)
|
||||
const emits = defineEmits(['refresh'])
|
||||
const dashboardConfigurationStore = DashboardConfigurationStore()
|
||||
const deleteClient = async () => {
|
||||
deleting.value = true
|
||||
await fetchPost("/api/clients/deleteClient", {
|
||||
ClientID: props.client.ClientID
|
||||
}, (res) => {
|
||||
deleting.value = false
|
||||
if (res.status){
|
||||
emits("deleteSuccess")
|
||||
dashboardConfigurationStore.newMessage("Server", "Delete client successfully", "success")
|
||||
}else {
|
||||
dashboardConfigurationStore.newMessage("Server", "Failed to delete client", "danger")
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 d-flex gap-3 flex-column border-bottom">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<h6 class="mb-0">
|
||||
<LocaleText t="Delete Client" v-if="!confirmDelete"></LocaleText>
|
||||
<LocaleText t="Are you sure to delete this client?" v-else></LocaleText>
|
||||
</h6>
|
||||
<button class="btn btn-sm bg-danger-subtle text-danger-emphasis rounded-3 ms-auto"
|
||||
v-if="!confirmDelete"
|
||||
@click="confirmDelete = true"
|
||||
>
|
||||
<i class="bi bi-trash-fill me-2"></i>
|
||||
<LocaleText t="Delete"></LocaleText>
|
||||
</button>
|
||||
|
||||
<template v-if="confirmDelete">
|
||||
<button
|
||||
@click="deleteClient"
|
||||
class="btn btn-sm bg-danger-subtle text-danger-emphasis rounded-3 ms-auto">
|
||||
<i class="bi bi-trash-fill me-2"></i>
|
||||
<LocaleText t="Yes"></LocaleText>
|
||||
</button>
|
||||
<button class="btn btn-sm bg-secondary-subtle text-secondary-emphasis rounded-3"
|
||||
v-if="confirmDelete" @click="confirmDelete = false">
|
||||
<i class="bi bi-x-lg me-2"></i>
|
||||
<LocaleText t="No"></LocaleText>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted} from "vue";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {useRoute} from "vue-router";
|
||||
|
||||
const props = defineProps(['groupName', 'clients', 'searchString'])
|
||||
|
||||
const getClients = computed(() => {
|
||||
const s = props.searchString.toLowerCase()
|
||||
if (!props.searchString){
|
||||
return props.clients
|
||||
}
|
||||
return props.clients.filter(
|
||||
x =>
|
||||
(x.ClientID && x.ClientID.toLowerCase().includes(s)) ||
|
||||
(x.Email && x.Email.toLowerCase().includes(s) ||
|
||||
(x.Name && x.Name.toLowerCase().includes(s)))
|
||||
)
|
||||
})
|
||||
const route = useRoute()
|
||||
onMounted(() => {
|
||||
document.querySelector(".clientList .active")?.scrollIntoView()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card rounded-0 border-0">
|
||||
<div class="card-header d-flex align-items-center rounded-0">
|
||||
<h6 class="my-2">{{ groupName }}</h6>
|
||||
<span class="badge text-bg-primary ms-auto">
|
||||
<LocaleText :t="getClients.length + ' Client' + (getClients.length > 1 ? 's': '')"></LocaleText>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="list-group list-group-flush clientList">
|
||||
<RouterLink
|
||||
:key="client.ClientID"
|
||||
:id="'client_' + client.ClientID"
|
||||
active-class="active"
|
||||
:to="{ name: 'Client Viewer', params: { id: client.ClientID } }"
|
||||
class="list-group-item d-flex flex-column border-bottom list-group-item-action client"
|
||||
v-for="client in getClients" >
|
||||
<small class="text-body">
|
||||
{{ client.Email }}
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
{{ client.Name ? client.Name : 'No Name'}}
|
||||
</small>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import { fetchGet, fetchPost } from "@/utilities/fetch.js"
|
||||
import {ref} from "vue";
|
||||
import {DashboardConfigurationStore } from "@/stores/DashboardConfigurationStore.js"
|
||||
import {useRouter} from "vue-router";
|
||||
const props = defineProps(['client'])
|
||||
|
||||
|
||||
const alert = ref(false)
|
||||
const alertStatus = ref(false)
|
||||
const alertMessage = ref(false)
|
||||
const resetting = ref(false)
|
||||
const store = DashboardConfigurationStore();
|
||||
const router = useRouter()
|
||||
|
||||
const getUrl = (token) => {
|
||||
const crossServer = store.getActiveCrossServer();
|
||||
if(crossServer){
|
||||
return new URL('/client/#/reset_password?token=' + token, crossServer.host).href
|
||||
}
|
||||
return new URL('/client/#/reset_password?token=' + token, window.location.href).href
|
||||
}
|
||||
|
||||
const sendResetLink = async () => {
|
||||
resetting.value = true
|
||||
let smtpReady = false;
|
||||
let token = undefined;
|
||||
await fetchPost('/api/clients/generatePasswordResetLink', {
|
||||
ClientID: props.client.ClientID
|
||||
},async (res) => {
|
||||
if (res.status){
|
||||
token = res.data
|
||||
alertStatus.value = true
|
||||
await fetchGet('/api/email/ready', {}, (res) => {
|
||||
smtpReady = res.status
|
||||
});
|
||||
if (smtpReady){
|
||||
let body = {
|
||||
"Receiver": props.client.Email,
|
||||
"Subject": "[WGDashboard | Client] Reset Password",
|
||||
"Body":
|
||||
`Hi${props.client.Name ? ' ' + props.client.Name: ''},\n\nWe received a request to reset the password for your account. You can reset your password by visiting the link below:\n\n${getUrl(token)}\n\nThis link will expire in 30 minutes for your security. If you didn’t request a password reset, you can safely ignore this email—your current password will remain unchanged.\n\nIf you need help, feel free to contact support.\n\nBest regards,\nWGDashboard`
|
||||
}
|
||||
await fetchPost('/api/email/send', body, (res) => {
|
||||
if (res.status){
|
||||
alertMessage.value = `Send email success.`
|
||||
alert.value = true;
|
||||
}else{
|
||||
alertMessage.value = `Send email failed.`
|
||||
alertStatus.value = false;
|
||||
alert.value = true;
|
||||
}
|
||||
});
|
||||
}else{
|
||||
alertMessage.value = `Please share this URL to your client to reset the password: ${getUrl(token)}`
|
||||
alert.value = true;
|
||||
|
||||
}
|
||||
}else{
|
||||
alertStatus.value = false
|
||||
alertMessage.value = res.message
|
||||
alert.value = true
|
||||
}
|
||||
})
|
||||
resetting.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 d-flex gap-3 flex-column border-bottom">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<LocaleText t="Reset Password"></LocaleText>
|
||||
</h6>
|
||||
<button class="btn btn-sm bg-primary-subtle text-primary-emphasis rounded-3 ms-auto"
|
||||
@click="sendResetLink()"
|
||||
:class="{disabled: resetting}"
|
||||
>
|
||||
<i class="bi bi-send me-2"></i>
|
||||
<LocaleText t="Send Password Reset Link" v-if="!resetting"></LocaleText>
|
||||
<LocaleText t="Sending..." v-else></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert rounded-3 mb-0"
|
||||
:class="[alertStatus ? 'alert-success' : 'alert-danger']"
|
||||
v-if="alert">
|
||||
{{ alertMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue"
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import { fetchGet } from "@/utilities/fetch.js"
|
||||
import { DashboardConfigurationStore } from "@/stores/DashboardConfigurationStore"
|
||||
|
||||
const props = defineProps(['mode'])
|
||||
const dashboardConfigurationStore = DashboardConfigurationStore()
|
||||
const oidcStatus = ref(false)
|
||||
const oidcStatusLoading = ref(false)
|
||||
|
||||
const getStatus = async () => {
|
||||
await fetchGet("/api/oidc/status", {
|
||||
mode: props.mode
|
||||
}, (res) => {
|
||||
oidcStatus.value = res.data
|
||||
oidcStatusLoading.value = false
|
||||
})
|
||||
}
|
||||
await getStatus()
|
||||
const toggle = async () => {
|
||||
oidcStatusLoading.value = true
|
||||
await fetchGet('/api/oidc/toggle', {
|
||||
mode: props.mode
|
||||
}, (res) => {
|
||||
if (!res.status){
|
||||
oidcStatus.value = !oidcStatus.value
|
||||
dashboardConfigurationStore.newMessage("Server", res.message, "danger")
|
||||
}
|
||||
oidcStatusLoading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<LocaleText t="OpenID Connect (OIDC)"></LocaleText>
|
||||
</h6>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<label class="form-check-label" for="oidc_switch">
|
||||
<LocaleText :t="oidcStatus ? 'Enabled':'Disabled'"></LocaleText>
|
||||
</label>
|
||||
<input
|
||||
:disabled="oidcStatusLoading"
|
||||
v-model="oidcStatus"
|
||||
@change="toggle()"
|
||||
class="form-check-input" type="checkbox" role="switch" id="oidc_switch">
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div>-->
|
||||
<!-- <div class="alert alert-dark rounded-3 mb-0">-->
|
||||
<!-- <LocaleText t="Due to security reason, in order to edit OIDC configuration, you will need to edit "></LocaleText>-->
|
||||
<!-- <code>wg-dashboard-oidc-providers.json</code> <LocaleText t="directly, then restart WGDashboard to apply the latest settings."></LocaleText>-->
|
||||
<!-- </div>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from "vue"
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import OidcSettings from "@/components/clientComponents/clientSettingComponents/oidcSettings.vue";
|
||||
import { fetchGet } from "@/utilities/fetch.js"
|
||||
const emits = defineEmits(['close'])
|
||||
import { DashboardConfigurationStore } from "@/stores/DashboardConfigurationStore"
|
||||
const dashboardConfigurationStore = DashboardConfigurationStore()
|
||||
const loading = ref(false)
|
||||
const values = reactive({
|
||||
enableClients: dashboardConfigurationStore.Configuration.Clients.enable
|
||||
})
|
||||
|
||||
const toggling = ref(false)
|
||||
const toggleClientSideApp = async () => {
|
||||
toggling.value = true
|
||||
await fetchGet("/api/clients/toggleStatus", {}, (res) => {
|
||||
values.enableClients = res.data
|
||||
})
|
||||
toggling.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="position-absolute w-100 h-100 top-0 start-0 z-1 rounded-3 d-flex p-2" style="background-color: #00000070; z-index: 9999">
|
||||
<div class="card m-auto rounded-3" style="width: 700px">
|
||||
<div class="card-header bg-transparent d-flex align-items-center gap-2 border-0 p-4 pb-2">
|
||||
<h4 class="mb-0">
|
||||
<LocaleText t="Clients Settings"></LocaleText>
|
||||
</h4>
|
||||
<button type="button" class="btn-close ms-auto" @click="emits('close')"></button>
|
||||
</div>
|
||||
<div class="card-body px-4 d-flex gap-3 flex-column">
|
||||
<div class="d-flex align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<LocaleText t="Client Side App"></LocaleText>
|
||||
</h6>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<label class="form-check-label" for="oidc_switch">
|
||||
<LocaleText :t="values.enableClients ? 'Enabled':'Disabled'"></LocaleText>
|
||||
</label>
|
||||
<input
|
||||
:disabled="oidcStatusLoading"
|
||||
v-model="values.enableClients"
|
||||
@change="toggleClientSideApp()"
|
||||
class="form-check-input" type="checkbox" role="switch" id="oidc_switch">
|
||||
</div>
|
||||
</div>
|
||||
<OidcSettings mode="Client"></OidcSettings>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
143
src/static/app/src/components/clientComponents/clientViewer.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts" async>
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import { fetchGet, fetchPost } from "@/utilities/fetch.js"
|
||||
|
||||
|
||||
import {DashboardClientAssignmentStore} from "@/stores/DashboardClientAssignmentStore.js";
|
||||
import { DashboardConfigurationStore } from "@/stores/DashboardConfigurationStore.js"
|
||||
|
||||
import {computed, reactive, ref, watch} from "vue";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import ClientAssignedPeers from "@/components/clientComponents/clientAssignedPeers.vue";
|
||||
import ClientResetPassword from "@/components/clientComponents/clientResetPassword.vue";
|
||||
import ClientDelete from "@/components/clientComponents/clientDelete.vue";
|
||||
const assignmentStore = DashboardClientAssignmentStore()
|
||||
const dashboardConfigurationStore = DashboardConfigurationStore()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = computed(() => {
|
||||
return assignmentStore.getClientById(route.params.id)
|
||||
})
|
||||
const clientAssignedPeers = ref({})
|
||||
const getAssignedPeers = async () => {
|
||||
await fetchGet('/api/clients/assignedPeers', {
|
||||
ClientID: client.value.ClientID
|
||||
}, (res) => {
|
||||
clientAssignedPeers.value = res.data;
|
||||
})
|
||||
}
|
||||
const emits = defineEmits(['deleteSuccess'])
|
||||
|
||||
const clientProfile = reactive({
|
||||
Name: undefined
|
||||
})
|
||||
|
||||
if (client.value){
|
||||
watch(() => client.value.ClientID, async () => {
|
||||
clientProfile.Name = client.value.Name;
|
||||
await getAssignedPeers()
|
||||
})
|
||||
await getAssignedPeers()
|
||||
clientProfile.Name = client.value.Name
|
||||
}else{
|
||||
router.push('/clients')
|
||||
dashboardConfigurationStore.newMessage("WGDashboard", "Client does not exist", "danger")
|
||||
}
|
||||
|
||||
|
||||
|
||||
const updatingProfile = ref(false)
|
||||
const updateProfile = async () => {
|
||||
updatingProfile.value = true
|
||||
await fetchPost("/api/clients/updateProfileName", {
|
||||
ClientID: client.value.ClientID,
|
||||
Name: clientProfile.Name
|
||||
}, (res) => {
|
||||
if (res.status){
|
||||
client.value.Name = clientProfile.Name;
|
||||
dashboardConfigurationStore.newMessage("Server", "Client name update success", "success")
|
||||
}else{
|
||||
clientProfile.Name = client.value.Name;
|
||||
dashboardConfigurationStore.newMessage("Server", "Client name update failed", "danger")
|
||||
}
|
||||
updatingProfile.value = false
|
||||
})
|
||||
}
|
||||
const deleteSuccess = async () => {
|
||||
await router.push('/clients')
|
||||
await assignmentStore.getClients()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-body d-flex flex-column overflow-y-scroll h-100" v-if="client" :key="client.ClientID">
|
||||
<div class="p-4 border-bottom bg-body-tertiary z-0">
|
||||
<div class="mb-3 backLink">
|
||||
<RouterLink to="/clients" class="text-body text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-2"></i>
|
||||
Back</RouterLink>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Email"></LocaleText>
|
||||
</small>
|
||||
<h1>
|
||||
{{ client.Email }}
|
||||
</h1>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Client ID"></LocaleText>
|
||||
</small>
|
||||
<small class="ms-auto">
|
||||
<samp>{{ client.ClientID }}</samp>
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Client Name"></LocaleText>
|
||||
</small>
|
||||
<input class="form-control form-control-sm rounded-3 ms-auto"
|
||||
style="width: 300px"
|
||||
type="text" v-model="clientProfile.Name">
|
||||
<button
|
||||
@click="updateProfile()"
|
||||
aria-label="Save Client Name"
|
||||
class="btn btn-sm rounded-3 bg-success-subtle border-success-subtle text-success-emphasis">
|
||||
<i class="bi bi-save-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 0 0; overflow-y: scroll;">
|
||||
<ClientAssignedPeers
|
||||
@refresh="getAssignedPeers()"
|
||||
:clientAssignedPeers="clientAssignedPeers"
|
||||
:client="client"></ClientAssignedPeers>
|
||||
<!-- <ClientResetPassword-->
|
||||
<!-- :client="client" v-if="client.ClientGroup === 'Local'"></ClientResetPassword>-->
|
||||
<ClientDelete
|
||||
@deleteSuccess="deleteSuccess()"
|
||||
:client="client"></ClientDelete>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="d-flex w-100 h-100 text-muted">
|
||||
<div class="m-auto text-center">
|
||||
<h1>
|
||||
<i class="bi bi-person-x"></i>
|
||||
</h1>
|
||||
<p>
|
||||
<LocaleText t="Client does not exist"></LocaleText>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media screen and (min-width: 576px) {
|
||||
.backLink{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
import dayjs from "dayjs";
|
||||
import {computed, ref} from "vue";
|
||||
import {fetchGet, fetchPost, getUrl} from "@/utilities/fetch.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
const props = defineProps(["b", "delay"])
|
||||
const deleteConfirmation = ref(false)
|
||||
const restoreConfirmation = ref(false)
|
||||
const route = useRoute()
|
||||
const emit = defineEmits(["refresh", "refreshPeersList"])
|
||||
const store = DashboardConfigurationStore()
|
||||
const loading = ref(false);
|
||||
const deleteBackup = () => {
|
||||
loading.value = true;
|
||||
fetchPost("/api/deleteWireguardConfigurationBackup", {
|
||||
ConfigurationName: route.params.id,
|
||||
BackupFileName: props.b.filename
|
||||
}, (res) => {
|
||||
loading.value = false;
|
||||
if (res.status){
|
||||
emit("refresh")
|
||||
store.newMessage("Server", "Backup deleted", "success")
|
||||
}else{
|
||||
store.newMessage("Server", "Backup failed to delete", "danger")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const restoreBackup = () => {
|
||||
loading.value = true;
|
||||
fetchPost("/api/restoreWireguardConfigurationBackup", {
|
||||
ConfigurationName: route.params.id,
|
||||
BackupFileName: props.b.filename
|
||||
}, (res) => {
|
||||
loading.value = false;
|
||||
restoreConfirmation.value = false;
|
||||
if (res.status){
|
||||
emit("refreshPeersList")
|
||||
store.newMessage("Server", "Backup restored with " + props.b.filename, "success")
|
||||
}else{
|
||||
store.newMessage("Server", "Backup failed to restore", "danger")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const downloadBackup = () => {
|
||||
fetchGet("/api/downloadWireguardConfigurationBackup", {
|
||||
configurationName: route.params.id,
|
||||
backupFileName: props.b.filename
|
||||
}, (res) => {
|
||||
if (res.status){
|
||||
window.open(getUrl(`/fileDownload?file=${res.data}`), '_blank')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const delaySeconds = computed(() => {
|
||||
return props.delay + 's'
|
||||
})
|
||||
|
||||
const showContent = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card my-0 rounded-3">
|
||||
<div class="card-body position-relative">
|
||||
<Transition name="zoomReversed">
|
||||
<div
|
||||
v-if="deleteConfirmation"
|
||||
class="position-absolute w-100 h-100 confirmationContainer start-0 top-0 rounded-3 d-flex p-2">
|
||||
<div class="m-auto">
|
||||
<h5>
|
||||
<LocaleText t="Are you sure to delete this backup?"></LocaleText>
|
||||
</h5>
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center">
|
||||
<button class="btn btn-danger rounded-3"
|
||||
:disabled="loading"
|
||||
@click='deleteBackup()'>
|
||||
<LocaleText t="Yes"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteConfirmation = false"
|
||||
:disabled="loading"
|
||||
class="btn bg-secondary-subtle text-secondary-emphasis border-secondary-subtle rounded-3">
|
||||
<LocaleText t="No"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="zoomReversed">
|
||||
<div
|
||||
v-if="restoreConfirmation"
|
||||
class="position-absolute w-100 h-100 confirmationContainer start-0 top-0 rounded-3 d-flex p-2">
|
||||
<div class="m-auto">
|
||||
<h5>
|
||||
<LocaleText t="Are you sure to restore this backup?"></LocaleText>
|
||||
</h5>
|
||||
<div class="d-flex gap-2 align-items-center justify-content-center">
|
||||
<button
|
||||
:disabled="loading"
|
||||
@click="restoreBackup()"
|
||||
class="btn btn-success rounded-3">
|
||||
<LocaleText t="Yes"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
@click="restoreConfirmation = false"
|
||||
:disabled="loading"
|
||||
class="btn bg-secondary-subtle text-secondary-emphasis border-secondary-subtle rounded-3">
|
||||
<LocaleText t="No"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Backup"></LocaleText>
|
||||
</small>
|
||||
<samp>{{b.filename}}</samp>
|
||||
</div>
|
||||
<div class="d-flex flex-column">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Backup Date"></LocaleText>
|
||||
</small>
|
||||
{{dayjs(b.backupDate, "YYYYMMDDHHmmss").format("YYYY-MM-DD HH:mm:ss")}}
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center ms-auto">
|
||||
<button
|
||||
@click="downloadBackup()"
|
||||
class="btn bg-primary-subtle text-primary-emphasis border-primary-subtle rounded-3 btn-sm">
|
||||
<i class="bi bi-download"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="restoreConfirmation = true"
|
||||
class="btn bg-warning-subtle text-warning-emphasis border-warning-subtle rounded-3 btn-sm">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="deleteConfirmation = true"
|
||||
class="btn bg-danger-subtle text-danger-emphasis border-danger-subtle rounded-3 btn-sm">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="card rounded-3">
|
||||
<a role="button" class="card-header d-flex text-decoration-none align-items-center"
|
||||
:class="{'border-bottom-0': !showContent}"
|
||||
style="cursor: pointer" @click="showContent = !showContent">
|
||||
<small>.conf <LocaleText t="File"></LocaleText>
|
||||
</small>
|
||||
<i class="bi bi-chevron-down ms-auto"></i>
|
||||
</a>
|
||||
<div class="card-body" v-if="showContent">
|
||||
<textarea class="form-control rounded-3" :value="b.content"
|
||||
disabled
|
||||
style="height: 300px; font-family: var(--bs-font-monospace),sans-serif !important;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="d-flex">
|
||||
<span>
|
||||
<i class="bi bi-database me-1"></i>
|
||||
<LocaleText t="Database File"></LocaleText>
|
||||
</span>
|
||||
<i class="bi ms-auto"
|
||||
:class="[b.database ? 'text-success bi-check-circle-fill' : 'text-danger bi-x-circle-fill']"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.confirmationContainer{
|
||||
background-color: rgba(0, 0, 0, 0.53);
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(1px);
|
||||
-webkit-backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.list1-enter-active{
|
||||
transition-delay: v-bind(delaySeconds) !important;
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
import {onBeforeUnmount, onMounted, reactive, ref} from "vue";
|
||||
import {fetchGet} from "@/utilities/fetch.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import dayjs from "dayjs";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import Backup from "@/components/configurationComponents/backupRestoreComponents/backup.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const backups = ref([])
|
||||
const loading = ref(true)
|
||||
const emit = defineEmits(["close", "refreshPeersList"])
|
||||
|
||||
onMounted(() => {
|
||||
loadBackup();
|
||||
})
|
||||
|
||||
const loadBackup = () => {
|
||||
loading.value = true
|
||||
fetchGet("/api/getWireguardConfigurationBackup", {
|
||||
configurationName: route.params.id
|
||||
}, (res) => {
|
||||
backups.value = res.data;
|
||||
loading.value = false;
|
||||
})
|
||||
}
|
||||
|
||||
const createBackup = () => {
|
||||
fetchGet("/api/createWireguardConfigurationBackup", {
|
||||
configurationName: route.params.id
|
||||
}, (res) => {
|
||||
backups.value = res.data;
|
||||
loading.value = false;
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="peerSettingContainer w-100 h-100 position-absolute top-0 start-0 overflow-y-scroll" ref="editConfigurationContainer">
|
||||
<div class="d-flex h-100 w-100">
|
||||
<div class="modal-dialog-centered dashboardModal w-100 h-100 overflow-x-scroll flex-column gap-3 mx-3">
|
||||
<div class="my-5 d-flex gap-3 flex-column position-relative">
|
||||
<div class="title">
|
||||
<div class="d-flex mb-3">
|
||||
<h4 class="mb-0">
|
||||
<LocaleText t="Backup & Restore"></LocaleText>
|
||||
</h4>
|
||||
<button type="button" class="btn-close ms-auto" @click="$emit('close')"></button>
|
||||
</div>
|
||||
<button
|
||||
@click="createBackup()"
|
||||
class="btn bg-primary-subtle text-primary-emphasis border-primary-subtle rounded-3 w-100">
|
||||
<i class="bi bi-plus-circle-fill me-2"></i>
|
||||
<LocaleText t="Create Backup"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
<div class="position-relative d-flex flex-column gap-3">
|
||||
<TransitionGroup name="list1" >
|
||||
<div class="text-center title"
|
||||
key="spinner"
|
||||
v-if="loading && backups.length === 0">
|
||||
<div class="spinner-border"></div>
|
||||
</div>
|
||||
<div class="card my-0 rounded-3"
|
||||
v-else-if="!loading && backups.length === 0"
|
||||
key="noBackups"
|
||||
>
|
||||
<div class="card-body text-center text-muted">
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
<LocaleText t="No backup yet, click the button above to create backup."></LocaleText>
|
||||
</div>
|
||||
</div>
|
||||
<Backup
|
||||
@refresh="loadBackup()"
|
||||
@refreshPeersList="emit('refreshPeersList')"
|
||||
:b="b" v-for="b in backups"
|
||||
:key="b.filename"
|
||||
></Backup>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card, .title{
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 700px) {
|
||||
.card, .title{
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
.animate__fadeInUp{
|
||||
animation-timing-function: cubic-bezier(0.42, 0, 0.22, 1.0)
|
||||
}
|
||||
|
||||
.list1-move, /* apply transition to moving elements */
|
||||
.list1-enter-active,
|
||||
.list1-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.42, 0, 0.22, 1.0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.list1-enter-from,
|
||||
.list1-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
/* ensure leaving items are taken out of layout flow so that moving
|
||||
animations can be calculated correctly. */
|
||||
.list1-leave-active {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import { fetchPost } from "@/utilities/fetch.js"
|
||||
|
||||
const props = defineProps(['configuration'])
|
||||
const description = ref(props.configuration.Info.Description)
|
||||
const showStatus = ref(false)
|
||||
const status = ref(false)
|
||||
|
||||
const updateDescription = async () => {
|
||||
await fetchPost("/api/updateWireguardConfigurationInfo", {
|
||||
Name: props.configuration.Name,
|
||||
Key: "Description",
|
||||
Value: description.value
|
||||
}, (res) => {
|
||||
status.value = res.status
|
||||
toggleStatus()
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSuccess = () => {
|
||||
status.value = true
|
||||
toggleStatus()
|
||||
}
|
||||
|
||||
const toggleFail = () => {
|
||||
status.value = false
|
||||
toggleStatus()
|
||||
}
|
||||
|
||||
const toggleStatus = () => {
|
||||
showStatus.value = true
|
||||
setTimeout(() => {
|
||||
showStatus.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex gap-1 flex-column">
|
||||
<label for="configurationDescription">
|
||||
<small style="white-space: nowrap" class="text-muted">
|
||||
<i class="bi bi-pencil-fill me-2"></i>Notes
|
||||
</small>
|
||||
</label>
|
||||
<input type="text"
|
||||
:class="[showStatus ? [status ? 'is-valid':'is-invalid'] : undefined]"
|
||||
id="configurationDescription"
|
||||
v-model="description"
|
||||
@change="updateDescription()"
|
||||
class="form-control rounded-3 bg-transparent form-control-sm">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
import {onMounted, ref, useTemplateRef} from "vue";
|
||||
import {fetchGet, fetchPost} from "@/utilities/fetch.js";
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
const route = useRoute()
|
||||
const configurationName = route.params.id;
|
||||
const input = ref("")
|
||||
const router = useRouter()
|
||||
const store = DashboardConfigurationStore()
|
||||
const deleting = ref(false)
|
||||
|
||||
const deleteConfiguration = () => {
|
||||
clearInterval(store.Peers.RefreshInterval)
|
||||
deleting.value = true;
|
||||
fetchPost("/api/deleteWireguardConfiguration", {
|
||||
ConfigurationName: configurationName
|
||||
}, (res) => {
|
||||
if (res.status){
|
||||
router.push('/')
|
||||
store.newMessage("Server", "Configuration deleted", "success")
|
||||
}else{
|
||||
deleting.value = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const loading = ref(true)
|
||||
const backups = ref([])
|
||||
const getBackup = () => {
|
||||
loading.value = true;
|
||||
fetchGet("/api/getWireguardConfigurationBackup", {
|
||||
configurationName: configurationName
|
||||
}, (res) => {
|
||||
backups.value = res.data;
|
||||
loading.value = false;
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
getBackup()
|
||||
})
|
||||
const emits = defineEmits(["backup", "close"])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="peerSettingContainer w-100 h-100 position-absolute top-0 start-0 overflow-y-scroll">
|
||||
<div class="container d-flex h-100 w-100">
|
||||
<div class="m-auto modal-dialog-centered dashboardModal" style="width: 700px">
|
||||
<div class="card rounded-3 shadow flex-grow-1 bg-danger-subtle border-danger-subtle" id="deleteConfigurationContainer">
|
||||
<div class="card-header bg-transparent d-flex align-items-center gap-2 border-0 p-4 pb-0">
|
||||
<h5 class="mb-0">
|
||||
<LocaleText t="Are you sure to delete this configuration?"></LocaleText>
|
||||
</h5>
|
||||
<button type="button" class="btn-close ms-auto" @click="emits('close')"></button>
|
||||
</div>
|
||||
<div class="card-body px-4 text-muted">
|
||||
<p class="mb-0">
|
||||
<LocaleText t="Once you deleted this configuration:"></LocaleText>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<LocaleText t="All connected peers will get disconnected"></LocaleText>
|
||||
</li>
|
||||
<li>
|
||||
<LocaleText t="Both configuration file (.conf) and database table related to this configuration will get deleted"></LocaleText>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="alert"
|
||||
:class="[loading ? 'alert-secondary' : (backups.length > 0 ? 'alert-success' : 'alert-danger')]">
|
||||
<div v-if="loading">
|
||||
<i class="bi bi-search me-2"></i>
|
||||
<LocaleText t="Checking backups..."></LocaleText>
|
||||
</div>
|
||||
<div v-else-if="backups.length > 0">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
<LocaleText :t="'This configuration have ' + backups.length + ' backups'"></LocaleText>
|
||||
</div>
|
||||
<div v-else class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
<LocaleText t="This configuration have no backup"></LocaleText>
|
||||
<a role="button"
|
||||
@click="emits('backup')"
|
||||
class="ms-auto btn btn-sm btn-primary rounded-3">
|
||||
<i class="bi bi-clock-history me-2"></i>
|
||||
<LocaleText t="Backup"></LocaleText>
|
||||
</a>
|
||||
<a role="button"
|
||||
@click="getBackup()"
|
||||
class="btn btn-sm btn-primary rounded-3">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<p>
|
||||
<LocaleText t="If you're sure, please type in the configuration name below and click Delete"></LocaleText>
|
||||
</p>
|
||||
<input class="form-control rounded-3 mb-3"
|
||||
:placeholder="configurationName"
|
||||
v-model="input"
|
||||
type="text">
|
||||
<button class="btn btn-danger w-100"
|
||||
@click="deleteConfiguration()"
|
||||
:disabled="input !== configurationName || deleting">
|
||||
<i class="bi bi-trash-fill me-2 rounded-3"></i>
|
||||
<LocaleText t="Delete" v-if="!deleting"></LocaleText>
|
||||
<LocaleText t="Deleting..." v-else></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|