Compare commits
1769 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 258564208b | |||
| e2e8222005 | |||
| 75f2826805 | |||
| 730af4f2ee | |||
| e5737ebd9d | |||
| 7f24904164 | |||
| 9ff7198602 | |||
| 6d56967a0f | |||
| 584118805a | |||
| 68b3f01082 | |||
| 77a82cb84b | |||
| 844b19495d | |||
| b8b721f2bd | |||
| 4f93d48ac6 | |||
| 02d1bf5a2c | |||
| fb3606c2b7 | |||
| a5bc2cd48f | |||
| 72151ac6a3 | |||
| e2c8b3bf02 | |||
| 06dd641274 | |||
| 7d9bf01d27 | |||
| b9553e0a05 | |||
| 993dcaf235 | |||
| 8d7c39bac4 | |||
| 80933ab571 | |||
| b5b30c8119 | |||
| bca06129d9 | |||
| a136b86d81 | |||
| 8d555d8ae7 | |||
| c22e61add2 | |||
| 2c83f1ce57 | |||
| f2750de153 | |||
| e0934f11d6 | |||
| 422924026a | |||
| 332e22e534 | |||
| c7bf30e18e | |||
| 5ade5176c0 | |||
| f9e4fafe9f | |||
| 201f2ef1f0 | |||
| 32af4fdcca | |||
| d254b4cc39 | |||
| d0ad4e05bf | |||
| c75854cb7b | |||
| 8cc71913b3 | |||
| 99abe6bcd2 | |||
| f5ad7a49ce | |||
| e42ecf3c99 | |||
| b58bf9dc17 | |||
| 745c8aaf3e | |||
| 0ca73bf80b | |||
| fd76cabd01 | |||
| fcf9300a49 | |||
| 649b66ba1a | |||
| b5d73a0861 | |||
| 1453996ccb | |||
| 5bf9b97d70 | |||
| 5c1e46f5a6 | |||
| c2665cbae8 | |||
| 3934248a6b | |||
| 7ae3bb5601 | |||
| 9890d4f3bc | |||
| 5b47fc16d9 | |||
| 344770a0a4 | |||
| fc11cacd24 | |||
| 737ebdd0c6 | |||
| d06dddfa24 | |||
| 710d631ffe | |||
| 7d9a845ca0 | |||
| cc6e5ffb97 | |||
| 8d29cb7488 | |||
| 5c588ea01a | |||
| dade9935da | |||
| 34cd3aaaca | |||
| 78d3f338fb | |||
| d801c5911e | |||
| 003743c074 | |||
| 4cef39d3b1 | |||
| 83f0df32d7 | |||
| 24f269191c | |||
| 94a0d5a0a4 | |||
| c0fbf4dd0c | |||
| 37b6161075 | |||
| c9b792c370 | |||
| 2103d547a1 | |||
| 3918e220d2 | |||
| c3eaaed43b | |||
| e0bf648076 | |||
| bb298164e4 | |||
| 898694b9be | |||
| f0f9ac92e6 | |||
| ea0229a8ab | |||
| 66fc7cd91d | |||
| c11d4f01b1 | |||
| a8f498f292 | |||
| 6854e2275f | |||
| 6662e79ac5 | |||
| 9e77430634 | |||
| b8e070824a | |||
| 304d4293f3 | |||
| a03e7f5b53 | |||
| ea57c8cad4 | |||
| 280802cf74 | |||
| fbce01d846 | |||
| 591b60c336 | |||
| 78ef870d91 | |||
| bc19d82aed | |||
| 79b41e1056 | |||
| 805f03d231 | |||
| 62acc1081f | |||
| 257a2c2d9f | |||
| ed9b05cdf9 | |||
| 2cc6fbd643 | |||
| bdfc260dd2 | |||
| 7ad65956c9 | |||
| b3dc60b7ca | |||
| bed08109fa | |||
| 06cedec090 | |||
| 1d2c468d03 | |||
| e84616f5f5 | |||
| caa9a90476 | |||
| 62be683dd8 | |||
| ffa63b3f82 | |||
| c5a7788394 | |||
| 99c42ff2c1 | |||
| fd2b7bb917 | |||
| 922a494df0 | |||
| c478ec0095 | |||
| 3abbe17910 | |||
| 349076599f | |||
| 92d299507f | |||
| 6f2d61e918 | |||
| ed13babf6a | |||
| 80db82eb93 | |||
| 8d20334d6e | |||
| 03a95ad018 | |||
| aa59cc8af0 | |||
| 80d63c2c89 | |||
| 03a4acd72b | |||
| 53118cf660 | |||
| 16b1baf81e | |||
| 960e0d0965 | |||
| 8f41222e43 | |||
| e7bc447166 | |||
| 1736359615 | |||
| 58784b1e22 | |||
| 07f2bfd279 | |||
| 6d62db2890 | |||
| a1b23c4aab | |||
| e272a096da | |||
| b8ca24c5bf | |||
| a05d09f0de | |||
| 1807a79c4f | |||
| 77f6826af9 | |||
| 7e39eedf35 | |||
| 0b4b48ceae | |||
| dad931e898 | |||
| 2e387730cb | |||
| c9b6775793 | |||
| a6eaf30c2b | |||
| 6f91d199d8 | |||
| 7ea486307a | |||
| 37d254c827 | |||
| b4014eb317 | |||
| c7824fa184 | |||
| 13c5bd45f9 | |||
| 465d102bd0 | |||
| 93007cd56c | |||
| 8367576dc8 | |||
| dbc06145b3 | |||
| 4efcf0e7c8 | |||
| 0a4bbc1106 | |||
| b08c1f6699 | |||
| 6828b905ef | |||
| f72b405f6d | |||
| 27ae99864c | |||
| 82ba15ba5a | |||
| af01db7bc9 | |||
| 6ec0a65d0c | |||
| c32ccfc9ed | |||
| ef818388c8 | |||
| f5aa45a3e1 | |||
| b7a047a743 | |||
| 05ec985453 | |||
| 88ed9c3e79 | |||
| 3fb03c8dfe | |||
| ff304027cd | |||
| 3140d46c94 | |||
| 9d476af384 | |||
| 0d380672f3 | |||
| e2fb8dca5b | |||
| eaba4c90c5 | |||
| b4ca7373f3 | |||
| 905c068ed5 | |||
| 02886dd420 | |||
| 5bf7754d6c | |||
| 7a0a3055c4 | |||
| bf73efb3b4 | |||
| b4da212158 | |||
| 4a04d42600 | |||
| 42bbfbe538 | |||
| 31ec2430c4 | |||
| f0b56c9f1d | |||
| 6f9f01c991 | |||
| b822ccb19d | |||
| dcb11671f3 | |||
| 282599d36f | |||
| f11acdc68f | |||
| 971e127cfd | |||
| 9b2c59d529 | |||
| 14dc049a66 | |||
| d3d0e7c615 | |||
| 89423ccb8b | |||
| 746f31f70c | |||
| 3aa9eab2bd | |||
| 1bcc8fed5e | |||
| 1213a030e3 | |||
| 3b2344b477 | |||
| c53b0fe2d4 | |||
| 0a92269456 | |||
| 1cb8906893 | |||
| f76579fe5e | |||
| db05ff5e98 | |||
| a05d6979ec | |||
| be40d17172 | |||
| 9e11339979 | |||
| a56129afe0 | |||
| 8933ea999c | |||
| ae3b8f6494 | |||
| 33487ee03f | |||
| 58ecae1162 | |||
| 8777da10e4 | |||
| 4ea971c1b9 | |||
| 0fa4759c3a | |||
| fe7b9730d3 | |||
| 040f6a8393 | |||
| b17575b197 | |||
| dee4f757e7 | |||
| 5ae08d02ab | |||
| b83aec4965 | |||
| 68c4837780 | |||
| 46fe5e99fe | |||
| 9ffb537cfb | |||
| cc7e6f852d | |||
| f83e99c62c | |||
| bc8b8e7982 | |||
| 3b761ca2a8 | |||
| edc21f9830 | |||
| d025ec4dff | |||
| ab334a393f | |||
| 2a9044086f | |||
| c3038ec60c | |||
| 0fa827ba00 | |||
| 26002fc372 | |||
| 59acba8c08 | |||
| 4c5ee45fdb | |||
| 6062120d0d | |||
| cd3ffb2126 | |||
| a8c11cc445 | |||
| fcb89aac6b | |||
| 3704d8f6e7 | |||
| 8c75d172c5 | |||
| 5687302081 | |||
| 9020b16a9a | |||
| d5462df4da | |||
| 9021ca31af | |||
| 83cb8b7f03 | |||
| baf2658eb8 | |||
| 3f19b05d28 | |||
| 5e6be1f830 | |||
| 14d1a3038f | |||
| 6a15e2e587 | |||
| 0679805728 | |||
| 62a03419a5 | |||
| 81f6b0767e | |||
| c7ca5c749b | |||
| 1daac09b7c | |||
| 4d3dd68ac0 | |||
| de9e4a6270 | |||
| bf8f347245 | |||
| a70d09ac7a | |||
| 919b344da4 | |||
| 44f013a678 |
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
.github
|
||||
*.md
|
||||
tests/
|
||||
docs/
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [donaldzou]
|
||||
patreon: DonaldDonnyZou
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe The Problem**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected Error / Traceback**
|
||||
```
|
||||
Please provide the error traceback here
|
||||
```
|
||||
|
||||
**To Reproduce**
|
||||
Please provide how you run the dashboard
|
||||
|
||||
**OS Information:**
|
||||
- OS: [e.g. Ubuntu 18.02]
|
||||
- Python Version: [e.g v3.7]
|
||||
|
||||
**Sample of your `.conf` file**
|
||||
```
|
||||
Please provide a sample of your configuration file that you are having problem with. You can replace your public key and private key to ABCD...
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
@@ -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: "daily"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker-compose"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -1,7 +1,52 @@
|
||||
.vscode/sftp.json
|
||||
src/.vscode/sftp.json
|
||||
.vscode
|
||||
.DS_Store
|
||||
wg.db
|
||||
*.json
|
||||
.idea
|
||||
src/db
|
||||
__pycache__
|
||||
src/test.py
|
||||
*.db
|
||||
src/wg-dashboard.ini
|
||||
src/static/pic.xd
|
||||
*.conf
|
||||
private_key.txt
|
||||
public_key.txt
|
||||
venv/**
|
||||
log/**
|
||||
release/*
|
||||
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/*
|
||||
@@ -1,49 +1,86 @@
|
||||
# Wireguard Dashboard
|
||||
Monitoring Wireguard is not convinient, need to login into server and type `wg show`. That's why this platform is being created, to view all configurations in a more straight forward way.
|
||||
> [!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!
|
||||
|
||||
## 💡Features
|
||||
> [!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!
|
||||
|
||||
- Add peers in configuration
|
||||
- Manage peer names
|
||||
- Delete peers
|
||||
- And many more coming up! Welcome to contribute to this project!
|
||||
|
||||
## 📝Requirement
|
||||
|
||||
- Ubuntu 18.04.1 LTS, other OS might work, but haven't test yet.
|
||||
- **Wireguard**
|
||||
- Configuration files under **/etc/wireguard**
|
||||
- Python 3.7
|
||||

|
||||
|
||||
## 🛠Install
|
||||
|
||||
**1. Install Python Dependencies**
|
||||
<p align="center">
|
||||
<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>
|
||||
|
||||
```
|
||||
$ python3 -m pip install flask tinydb
|
||||
```
|
||||
<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">
|
||||
</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>
|
||||
|
||||
**2. Install Wireguard Dashboard**
|
||||
<p align="center"><b><i>This project is not affiliate to the official WireGuard Project</i></b></p>
|
||||
|
||||
```
|
||||
$ git clone https://github.com/donaldzou/Wireguard-Dashboard.git
|
||||
$ cd Wireguard-Dashboard/src
|
||||
$ python3 dashboard.py
|
||||
```
|
||||
<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 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>
|
||||
|
||||
Access your server with port `10086` ! e.g (http://your_server_ip:10086)
|
||||
<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>
|
||||
|
||||
**3. Install with Production Mode (Optional)**
|
||||
<hr>
|
||||
<h4 align="center">
|
||||
for more information, visit our
|
||||
</h4>
|
||||
<h1 align="center">
|
||||
<a href="https://wgdashboard.dev">Official Website</a>
|
||||
</h1>
|
||||
|
||||
```
|
||||
$ cd Wireguard-Dashboard/src
|
||||
$ export FLASK_APP=dashboard.py
|
||||
$ export FLASK_RUN_HOST=0.0.0.0
|
||||
$ export FLASK_ENV=development
|
||||
$ export FLASK_DEBUG=0
|
||||
$ flask run
|
||||
```
|
||||
|
||||
## 🔍Example
|
||||

|
||||

|
||||
# 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=""/>
|
||||
|
||||
@@ -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: |
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,125 @@
|
||||
#
|
||||
# AWG GOLANG BUILDING STAGE
|
||||
# Base: Alpine
|
||||
#
|
||||
|
||||
FROM golang:1.25-alpine AS awg-go
|
||||
|
||||
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
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
WORKDIR /workspace/awg
|
||||
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
|
||||
|
||||
RUN apk add --no-cache \
|
||||
make \
|
||||
git \
|
||||
build-base \
|
||||
linux-headers \
|
||||
ca-certificates
|
||||
|
||||
RUN mkdir -p /workspace && \
|
||||
git clone https://github.com/WGDashboard/amneziawg-tools /workspace/awg-tools
|
||||
|
||||
WORKDIR /workspace/awg-tools/src
|
||||
RUN make && chmod +x wg*
|
||||
|
||||
#
|
||||
# PIP DEPENDENCY BUILDING
|
||||
# Base: Alpine
|
||||
#
|
||||
FROM python:3.13-alpine AS pip-builder
|
||||
|
||||
|
||||
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 ./src/requirements.txt /opt/wgdashboard/src
|
||||
RUN . /opt/wgdashboard/src/venv/bin/activate && \
|
||||
pip3 install --upgrade pip && \
|
||||
pip3 install -r /opt/wgdashboard/src/requirements.txt
|
||||
|
||||
#
|
||||
# WGDashboard RUNNING STAGE
|
||||
# Base: Alpine
|
||||
#
|
||||
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 builders
|
||||
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
|
||||
RUN mkdir /data /configs -p ${WGDASH}/src /etc/amnezia/amneziawg
|
||||
|
||||
# Copy app source and prebuilt venv only (no pip cache)
|
||||
COPY ./src ${WGDASH}/src
|
||||
COPY --from=pip-builder /opt/wgdashboard/src/venv /opt/wgdashboard/src/venv
|
||||
|
||||
# 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
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD sh -c 'pgrep gunicorn > /dev/null && pgrep tail > /dev/null' || exit 1
|
||||
|
||||
COPY ./docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
EXPOSE 10086
|
||||
WORKDIR $WGDASH/src
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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 running the container. <br>
|
||||
In this example, WireGuard is integrated into the container itself, so it should be a run-and-go(/out-of-the-box).<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 image from the repository, (docker.io)`donaldzou/wgdashboard:latest`.<br>
|
||||
From there either use the environment variables describe 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 use this port if you want to use it out of the box.<br>
|
||||
Otherwise edit the configuration file in `/etc/wireguard/wg0.conf`.
|
||||
|
||||
# WGDashboard: 🐳 Docker Deployment Guide
|
||||
|
||||
To run the container, you can either pull the image from Docker Hub or build it yourself. The image is available at:
|
||||
|
||||
```
|
||||
docker.io/donaldzou/wgdashboard:latest
|
||||
```
|
||||
|
||||
> `docker.io` is in most cases automatically resolved by the Docker application.
|
||||
|
||||
### 🔧 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 \
|
||||
donaldzou/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: donaldzou/wgdashboard:latest
|
||||
restart: unless-stopped
|
||||
container_name: wgdashboard
|
||||
environment:
|
||||
# - tz=Europe/Amsterdam
|
||||
# - global_dns=1.1.1.1
|
||||
# - public_ip=YOUR_PUBLIC_IP
|
||||
ports:
|
||||
- 10086:10086/tcp
|
||||
- 51820:51820/udp
|
||||
volumes:
|
||||
- conf:/etc/wireguard
|
||||
- data:/data
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
volumes:
|
||||
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 WGDashboard is currently in **alpha** stage. While the update process may work, it's still under testing.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
wireguard-dashboard:
|
||||
image: donaldzou/wgdashboard:latest
|
||||
restart: unless-stopped
|
||||
container_name: wgdashboard
|
||||
#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.
|
||||
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:
|
||||
@@ -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 [ ! -f "/etc/wireguard/wg0.conf" ]; 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
|
||||
@@ -0,0 +1,6 @@
|
||||
version: "1.0"
|
||||
linter: jetbrains/qodana-python:2024.3
|
||||
profile:
|
||||
name: qodana.recommended
|
||||
include:
|
||||
- name: CheckDependencyLicenses
|
||||
@@ -1,2 +0,0 @@
|
||||
Flask==1.1.2
|
||||
tinydb==4.3.0
|
||||
@@ -0,0 +1,4 @@
|
||||
authenticator = standalone
|
||||
noninteractive = true
|
||||
agree-tos = true
|
||||
rsa-key-size = 2048
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
You can delete this later ;)
|
||||
@@ -0,0 +1,26 @@
|
||||
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')
|
||||
|
||||
def post_worker_init(worker):
|
||||
dashboard.startThreads()
|
||||
dashboard.DashboardPlugins.startThreads()
|
||||
|
||||
worker_class = 'gthread'
|
||||
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)
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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__
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 {c.Name} 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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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, ""
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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] = {}
|
||||
@@ -0,0 +1,17 @@
|
||||
bcrypt
|
||||
ifcfg
|
||||
psutil
|
||||
pyotp
|
||||
Flask
|
||||
flask-cors
|
||||
icmplib
|
||||
gunicorn
|
||||
requests
|
||||
tcconfig
|
||||
sqlalchemy
|
||||
sqlalchemy_utils
|
||||
psycopg
|
||||
PyMySQL
|
||||
tzlocal
|
||||
python-jose
|
||||
pydantic
|
||||
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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.0.5",
|
||||
"@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": "^5.0.3",
|
||||
"npm": "^10.5.0",
|
||||
"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": "^11.1.0",
|
||||
"vue": "^3.5.17",
|
||||
"vue-chartjs": "^5.3.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"vite": "^7.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const proxy = "http://wg.local:10086/"
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 126 KiB |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,270 @@
|
||||
<script setup>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {reactive, ref, watch} from "vue";
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import {fetchPost} from "@/utilities/fetch.js";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import UpdateConfigurationName
|
||||
from "@/components/configurationComponents/editConfigurationComponents/updateConfigurationName.vue";
|
||||
import EditRawConfigurationFile
|
||||
from "@/components/configurationComponents/editConfigurationComponents/editRawConfigurationFile.vue";
|
||||
import DeleteConfiguration from "@/components/configurationComponents/deleteConfiguration.vue";
|
||||
import ConfigurationBackupRestore from "@/components/configurationComponents/configurationBackupRestore.vue";
|
||||
import EditPeerSettingsOverride
|
||||
from "@/components/configurationComponents/editConfigurationComponents/editPeerSettingsOverride.vue";
|
||||
const props = defineProps({
|
||||
configurationInfo: Object
|
||||
})
|
||||
const wgStore = WireguardConfigurationsStore()
|
||||
const store = DashboardConfigurationStore()
|
||||
const saving = ref(false)
|
||||
const data = reactive(JSON.parse(JSON.stringify(props.configurationInfo)))
|
||||
const editPrivateKey = ref(false)
|
||||
const dataChanged = ref(false)
|
||||
const reqField = reactive({
|
||||
PrivateKey: true,
|
||||
IPAddress: true,
|
||||
ListenPort: true
|
||||
})
|
||||
const genKey = () => {
|
||||
if (wgStore.checkWGKeyLength(data.PrivateKey)){
|
||||
reqField.PrivateKey = true;
|
||||
data.PublicKey = window.wireguard.generatePublicKey(data.PrivateKey)
|
||||
}else{
|
||||
reqField.PrivateKey = false;
|
||||
}
|
||||
}
|
||||
const resetForm = () => {
|
||||
dataChanged.value = false;
|
||||
Object.assign(data, JSON.parse(JSON.stringify(props.configurationInfo)))
|
||||
}
|
||||
const emit = defineEmits(["changed", "close", "refresh", "dataChanged"])
|
||||
const saveForm = () => {
|
||||
saving.value = true
|
||||
fetchPost("/api/updateWireguardConfiguration", data, (res) => {
|
||||
saving.value = false
|
||||
if (res.status){
|
||||
store.newMessage("Server", "Configuration saved", "success")
|
||||
dataChanged.value = false
|
||||
emit("dataChanged", res.data)
|
||||
|
||||
}else{
|
||||
store.newMessage("Server", res.message, "danger")
|
||||
}
|
||||
})
|
||||
}
|
||||
const updateConfigurationName = ref(false)
|
||||
|
||||
watch(data, () => {
|
||||
dataChanged.value = JSON.stringify(data) !== JSON.stringify(props.configurationInfo);
|
||||
}, {
|
||||
deep: true
|
||||
})
|
||||
|
||||
const editRawConfigurationFileModal = ref(false)
|
||||
const backupRestoreModal = ref(false)
|
||||
const deleteConfigurationModal = ref(false)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="peerSettingContainer w-100 h-100 position-absolute top-0 start-0" ref="editConfigurationContainer">
|
||||
<div class="w-100 h-100 overflow-y-scroll">
|
||||
<TransitionGroup name="zoom">
|
||||
<EditRawConfigurationFile
|
||||
name="EditRawConfigurationFile"
|
||||
v-if="editRawConfigurationFileModal"
|
||||
@close="editRawConfigurationFileModal = false">
|
||||
</EditRawConfigurationFile>
|
||||
<DeleteConfiguration
|
||||
key="DeleteConfiguration"
|
||||
@backup="backupRestoreModal = true"
|
||||
@close="deleteConfigurationModal = false"
|
||||
v-if="deleteConfigurationModal">
|
||||
</DeleteConfiguration>
|
||||
<ConfigurationBackupRestore
|
||||
@close="backupRestoreModal = false"
|
||||
@refreshPeersList="emit('refresh')"
|
||||
v-if="backupRestoreModal">
|
||||
</ConfigurationBackupRestore>
|
||||
</TransitionGroup>
|
||||
|
||||
<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">
|
||||
<div class="card-header bg-transparent d-flex align-items-center gap-2 border-0 p-4">
|
||||
<h4 class="mb-0">
|
||||
<LocaleText t="Configuration Settings"></LocaleText>
|
||||
</h4>
|
||||
<button type="button" class="btn-close ms-auto" @click="$emit('close')"></button>
|
||||
</div>
|
||||
<div class="card-body px-4 pb-4">
|
||||
<div class="d-flex gap-2 flex-column">
|
||||
<div class="d-flex align-items-center gap-3" v-if="!updateConfigurationName">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Name"></LocaleText>
|
||||
</small>
|
||||
<small>{{data.Name}}</small>
|
||||
<button
|
||||
@click="updateConfigurationName = true"
|
||||
class="btn btn-sm bg-danger-subtle border-danger-subtle text-danger-emphasis rounded-3 ms-auto">
|
||||
<LocaleText t="Update Name"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
<UpdateConfigurationName
|
||||
@close="updateConfigurationName = false"
|
||||
:configuration-name="data.Name"
|
||||
v-if="updateConfigurationName"></UpdateConfigurationName>
|
||||
<template v-else>
|
||||
<hr>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<small class="text-muted" style="word-break: keep-all">
|
||||
<LocaleText t="Public Key"></LocaleText>
|
||||
</small>
|
||||
<small class="ms-auto" style="word-break: break-all">
|
||||
{{data.PublicKey}}
|
||||
</small>
|
||||
</div>
|
||||
<hr>
|
||||
<div>
|
||||
<div class="d-flex">
|
||||
<label for="configuration_private_key" class="form-label">
|
||||
<small class="text-muted d-block">
|
||||
<LocaleText t="Private Key"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input class="form-check-input"
|
||||
type="checkbox" role="switch" id="editPrivateKeySwitch"
|
||||
v-model="editPrivateKey"
|
||||
>
|
||||
<label class="form-check-label" for="editPrivateKeySwitch">
|
||||
<small>Edit</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving || !editPrivateKey"
|
||||
:class="{'is-invalid': !reqField.PrivateKey}"
|
||||
@keyup="genKey()"
|
||||
v-model="data.PrivateKey"
|
||||
id="configuration_private_key">
|
||||
</div>
|
||||
<div>
|
||||
<label for="configuration_ipaddress_cidr" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="IP Address/CIDR"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="data.Address"
|
||||
id="configuration_ipaddress_cidr">
|
||||
</div>
|
||||
<div>
|
||||
<label for="configuration_listen_port" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Listen Port"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="data.ListenPort"
|
||||
id="configuration_listen_port">
|
||||
|
||||
</div>
|
||||
<div class="accordion mt-2" id="editConfigurationOptionalAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed px-3 py-2" type="button" data-bs-toggle="collapse" data-bs-target="#editOptionalAccordionCollapse">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Optional Settings"></LocaleText>
|
||||
</small>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="editOptionalAccordionCollapse"
|
||||
class="accordion-collapse collapse" data-bs-parent="#editConfigurationOptionalAccordion">
|
||||
<div class="accordion-body d-flex flex-column gap-3">
|
||||
<div v-for="key in ['Table', 'PreUp', 'PreDown', 'PostUp', 'PostDown']">
|
||||
<label :for="'configuration_' + key" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText :t="key"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="data[key]"
|
||||
:id="'configuration_' + key">
|
||||
</div>
|
||||
<div v-for="key in ['Jc', 'Jmin', 'Jmax', 'S1', 'S2', 'H1', 'H2', 'H3', 'H4']"
|
||||
v-if="configurationInfo.Protocol === 'awg'">
|
||||
<label :for="'configuration_' + key" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText :t="key"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="number" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="data[key]"
|
||||
:id="'configuration_' + key">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
<button class="btn btn-sm bg-secondary-subtle border-secondary-subtle text-secondary-emphasis rounded-3 shadow ms-auto"
|
||||
@click="resetForm()"
|
||||
:disabled="!dataChanged || saving">
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||
<LocaleText t="Reset"></LocaleText>
|
||||
</button>
|
||||
<button class="btn btn-sm bg-primary-subtle border-primary-subtle text-primary-emphasis rounded-3 shadow"
|
||||
:disabled="!dataChanged || saving"
|
||||
@click="saveForm()"
|
||||
>
|
||||
<i class="bi bi-save-fill me-2"></i>
|
||||
<LocaleText t="Save"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
<hr>
|
||||
<EditPeerSettingsOverride :configuration="configurationInfo"></EditPeerSettingsOverride>
|
||||
<hr>
|
||||
<h5 class="mb-3">
|
||||
<LocaleText t="Danger Zone"></LocaleText>
|
||||
</h5>
|
||||
<div class="d-flex gap-2 flex-column">
|
||||
<button
|
||||
@click="backupRestoreModal = true"
|
||||
class="btn bg-warning-subtle border-warning-subtle text-warning-emphasis rounded-3 text-start d-flex">
|
||||
<i class="bi bi-copy me-auto"></i>
|
||||
<LocaleText t="Backup & Restore"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
@click="editRawConfigurationFileModal = true"
|
||||
class="btn bg-warning-subtle border-warning-subtle text-warning-emphasis rounded-3 d-flex">
|
||||
<i class="bi bi-pen me-auto"></i>
|
||||
<LocaleText t="Edit Raw Configuration File"></LocaleText>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="deleteConfigurationModal = true"
|
||||
class="btn bg-danger-subtle border-danger-subtle text-danger-emphasis rounded-3 d-flex mt-4">
|
||||
<i class="bi bi-trash-fill me-auto"></i>
|
||||
<LocaleText t="Delete Configuration"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import { fetchPost } from "@/utilities/fetch.js"
|
||||
import {onMounted, reactive, ref} from "vue";
|
||||
const props = defineProps(['configuration'])
|
||||
const saving = ref(false)
|
||||
const overridePeerSettings = ref({...props.configuration.Info.OverridePeerSettings})
|
||||
const edited = ref(false)
|
||||
const errorMsg = ref("")
|
||||
|
||||
onMounted(() => {
|
||||
document.querySelectorAll("#editPeerSettingsOverride input").forEach(
|
||||
x => x.addEventListener("change", () => {
|
||||
edited.value = true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
overridePeerSettings.value = props.configuration.Info.OverridePeerSettings
|
||||
edited.value = false
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
document.querySelectorAll("#editPeerSettingsOverride input").forEach(
|
||||
x => x.classList.remove("is-invalid", "is-valid")
|
||||
)
|
||||
await fetchPost("/api/updateWireguardConfigurationInfo", {
|
||||
Name: props.configuration.Name,
|
||||
Key: "OverridePeerSettings",
|
||||
Value: overridePeerSettings.value
|
||||
}, (res) => {
|
||||
if (res.status){
|
||||
edited.value = false
|
||||
props.configuration.Info.OverridePeerSettings = overridePeerSettings.value
|
||||
document.querySelectorAll("#editPeerSettingsOverride input").forEach(
|
||||
x => x.classList.add("is-valid")
|
||||
)
|
||||
}else{
|
||||
errorMsg.value = res.message
|
||||
document.querySelector(`#override_${res.data}`).classList.add("is-invalid")
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="editPeerSettingsOverride">
|
||||
<h5 class="mb-0">
|
||||
<LocaleText t="Override Peer Settings"></LocaleText>
|
||||
</h5>
|
||||
<h6 class="mb-3 text-muted">
|
||||
<small>
|
||||
<LocaleText t="Only apply to peers in this configuration"></LocaleText>
|
||||
</small>
|
||||
</h6>
|
||||
<div class="d-flex gap-2 flex-column">
|
||||
<div>
|
||||
<label for="override_DNS" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="DNS"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.DNS"
|
||||
id="override_DNS">
|
||||
<div class="invalid-feedback">{{ errorMsg }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="override_EndpointAllowedIPs" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Endpoint Allowed IPs"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.EndpointAllowedIPs"
|
||||
id="override_EndpointAllowedIPs">
|
||||
<div class="invalid-feedback">{{ errorMsg }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="override_ListenPort" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Listen Port"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.ListenPort"
|
||||
id="override_ListenPort">
|
||||
<div class="invalid-feedback">{{ errorMsg }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="override_MTU" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="MTU"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text"
|
||||
class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.MTU"
|
||||
id="override_MTU">
|
||||
<div class="invalid-feedback">{{ errorMsg }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="override_PeerRemoteEndpoint" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Peer Remote Endpoint"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.PeerRemoteEndpoint"
|
||||
id="override_PeerRemoteEndpoint">
|
||||
</div>
|
||||
<div>
|
||||
<label for="override_persistent_keepalive" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Persistent Keepalive"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="saving"
|
||||
v-model="overridePeerSettings.PersistentKeepalive"
|
||||
id="override_PersistentKeepalive">
|
||||
<div class="invalid-feedback">{{ errorMsg }}</div>
|
||||
</div>
|
||||
<div class="d-flex mt-1 gap-2">
|
||||
<button
|
||||
:class="{disabled: !edited}"
|
||||
@click="resetForm()"
|
||||
class="btn btn-sm bg-secondary-subtle border-secondary-subtle text-secondary-emphasis rounded-3 shadow ms-auto">
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||
<LocaleText t="Reset"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
:class="{disabled: !edited}"
|
||||
@click="submitForm()"
|
||||
class="btn btn-sm bg-primary-subtle border-primary-subtle text-primary-emphasis rounded-3 shadow">
|
||||
<i class="bi bi-save-fill me-2"></i>
|
||||
<LocaleText t="Save"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import CodeEditor from "@/utilities/simple-code-editor/CodeEditor.vue";
|
||||
import {fetchGet, fetchPost} from "@/utilities/fetch.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {ref} from "vue";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
const emits = defineEmits(['close'])
|
||||
const route = useRoute()
|
||||
|
||||
const content = ref("")
|
||||
const path = ref("")
|
||||
const error = ref(false)
|
||||
const errorMessage = ref("")
|
||||
|
||||
const getRaw = async () => {
|
||||
await fetchGet('/api/getWireguardConfigurationRawFile', {
|
||||
configurationName: route.params.id
|
||||
}, (res) => {
|
||||
content.value = res.data.content
|
||||
path.value = res.data.path
|
||||
})
|
||||
}
|
||||
|
||||
await getRaw()
|
||||
const dashboardStore = DashboardConfigurationStore();
|
||||
const saving = ref(false)
|
||||
|
||||
const saveRaw = async () => {
|
||||
saving.value = true
|
||||
await fetchPost('/api/updateWireguardConfigurationRawFile', {
|
||||
configurationName: route.params.id,
|
||||
rawConfiguration: content.value
|
||||
}, (res) => {
|
||||
if (res.status){
|
||||
error.value = false
|
||||
dashboardStore.newMessage("Server", "Configuration saved", "success")
|
||||
}else{
|
||||
error.value = true
|
||||
errorMessage.value = res.message
|
||||
}
|
||||
saving.value = false
|
||||
})
|
||||
}
|
||||
</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: 1000px">
|
||||
<div class="card rounded-3 shadow flex-grow-1" 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="Edit Raw Configuration File"></LocaleText>
|
||||
</h5>
|
||||
<button type="button" class="btn-close ms-auto" @click="emits('close')"></button>
|
||||
</div>
|
||||
<div class="card-body px-4 d-flex flex-column gap-3">
|
||||
<div class="alert alert-danger rounded-3 mb-0" v-if="error">
|
||||
<div class="mb-2">
|
||||
<strong>
|
||||
<LocaleText t="Failed to save configuration. Please see the following error message:"></LocaleText>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="bg-body w-100 p-2 rounded-3">
|
||||
<pre>{{errorMessage}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<CodeEditor
|
||||
:disabled="true"
|
||||
:read-only="saving"
|
||||
v-model="content"
|
||||
:theme="dashboardStore.Configuration.Server.dashboard_theme === 'dark' ? 'github-dark':'github'"
|
||||
:languages="[['ini', path]]"
|
||||
width="100%" height="600px">
|
||||
</CodeEditor>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn bg-secondary-subtle border-secondary-subtle text-secondary-emphasis rounded-3 shadow ms-auto px-3 py-2"
|
||||
:disabled="saving"
|
||||
@click="getRaw()">
|
||||
<i class="bi bi-arrow-clockwise me-2"></i>
|
||||
<LocaleText t="Reset"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
@click="saveRaw()"
|
||||
:disabled="saving"
|
||||
class="btn bg-danger-subtle border-danger-subtle text-danger-emphasis rounded-3 px-3 py-2 shadow"
|
||||
>
|
||||
<i class="bi bi-save-fill me-2"></i>
|
||||
<LocaleText t="Save" v-if="!saving"></LocaleText>
|
||||
<LocaleText t="Saving..." v-else></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import {onMounted, reactive, ref, watch} from "vue";
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {fetchPost} from "@/utilities/fetch.js";
|
||||
import {useRouter} from "vue-router";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
const props = defineProps({
|
||||
configurationName: String
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
const newConfigurationName = reactive({
|
||||
data: "",
|
||||
valid: false
|
||||
});
|
||||
const store = WireguardConfigurationsStore()
|
||||
|
||||
onMounted(() => {
|
||||
watch(() => newConfigurationName.data, (newVal) => {
|
||||
newConfigurationName.valid = /^[a-zA-Z0-9_=+.-]{1,15}$/.test(newVal) && newVal.length > 0 && !store.Configurations.find(x => x.Name === newVal);
|
||||
})
|
||||
})
|
||||
const dashboardConfigurationStore = DashboardConfigurationStore()
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const rename = async () => {
|
||||
if (newConfigurationName.data){
|
||||
loading.value = true
|
||||
clearInterval(dashboardConfigurationStore.Peers.RefreshInterval)
|
||||
await fetchPost("/api/renameWireguardConfiguration", {
|
||||
ConfigurationName: props.configurationName,
|
||||
NewConfigurationName: newConfigurationName.data
|
||||
}, async (res) => {
|
||||
if (res.status){
|
||||
await store.getConfigurations()
|
||||
dashboardConfigurationStore.newMessage("Server", "Configuration renamed", "success")
|
||||
router.push(`/configuration/${newConfigurationName.data}/peers`)
|
||||
}else{
|
||||
dashboardConfigurationStore.newMessage("Server", res.message, "danger")
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card rounded-3 flex-grow-1 bg-danger-subtle border-danger-subtle border shadow">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
<LocaleText t="To update this configuration's name, WGDashboard will execute the following operations:"></LocaleText>
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<LocaleText t="Duplicate current configuration's database table and .conf file with the new name"></LocaleText>
|
||||
</li>
|
||||
<li>
|
||||
<LocaleText t="Delete current configuration's database table and .conf file"></LocaleText>
|
||||
</li>
|
||||
</ol>
|
||||
<div class="d-flex align-items-center gap-3 inputGroup">
|
||||
<input class="form-control form-control-sm rounded-3" :value="configurationName" disabled>
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-arrow-right"></i>
|
||||
</h3>
|
||||
<input class="form-control form-control-sm rounded-3"
|
||||
id="newConfigurationName"
|
||||
:class="[newConfigurationName.data ? (newConfigurationName.valid ? 'is-valid' : 'is-invalid') : '']"
|
||||
v-model="newConfigurationName.data">
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="{'d-block': !newConfigurationName.valid && newConfigurationName.data}">
|
||||
<LocaleText t="Configuration name is invalid. Possible reasons:"></LocaleText>
|
||||
<ul class="mb-0">
|
||||
<li>
|
||||
<LocaleText t="Configuration name already exist"></LocaleText>
|
||||
</li>
|
||||
<li>
|
||||
<LocaleText t="Configuration name can only contain 15 lower/uppercase alphabet, numbers, underscore, equal sign, plus sign, period and hyphen."></LocaleText>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex mt-3">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="btn btn-sm bg-secondary-subtle border-secondary-subtle text-secondary-emphasis rounded-3">
|
||||
<LocaleText t="Cancel"></LocaleText>
|
||||
</button>
|
||||
<button
|
||||
@click="rename()"
|
||||
:disabled="!newConfigurationName.data || loading"
|
||||
class="btn btn-sm btn-danger rounded-3 ms-auto">
|
||||
<LocaleText t="Save"></LocaleText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media screen and (max-width: 567px) {
|
||||
.inputGroup{
|
||||
flex-direction: column;
|
||||
|
||||
h3{
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script>
|
||||
import {fetchGet} from "@/utilities/fetch.js";
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {GetLocale} from "@/utilities/locale.js";
|
||||
import {ref} from "vue";
|
||||
|
||||
export default {
|
||||
name: "allowedIPsInput",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
data: Object,
|
||||
saving: Boolean,
|
||||
bulk: Boolean,
|
||||
availableIp: undefined,
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
allowedIp: [],
|
||||
availableIpSearchString: "",
|
||||
customAvailableIp: "",
|
||||
allowedIpFormatError: false
|
||||
}
|
||||
},
|
||||
setup(props){
|
||||
const store = WireguardConfigurationsStore();
|
||||
const dashboardStore = DashboardConfigurationStore();
|
||||
const selectedSubnet = ref("")
|
||||
if(Object.keys(props.availableIp).length > 0){
|
||||
selectedSubnet.value = Object.keys(props.availableIp)[0]
|
||||
}
|
||||
|
||||
|
||||
return {store, dashboardStore, selectedSubnet}
|
||||
},
|
||||
computed: {
|
||||
searchAvailableIps(){
|
||||
return (this.availableIpSearchString ?
|
||||
this.availableIp[this.selectedSubnet].filter(x =>
|
||||
x.includes(this.availableIpSearchString) && !this.data.allowed_ips.includes(x)) :
|
||||
this.availableIp[this.selectedSubnet].filter(x => !this.data.allowed_ips.includes(x)))
|
||||
},
|
||||
inputGetLocale(){
|
||||
return GetLocale("Enter IP Address/CIDR")
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addAllowedIp(ip){
|
||||
let list = ip.split(',')
|
||||
for (let i = 0; i < list.length; i++){
|
||||
let ipaddress = list[i].trim();
|
||||
if(this.store.checkCIDR(ipaddress)){
|
||||
this.data.allowed_ips.push(ipaddress);
|
||||
}else{
|
||||
this.allowedIpFormatError = true;
|
||||
this.dashboardStore.newMessage('WGDashboard',
|
||||
`This Allowed IP address is invalid: ${ipaddress}`, 'danger')
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this.customAvailableIp = ''
|
||||
return true;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
customAvailableIp(){
|
||||
this.allowedIpFormatError = false;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.availableIp !== undefined &&
|
||||
Object.keys(this.availableIp).length > 0 && this.data.allowed_ips.length === 0){
|
||||
for (let subnet in this.availableIp){
|
||||
if (this.availableIp[subnet].length > 0){
|
||||
this.addAllowedIp(this.availableIp[subnet][0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{inactiveField: this.bulk}">
|
||||
<div class="d-flex flex-column flex-md-row mb-2">
|
||||
<label for="peer_allowed_ip_textbox" class="form-label mb-0">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Allowed IPs"></LocaleText> <code><LocaleText t="(Required)"></LocaleText></code>
|
||||
</small>
|
||||
</label>
|
||||
<div class="form-check form-switch ms-md-auto">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
v-model="this.data.allowed_ips_validation"
|
||||
role="switch" id="disableIPValidation">
|
||||
<label class="form-check-label" for="disableIPValidation">
|
||||
<small>
|
||||
<LocaleText t="Allowed IPs Validation"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="d-flex gap-2 flex-wrap" :class="{'mb-2': this.data.allowed_ips.length > 0}">
|
||||
<TransitionGroup name="list">
|
||||
<span class="badge rounded-pill text-bg-success" v-for="(ip, index) in this.data.allowed_ips" :key="ip">
|
||||
{{ip}}
|
||||
<a role="button" @click="this.data.allowed_ips.splice(index, 1)">
|
||||
<i class="bi bi-x-circle-fill ms-1"></i></a>
|
||||
</span>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control form-control-sm rounded-start-3"
|
||||
:placeholder="this.inputGetLocale"
|
||||
:class="{'is-invalid': this.allowedIpFormatError}"
|
||||
@keyup.enter="this.customAvailableIp ? this.addAllowedIp(this.customAvailableIp) : undefined"
|
||||
v-model="customAvailableIp"
|
||||
id="peer_allowed_ip_textbox"
|
||||
:disabled="bulk">
|
||||
<button class="btn btn-sm rounded-end-3"
|
||||
:class="[this.customAvailableIp ? 'btn-success':'btn-outline-success']"
|
||||
:disabled="bulk || !this.customAvailableIp"
|
||||
@click="this.addAllowedIp(this.customAvailableIp)"
|
||||
type="button" id="button-addon2">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<LocaleText t="or"></LocaleText>
|
||||
</small>
|
||||
<div class="dropdown flex-grow-1">
|
||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle rounded-3 w-100"
|
||||
:disabled="!availableIp || bulk"
|
||||
data-bs-auto-close="outside"
|
||||
type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-filter-circle me-2"></i>
|
||||
<LocaleText t="Pick Available IP"></LocaleText>
|
||||
</button>
|
||||
<ul class="dropdown-menu mt-2 shadow w-100 dropdown-menu-end rounded-3 pb-0"
|
||||
v-if="this.availableIp"
|
||||
style="width: 300px !important;">
|
||||
<li>
|
||||
<div class="px-3 d-flex gap-3 align-items-center">
|
||||
<label for="availableIpSearchString" class="text-muted">
|
||||
<i class="bi bi-search"></i>
|
||||
</label>
|
||||
<input
|
||||
id="availableIpSearchString"
|
||||
class="form-control form-control-sm rounded-3"
|
||||
v-model="this.availableIpSearchString">
|
||||
</div>
|
||||
<hr class="my-2">
|
||||
<div class="px-3 overflow-x-scroll d-flex overflow-x-scroll overflow-y-hidden align-items-center gap-2">
|
||||
<small class="text-muted">Subnet</small>
|
||||
<button
|
||||
v-for="key in Object.keys(this.availableIp)"
|
||||
:key="key"
|
||||
@click="this.selectedSubnet = key"
|
||||
:class="{'bg-primary-subtle': this.selectedSubnet === key}"
|
||||
class="btn btn-sm text-primary-emphasis rounded-3">
|
||||
{{key}}
|
||||
</button>
|
||||
</div>
|
||||
<hr class="mt-2 mb-0">
|
||||
</li>
|
||||
<li>
|
||||
<div class="overflow-y-scroll" style="height: 270px;">
|
||||
<div v-for="ip in this.searchAvailableIps" style="" :key="ip">
|
||||
<a class="dropdown-item d-flex" role="button" @click="this.addAllowedIp(ip)">
|
||||
<span class="me-auto"><small>{{ip}}</small></span>
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="this.searchAvailableIps.length === 0" class="px-3 py-2">
|
||||
<small class="text-muted" v-if="this.availableIpSearchString"><LocaleText t="No available IP containing"></LocaleText>"{{this.availableIpSearchString}}"</small>
|
||||
<small class="text-muted" v-else><LocaleText t="No more IP address available in this subnet"></LocaleText></small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-move, /* apply transition to moving elements */
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
/* ensure leaving items are taken out of layout flow so that moving
|
||||
animations can be calculated correctly. */
|
||||
.list-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
import {GetLocale} from "@/utilities/locale.js";
|
||||
import {useRoute} from "vue-router";
|
||||
import {fetchGet} from "@/utilities/fetch.js";
|
||||
|
||||
export default {
|
||||
name: "bulkAdd",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
saving: Boolean,
|
||||
data: Object,
|
||||
availableIp: undefined
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
numberOfAvailableIPs: null
|
||||
}
|
||||
},
|
||||
computed:{
|
||||
bulkAddGetLocale(){
|
||||
return GetLocale("How many peers you want to add?")
|
||||
},
|
||||
getNumberOfAvailableIPs(){
|
||||
if (!this.numberOfAvailableIPs){
|
||||
return '...'
|
||||
}else{
|
||||
return Object.values(this.numberOfAvailableIPs).reduce((x, y) => {
|
||||
return x + y
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'data.bulkAdd': {
|
||||
immediate: true,
|
||||
handler(newVal){
|
||||
if (newVal){
|
||||
fetchGet("/api/getNumberOfAvailableIPs/" + this.$route.params.id, {}, (res) => {
|
||||
if (res.status){
|
||||
this.numberOfAvailableIPs = res.data
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="form-check form-switch ">
|
||||
<input class="form-check-input"
|
||||
type="checkbox" role="switch"
|
||||
:disabled="!this.availableIp"
|
||||
id="bulk_add" v-model="this.data.bulkAdd">
|
||||
<label class="form-check-label me-2" for="bulk_add">
|
||||
<small><strong>
|
||||
<LocaleText t="Bulk Add"></LocaleText>
|
||||
</strong></small>
|
||||
</label>
|
||||
</div>
|
||||
<p :class="{'mb-0': !this.data.bulkAdd}"><small class="text-muted d-block">
|
||||
<LocaleText t="By adding peers by bulk, each peer's name will be auto generated, and Allowed IP will be assign to the next available IP."></LocaleText>
|
||||
</small></p>
|
||||
|
||||
<div class="form-group" v-if="this.data.bulkAdd">
|
||||
<input class="form-control form-control-sm rounded-3 mb-1" type="number" min="1"
|
||||
id="bulk_add_count"
|
||||
:max="this.availableIp.length"
|
||||
v-model="this.data.bulkAddAmount"
|
||||
:placeholder="this.bulkAddGetLocale">
|
||||
<small class="text-muted">
|
||||
<LocaleText :t="`You can add up to ` +
|
||||
getNumberOfAvailableIPs
|
||||
+ ' peers'"></LocaleText>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script>
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
|
||||
export default {
|
||||
name: "dnsInput",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
|
||||
data: Object,
|
||||
saving: Boolean,
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
error: false,
|
||||
dns: JSON.parse(JSON.stringify(this.data.DNS)),
|
||||
}
|
||||
},
|
||||
setup(){
|
||||
const store = WireguardConfigurationsStore();
|
||||
const dashboardStore = DashboardConfigurationStore();
|
||||
return {store, dashboardStore}
|
||||
},
|
||||
methods:{
|
||||
checkDNS(){
|
||||
if(this.dns){
|
||||
let i = this.dns.split(',').map(x => x.replaceAll(' ', ''));
|
||||
for(let ip in i){
|
||||
if (!this.store.regexCheckIP(i[ip])){
|
||||
if (!this.error){
|
||||
this.dashboardStore.newMessage("WGDashboard", "DNS format is incorrect", "danger");
|
||||
}
|
||||
this.error = true;
|
||||
this.data.DNS = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.error = false;
|
||||
this.data.DNS = this.dns;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'dns'(){
|
||||
this.checkDNS();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label for="peer_DNS_textbox" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="DNS"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:class="{'is-invalid': this.error}"
|
||||
:disabled="this.saving"
|
||||
v-model="this.dns"
|
||||
|
||||
id="peer_DNS_textbox">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
|
||||
import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStore.js";
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
|
||||
export default {
|
||||
name: "endpointAllowedIps",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
data: Object,
|
||||
saving: Boolean
|
||||
},
|
||||
setup(){
|
||||
const store = WireguardConfigurationsStore();
|
||||
const dashboardStore = DashboardConfigurationStore()
|
||||
return {store, dashboardStore}
|
||||
},
|
||||
data(){
|
||||
return {
|
||||
endpointAllowedIps: JSON.parse(JSON.stringify(this.data.endpoint_allowed_ip)),
|
||||
error: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkAllowedIP(){
|
||||
let i = this.endpointAllowedIps.split(",").map(x => x.replaceAll(' ', ''));
|
||||
for (let ip in i){
|
||||
if (!this.store.checkCIDR(i[ip])){
|
||||
if (!this.error){
|
||||
this.dashboardStore.newMessage("WGDashboard", "Endpoint Allowed IPs format is incorrect", "danger")
|
||||
}
|
||||
this.data.endpoint_allowed_ip = "";
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.error = false;
|
||||
this.data.endpoint_allowed_ip = this.endpointAllowedIps;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'endpointAllowedIps'(){
|
||||
this.checkAllowedIP();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label for="peer_endpoint_allowed_ips" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Endpoint Allowed IPs"></LocaleText> <code><LocaleText t="(Required)"></LocaleText></code></small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:class="{'is-invalid': error}"
|
||||
:disabled="this.saving"
|
||||
v-model="this.endpointAllowedIps"
|
||||
@blur="this.checkAllowedIP()"
|
||||
id="peer_endpoint_allowed_ips">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
|
||||
export default {
|
||||
name: "mtuInput",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
data: Object,
|
||||
saving: Boolean
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<label for="peer_mtu" class="form-label"><small class="text-muted">
|
||||
<LocaleText t="MTU"></LocaleText>
|
||||
</small></label>
|
||||
<input type="number" class="form-control form-control-sm rounded-3"
|
||||
:disabled="this.saving"
|
||||
v-model="this.data.mtu"
|
||||
min="0"
|
||||
id="peer_mtu">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import LocaleText from "@/components/text/localeText.vue";
|
||||
|
||||
export default {
|
||||
name: "nameInput",
|
||||
components: {LocaleText},
|
||||
props: {
|
||||
bulk: Boolean,
|
||||
data: Object,
|
||||
saving: Boolean
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{inactiveField: this.bulk}">
|
||||
<label for="peer_name_textbox" class="form-label">
|
||||
<small class="text-muted">
|
||||
<LocaleText t="Name"></LocaleText>
|
||||
</small>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm rounded-3"
|
||||
:disabled="this.saving || this.bulk"
|
||||
v-model="this.data.name"
|
||||
id="peer_name_textbox" placeholder="">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||