mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-07 03:33:01 +02:00
Compare commits
1010 Commits
cd9e56fdb7
..
1.21.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c68148a9 | |||
| 5d38059b95 | |||
| df0198df9c | |||
| 26713b53f6 | |||
| 2bc6b28978 | |||
| f1ed4da8a4 | |||
| adeefb9dbd | |||
| bf1cc705a5 | |||
| 0b82dae01e | |||
| 75afe6ece2 | |||
| 70e2fe1e4e | |||
| 17e03457e1 | |||
| 4d8cb7e231 | |||
| 9561d23f1e | |||
| a2e1c7b751 | |||
| 9e2ec72ced | |||
| 02fe1f3abd | |||
| c30fe5b574 | |||
| a0b9ea76a3 | |||
| eb3a3eac98 | |||
| 02d2e09709 | |||
| 6e8283957c | |||
| bb9b94a983 | |||
| 36f807ddbc | |||
| cb31546c6b | |||
| 2d48adc9f9 | |||
| 113b7a0b84 | |||
| ca279ee3e0 | |||
| a5808200f0 | |||
| aa9de4d2ac | |||
| 50821e972d | |||
| e5b9dfee66 | |||
| 8e09070bc1 | |||
| 3d9c413bbb | |||
| 86670c1d60 | |||
| 5f2ee77a73 | |||
| 714e3a8fb1 | |||
| 672555f051 | |||
| 7853f2b096 | |||
| 61a7dda29a | |||
| f9bcb25eaa | |||
| 6df804ab32 | |||
| 9e50735800 | |||
| a30119f5e8 | |||
| 3c9f0946d2 | |||
| 21861a6dcd | |||
| 6135f2b727 | |||
| 3a616cc804 | |||
| d5eb67a8fc | |||
| 3ac7ef23ae | |||
| 08c5ec2be7 | |||
| a88b79e066 | |||
| a48ef77ee5 | |||
| 55f3807491 | |||
| dbfb8e83e8 | |||
| 515afc14a5 | |||
| 903e8c0fa1 | |||
| 7f9c760380 | |||
| 530a1a5350 | |||
| 3fb36c1434 | |||
| 5d20956a0e | |||
| 29f91e8276 | |||
| 4d9e38d022 | |||
| d7d31b78f0 | |||
| e8ed8fc7e9 | |||
| 5ee406c92e | |||
| 89f4f4ba6f | |||
| 4e5fd2f2b0 | |||
| 22a5743817 | |||
| 40e05f903a | |||
| 6c1e1bc0ca | |||
| cebbf9ca65 | |||
| e6646e5833 | |||
| 00f7510195 | |||
| 8d1f5e1096 | |||
| e497fcc3b9 | |||
| a7010b2788 | |||
| abf58a70ee | |||
| d4c602cf91 | |||
| d1b0bbb6d9 | |||
| ae0be3a4bc | |||
| a8f3f71021 | |||
| 933ca71c16 | |||
| 591cb9cdc1 | |||
| 34b18bdb53 | |||
| 7d475f5e91 | |||
| 39c35fa539 | |||
| bada1fdb97 | |||
| d9303f87c8 | |||
| e1bc0b7efd | |||
| 5ef068c8dc | |||
| 14680df160 | |||
| 94c01a23a9 | |||
| 609fb357bb | |||
| cf9a17cc2e | |||
| 538b57941c | |||
| f4bee6406a | |||
| 0b2693a317 | |||
| 3be2d928f6 | |||
| e5398d441e | |||
| d38f9ac2bb | |||
| 8d018fe47d | |||
| 34d5c9535d | |||
| dc60ef712f | |||
| dc7eb630c3 | |||
| d4138e2141 | |||
| bbcd352817 | |||
| 21d9199978 | |||
| 3e7b9d7bff | |||
| 54acdbe282 | |||
| 11bbd028fc | |||
| 7ae99731f4 | |||
| 17b4fd9a44 | |||
| caa398ae54 | |||
| 035e324e5a | |||
| 272a5737b0 | |||
| be50fd4586 | |||
| 8301511efe | |||
| c527ce6562 | |||
| 80bee900aa | |||
| 54209c2390 | |||
| 3b0ee8d9d4 | |||
| 7a0a877dc0 | |||
| b6ed762724 | |||
| d1d58cf455 | |||
| e22f30c694 | |||
| d71c112959 | |||
| 199e960f6d | |||
| 843b9a0b16 | |||
| b9aedc65fe | |||
| 11f5cfd4f8 | |||
| 2ff2e32e9c | |||
| facb1e4cdf | |||
| 6d129c0738 | |||
| 67a3d226f5 | |||
| 19a3773880 | |||
| 09d5d9082e | |||
| 8eab3b1ee2 | |||
| 30b46ebb6e | |||
| 91d5f89ab9 | |||
| 703050f949 | |||
| 97af17a993 | |||
| 09a8c457ac | |||
| 80317aca83 | |||
| bd10519598 | |||
| 15c7c47b3c | |||
| 0a7e8c3cc2 | |||
| fc2668fca8 | |||
| 8f2c20a2bc | |||
| 462ae7ee8c | |||
| 808abd2038 | |||
| 3de25e51ff | |||
| ef25a9dbe0 | |||
| e21c3afb13 | |||
| cb7962c25d | |||
| f667ee4feb | |||
| 810119e4ee | |||
| d082de3d88 | |||
| 1e7863ce4f | |||
| a942871f3d | |||
| 2fe85ebda2 | |||
| da1205d38e | |||
| 92775a51ca | |||
| ea60aa701e | |||
| b1f7ee02b0 | |||
| affa3bbb6a | |||
| dbb1e7b274 | |||
| b2d1a66279 | |||
| 202466792c | |||
| 4dcf684868 | |||
| 2b8f50af6f | |||
| a8c255ef71 | |||
| d9608bd408 | |||
| be193731c1 | |||
| 0c3edc7560 | |||
| 813a3f07dc | |||
| 417438c209 | |||
| beaa5735ce | |||
| 5cb316f4e9 | |||
| 83de8576bf | |||
| 6b6c9cf4d8 | |||
| e9d424eb80 | |||
| 05617c63c0 | |||
| 8cceed91cf | |||
| 633159fb77 | |||
| 4c8bd4db0a | |||
| d742300e82 | |||
| a521db91a2 | |||
| d4549f1c33 | |||
| 71da22328c | |||
| 0b5b732a48 | |||
| a82bae8f93 | |||
| e59176149b | |||
| 2c3151da9b | |||
| f60b8795ad | |||
| 4054976388 | |||
| 11c30b8b27 | |||
| 3d13e9105c | |||
| 289be30e6b | |||
| d4c52bbf2f | |||
| 390c822bb4 | |||
| a74c0c227c | |||
| 05bf77da29 | |||
| bb4deb1ae9 | |||
| 2bfc1901a6 | |||
| 5da186b528 | |||
| 600a96c13b | |||
| e4e0da3723 | |||
| 4087d7fb6b | |||
| 9edb86fd73 | |||
| 87f50bf0cc | |||
| 440ebfe08e | |||
| b399d2a291 | |||
| 2c66da1b19 | |||
| 811119a9a6 | |||
| b53f80d317 | |||
| 1b1fba60f1 | |||
| ab19955502 | |||
| 1db9dcec81 | |||
| a89509c8bd | |||
| f0546eb622 | |||
| 12f9ac94fd | |||
| 1717a99cee | |||
| b93d26f09f | |||
| fc54ad49b5 | |||
| f87e136f6b | |||
| 1bf3d2cdd6 | |||
| 5fc5a3ebca | |||
| 49c2d3163e | |||
| e40f325703 | |||
| 8f377a4fb2 | |||
| 45b9e13a13 | |||
| 3699f8f9cb | |||
| 5a2388a1e6 | |||
| 86e6ebc8af | |||
| 005f050a81 | |||
| c82678852e | |||
| 2e3ab10f5e | |||
| 4985ed02d3 | |||
| a2fea7f714 | |||
| ddba2aff21 | |||
| da9e668fb8 | |||
| 9c2d14d8c6 | |||
| c383e74df4 | |||
| 40101f8bb0 | |||
| b20ed9efa9 | |||
| 56266e3b62 | |||
| 47eff8c948 | |||
| 15f36096ef | |||
| 064252586d | |||
| 6181d46a1e | |||
| 9747731668 | |||
| 2a478eef6f | |||
| 4ab101f8a9 | |||
| e35878ee55 | |||
| 807613f28c | |||
| 663244fa3a | |||
| bcc128aeb6 | |||
| ba33cb9895 | |||
| 69e7fedcfc | |||
| 023110b341 | |||
| 7fb95e1726 | |||
| db0a7cc1ce | |||
| 61fc2e5ea7 | |||
| 0871a211ec | |||
| 5a1d5cb66e | |||
| 5a7ca5b542 | |||
| 87e1a509ce | |||
| 75f481bc3d | |||
| 97cdb2eb5a | |||
| 297fd2caf3 | |||
| 22dd4220fe | |||
| 108cb6216c | |||
| e3ef592778 | |||
| 3c37e10638 | |||
| 561f75b6b1 | |||
| e98bcb83ac | |||
| 80284863bb | |||
| bc759c5c9e | |||
| f4854a3a74 | |||
| 376dd465b3 | |||
| 296439fd67 | |||
| 31f675f38c | |||
| 9f68be2a9b | |||
| fed4ec42c4 | |||
| f0efa4203b | |||
| cfbbdedaf5 | |||
| 686789ee4c | |||
| 3fda190ff6 | |||
| 0033f40f4d | |||
| af95052706 | |||
| 9bb2d6cdc8 | |||
| 29563a13a4 | |||
| b41c1f5b27 | |||
| e5652cdb8a | |||
| 7c2ea153c5 | |||
| ccabddc225 | |||
| 42d98fa83b | |||
| 2f2b7f43c1 | |||
| 528bbeca26 | |||
| d60c15b0ae | |||
| ff89a64453 | |||
| 4718c489d3 | |||
| d5d99a4804 | |||
| 8d29602929 | |||
| 9c18936be7 | |||
| cf07cceb5d | |||
| faee9e6330 | |||
| 31725eb3cc | |||
| 04d4e298e8 | |||
| c9cc9581b1 | |||
| 6c2a8bf6de | |||
| 697be01411 | |||
| eac7c67dcc | |||
| 633d9031af | |||
| 05dc558c4a | |||
| 1bd6f240cc | |||
| 7506c0420d | |||
| 5572822c4a | |||
| ea3f1c341b | |||
| 35dffe71cb | |||
| 5428bf4ed0 | |||
| 9a89579e08 | |||
| 784588cebc | |||
| 2e628fe0e4 | |||
| 7590e8d8a1 | |||
| 053ff1e799 | |||
| 278375f7be | |||
| c5ffca499e | |||
| 65c383520b | |||
| 9ae54f445d | |||
| 8183d19400 | |||
| 7425daad3f | |||
| 39d61a35eb | |||
| f3fe11c136 | |||
| 66b1b385a3 | |||
| 822a07d48e | |||
| 3c13b1ea15 | |||
| fee635b861 | |||
| 7e4dea918a | |||
| 56187d61d5 | |||
| 36460d4cc0 | |||
| 88a9b92dc3 | |||
| dd26518d6f | |||
| 60339706bb | |||
| 4b1b3d3d5b | |||
| cf21bacd9c | |||
| e54bd25516 | |||
| 80d257b94b | |||
| e0d0c5dcbf | |||
| 0f02d1bc02 | |||
| f8591f27c5 | |||
| 877985deb3 | |||
| be3877a3ce | |||
| 79de64dc07 | |||
| d0defa380a | |||
| 4eba51de72 | |||
| a48032adb3 | |||
| 6fe4eee336 | |||
| 242123b875 | |||
| 2b38658ea6 | |||
| b18a41e4aa | |||
| d303fa05cb | |||
| 75b87ffba7 | |||
| 62fc2edae9 | |||
| 80b66cf9b9 | |||
| 034bcbd271 | |||
| bc63747efe | |||
| bb7729df00 | |||
| 2a8ceeec1b | |||
| 91ef0d0153 | |||
| e104489257 | |||
| b8101402cd | |||
| 7731849a2f | |||
| c11d24e10a | |||
| a9b7cce49b | |||
| d78223b94f | |||
| 963e9da7dd | |||
| 2cbc88fa05 | |||
| 65bad456cb | |||
| f48a4f7bc0 | |||
| ce3c2f7583 | |||
| 51c357e6c7 | |||
| 7ae29612d4 | |||
| da794adb7d | |||
| 8004ae6870 | |||
| 1bff7bbc2f | |||
| 50db5695fc | |||
| babd90ae71 | |||
| f7050ef989 | |||
| b2778a2c49 | |||
| 096940a152 | |||
| 73eb07de71 | |||
| 74ef844e27 | |||
| c58968536d | |||
| 228efacfe0 | |||
| 1262030abb | |||
| b07fe6d18b | |||
| 63c3ee623b | |||
| 18ec6c8d92 | |||
| d8acccbde4 | |||
| 37eaf34e4d | |||
| cfb63f9742 | |||
| c76b4555e1 | |||
| c25bfbad27 | |||
| 44782f8963 | |||
| e6f7cd6da9 | |||
| 19faa3a29c | |||
| c284dc2e83 | |||
| 1b634955d8 | |||
| be888c3fc1 | |||
| 3f2bb42221 | |||
| 5dc3ae4c7f | |||
| ffb6c64de0 | |||
| 2cbc6fb128 | |||
| 75084028d7 | |||
| f44a7c55dd | |||
| 72fa1d6a14 | |||
| c3820a4e70 | |||
| 6b56c00782 | |||
| 8f0e17774f | |||
| 60c1b572ba | |||
| 604dee9aa5 | |||
| ee42846c90 | |||
| 22ac711dc6 | |||
| d09668b20b | |||
| 16abe98fd9 | |||
| d240201361 | |||
| b7081aff11 | |||
| a55fb21e53 | |||
| e5e7b79712 | |||
| de48a0529e | |||
| 3f37408dae | |||
| a2882857ff | |||
| 476d92b3ac | |||
| bf604f25e9 | |||
| 34a0d2a68b | |||
| 62c7e0a13e | |||
| 753358a17d | |||
| c859393418 | |||
| d747b45f0b | |||
| a24091257a | |||
| 1c60041390 | |||
| 2ab5540085 | |||
| 95c3f74a33 | |||
| 4e7328a1cc | |||
| 6380079239 | |||
| b6aba13d3a | |||
| c0a9db92ef | |||
| 16c0f4eef4 | |||
| a08c6d70fe | |||
| a6568692b7 | |||
| a1196d3da6 | |||
| 70bc4c0b30 | |||
| a0fef89031 | |||
| ea1badf4e0 | |||
| f15654ed11 | |||
| 4435a669a6 | |||
| 0b41fe3d49 | |||
| 90eceb457a | |||
| f39cbc9bf4 | |||
| 50da863bb7 | |||
| c6ddd5c402 | |||
| 0fb5ace9c7 | |||
| cedccd8cdb | |||
| 3bc5ab2136 | |||
| ce77268c82 | |||
| abc0a41d9e | |||
| b9db0a4490 | |||
| 39f40e5160 | |||
| a68b57067c | |||
| 3fd5c98def | |||
| 5a8a48f9bf | |||
| 471ae98204 | |||
| d985bfd3a6 | |||
| aab51a999c | |||
| ae4f5aa58d | |||
| 70d5f55437 | |||
| 08df4b93aa | |||
| 61f0bc95c7 | |||
| 5b0f79a8bc | |||
| 86ff272095 | |||
| ef0575cc36 | |||
| 30a6889ba8 | |||
| ce43e717d5 | |||
| 1f0361e687 | |||
| 07ae57ea72 | |||
| fc982232d6 | |||
| afaf5f976e | |||
| a8ca28acb2 | |||
| b136bd2246 | |||
| d9952b0762 | |||
| 935593885a | |||
| 3fcfd3304f | |||
| 6e271028f3 | |||
| 820f66e58f | |||
| b0fdc10e06 | |||
| b82b41ed26 | |||
| 444d293a29 | |||
| 3e977ba00d | |||
| a724b07846 | |||
| 5f0bc71bcd | |||
| aea7827c1a | |||
| 7a275c86c2 | |||
| 4b703b5c11 | |||
| d865c4c55b | |||
| 5baf0c3c09 | |||
| 1b6e9e8cfe | |||
| cfe33eb974 | |||
| 71273e1b1c | |||
| 02f6e2a8c3 | |||
| 3cc244a1d3 | |||
| 1d9c4dd9e2 | |||
| b9dd0c8e43 | |||
| cd052976eb | |||
| cc498f0e33 | |||
| 1a942937e6 | |||
| d81d1a6b7f | |||
| f64d04e827 | |||
| 540aee3fe2 | |||
| 10542d7282 | |||
| b1d52ad1a3 | |||
| ce2fbef805 | |||
| e312b31e02 | |||
| bc156c715d | |||
| 9a4c1f23c6 | |||
| fe55956079 | |||
| 4cd0b9a0bb | |||
| ab4d567af9 | |||
| 6921447fab | |||
| d47449b082 | |||
| 665806dfe8 | |||
| e248571268 | |||
| fcf03854ff | |||
| dd1fba4e45 | |||
| a1ab8d8f35 | |||
| c789e967db | |||
| d870b9ff49 | |||
| 9c09019ddb | |||
| 9d88683fc5 | |||
| dd2c9f2a02 | |||
| bdb38db5bc | |||
| 96a54fc9cc | |||
| 3a485f74f1 | |||
| 92b0340324 | |||
| 9257ac01c7 | |||
| 4d1d0d9fcb | |||
| f186e7e99e | |||
| 1aa6e3511f | |||
| fb6f5b3953 | |||
| c85a7f6ac5 | |||
| dd54be523f | |||
| d57f064d4c | |||
| 34799b7de2 | |||
| 20a66bba6f | |||
| cdb43d9658 | |||
| 6581ccafa3 | |||
| a3a45b4239 | |||
| d6634b6e8a | |||
| 1089cfbacc | |||
| 1907a3c93b | |||
| 38203e522b | |||
| 407ba567a0 | |||
| f28571629f | |||
| 5a575c916b | |||
| 9a7e534b10 | |||
| 42974d1739 | |||
| 780e8babe4 | |||
| 2c7b8006cf | |||
| 35066c1388 | |||
| 135a5d38af | |||
| 1b7c1ffa70 | |||
| 641f643d2d | |||
| b4ecfceb5e | |||
| 08a84d4bb1 | |||
| 4dbad7ab24 | |||
| 859c0c9477 | |||
| d294bf8534 | |||
| 3c8fea382f | |||
| b81bfcfcee | |||
| 56c415ca05 | |||
| 74fdcceace | |||
| 7dec8ba998 | |||
| c9dc6affe7 | |||
| 8fe45ba78c | |||
| 934886caea | |||
| fae258b145 | |||
| 9f224f655f | |||
| aea7df7dc2 | |||
| 3b675f7de1 | |||
| 8daf7c2872 | |||
| c394490473 | |||
| 92d611df9a | |||
| 3b6b78b3e1 | |||
| aa47f522ef | |||
| 8658198a93 | |||
| 4b770d1385 | |||
| cd4d7372a0 | |||
| dc8243cb51 | |||
| 7b1f8d98f3 | |||
| dd8bcbb3e3 | |||
| d1af7a153f | |||
| 13efa47db7 | |||
| 69bd61c308 | |||
| 7b7ff51289 | |||
| 772ac8af73 | |||
| 8ee520dbb5 | |||
| 8e5d9e94a9 | |||
| c9cb28af45 | |||
| a994f8ff07 | |||
| ea8eaf9736 | |||
| b78db3daef | |||
| 7cf3f8df92 | |||
| f2b5cff3f9 | |||
| 6de9ab8f05 | |||
| ad0e800d8d | |||
| 13b691fd7d | |||
| 65470fb64b | |||
| f23142336b | |||
| 2da4987cd3 | |||
| 253ba554a2 | |||
| 95ce91d94b | |||
| a4548fd874 | |||
| eb03fb7060 | |||
| add9b8dfb0 | |||
| 2adb7b64cb | |||
| 84fef5f1d6 | |||
| def1e9c851 | |||
| 67b08ca61e | |||
| 614df75880 | |||
| 676cf37ee2 | |||
| 6b96e3dce6 | |||
| b67037e2ea | |||
| 5a5b77cf62 | |||
| d2793dfad7 | |||
| ff507f1275 | |||
| 6b04bcb383 | |||
| b2f1115ef8 | |||
| 567ef23ac4 | |||
| 6affebc666 | |||
| 889f78ddb8 | |||
| 9d3f96cf83 | |||
| e5d0673bbf | |||
| 0907c0346f | |||
| 6420a90d08 | |||
| 7fa1180d10 | |||
| 769d36e289 | |||
| a7a41b820e | |||
| 33fdc9a94f | |||
| 8b50f1fb65 | |||
| 2d78a4b628 | |||
| c86026c941 | |||
| db014e3446 | |||
| feb8045643 | |||
| d485a09318 | |||
| 9cff5f66b1 | |||
| 527d4cc777 | |||
| 01361884eb | |||
| 6c4cbcab5d | |||
| aac25f0a53 | |||
| 89f3f3c8cd | |||
| 4a3c201741 | |||
| f5ab837cce | |||
| 00ec7a5c66 | |||
| 03df4d03ed | |||
| 8488edd707 | |||
| 920dbba2a3 | |||
| 71e065a8bc | |||
| e125c762b2 | |||
| 4f01f7c072 | |||
| 9a5ee9d489 | |||
| 665da931f1 | |||
| 7595cf7ac7 | |||
| aa6232d0fc | |||
| beaf5dc843 | |||
| 7158855052 | |||
| 765b2d795f | |||
| 66f00fcf94 | |||
| e408e735be | |||
| e826d0dea6 | |||
| bc6fd0b399 | |||
| d00b737412 | |||
| 1f43713986 | |||
| cc5bec1d83 | |||
| 40125c717c | |||
| 2b402f8fec | |||
| 8e9071a336 | |||
| 18bcf40174 | |||
| 42e9b913f1 | |||
| fcb73f78ea | |||
| a21569bd00 | |||
| 565727ad36 | |||
| 00dce19997 | |||
| 29717e19db | |||
| 97aeee541a | |||
| 44c16d69af | |||
| b70a2bee58 | |||
| f2f56dc6c2 | |||
| 128db20755 | |||
| 12cbd40596 | |||
| ffd0d17b58 | |||
| 33fad57bf7 | |||
| 8bcc130947 | |||
| 19feaf4bf2 | |||
| 88ea4391e0 | |||
| fba37b7ad0 | |||
| 6c1798a8c5 | |||
| b6d688f15e | |||
| 8a57d8dd9c | |||
| 8e0e32c2be | |||
| 6b3a0a2113 | |||
| 4d6ed7eec5 | |||
| 1625dd1add | |||
| 605dd2f3c9 | |||
| 51bb149fd5 | |||
| 2ae4c29418 | |||
| ba71016f87 | |||
| 85c2bd807e | |||
| 517e1d15c8 | |||
| 3d6d5f176a | |||
| 5dd19edb56 | |||
| c6a52ffc75 | |||
| 09b2671759 | |||
| d11a244caa | |||
| 35d16ac683 | |||
| bf79768e05 | |||
| 08a2923cfc | |||
| b99e9a6468 | |||
| cb2ee9c489 | |||
| c1d933259a | |||
| 3cf6abdf27 | |||
| 0f2132e565 | |||
| 5cc88dc73f | |||
| ebe1c7a297 | |||
| 0943cf5d4c | |||
| 3b82ac568f | |||
| b695f34dc8 | |||
| 6df4bba3b6 | |||
| 9f83c0a0e8 | |||
| f617f93a94 | |||
| 51629247a5 | |||
| 0ab1854125 | |||
| b071fa2c9f | |||
| 8e2a79a0f5 | |||
| 71756812b6 | |||
| 76cd716caa | |||
| b0d1291cff | |||
| 9617eb2bd7 | |||
| c1ef5b4fbe | |||
| 8e14bdec95 | |||
| b26dfaf57f | |||
| 1a1c19b24e | |||
| 9d214b18af | |||
| e67b50b356 | |||
| 616caf76cb | |||
| 9a1db4948b | |||
| 1215aa8122 | |||
| d318a756a8 | |||
| b3c1e49c0c | |||
| dc12b00502 | |||
| 5b814e37c4 | |||
| 8483616b04 | |||
| ffe198839a | |||
| db5d1d4a16 | |||
| ad7dcddf24 | |||
| 94408aad21 | |||
| b84a7996a9 | |||
| a9b0bd8b47 | |||
| a32acf7c69 | |||
| 1e27acbf88 | |||
| 4012cc658d | |||
| 84d7a87609 | |||
| 9a92be532a | |||
| 18ac542e30 | |||
| 322475fb5c | |||
| 2f124bffc4 | |||
| 86367383e7 | |||
| d22ba3566d | |||
| c74b423bae | |||
| f8a757c55f | |||
| 6aea3f1643 | |||
| 073dc34522 | |||
| 3f5970a1f9 | |||
| e2f2608358 | |||
| 6d17bb04c4 | |||
| 957e7ba127 | |||
| def710cba8 | |||
| 44da854575 | |||
| d3d2474855 | |||
| d7d37c6f6e | |||
| 3c80b9a229 | |||
| a998a35482 | |||
| 20e0e5ebd0 | |||
| 4d831effe1 | |||
| 80f4dd0e60 | |||
| eafa3076d8 | |||
| fef3cd8354 | |||
| 36ada0705e | |||
| 8ae3c06df7 | |||
| ba127a8536 | |||
| 5c024f3a3a | |||
| 4fdb8583f6 | |||
| 2946df3b8e | |||
| c3b0c4e5e9 | |||
| a79d0f1677 | |||
| bfd7a7f561 | |||
| a5332bb0cc | |||
| b3963cc34b | |||
| ddb132f9fa | |||
| 64c901d91f | |||
| cdc50ed47a | |||
| e2441ce284 | |||
| 0b6a3234a5 | |||
| ae8599c723 | |||
| 938e9b0d49 | |||
| 05e4ad3200 | |||
| 9eb55ba68c | |||
| cf12ab1ac3 | |||
| 6d14a4df49 | |||
| 94949aa3fd | |||
| df098f55ba | |||
| facbb8f0a4 | |||
| 36fbd8818c | |||
| 91883397e6 | |||
| fd1813f3a7 | |||
| ddabfb5ca1 | |||
| ec0666a612 | |||
| bbf42c5802 | |||
| 6aa1d3b094 | |||
| 0d820df797 | |||
| f1ec1a2fb1 | |||
| 32fcf90467 | |||
| 5a53f88fd6 | |||
| 51971c7ef2 | |||
| 491096109a | |||
| 802a41b1bd | |||
| f59fbabede | |||
| 5a7d54058e | |||
| 5ef4490692 | |||
| 817e848d08 | |||
| 166c8326c5 | |||
| 673f1e93f4 | |||
| 76aea311a4 | |||
| 3539b9ddb4 | |||
| 1a3cf2094b | |||
| 4530aac4f3 | |||
| 09cb20a084 | |||
| 6d4afd0953 | |||
| d1fb2e19d3 | |||
| dee0ca6864 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| ed73d089d0 | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| 35ad235f49 | |||
| 834672c846 | |||
| af13790c93 | |||
| b8180d848a | |||
| fef7563e14 | |||
| 6337cf4359 | |||
| 87bcd8ec1b | |||
| b3cfe82dff | |||
| d65128671c | |||
| 41fdd5de74 | |||
| 2704202ba9 | |||
| 72ef0ae020 | |||
| 1442faa740 | |||
| 6aa589e612 | |||
| 4b1a8e14c4 | |||
| 1a0db10b1a | |||
| b7634086db | |||
| 73e9e830c3 | |||
| a6469e67a8 | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| 1ba75092f9 | |||
| c500979099 | |||
| 81ed391efb | |||
| f3bee70c23 | |||
| 15a9eb28d9 | |||
| a0a093ed0b | |||
| 9cec711427 | |||
| 82745c701a | |||
| e4fd2b656d | |||
| 18d380ce30 | |||
| e9df995e76 | |||
| 0611ceb5c3 | |||
| c4b3656fad | |||
| 54c1dd3bae | |||
| a8f4d2b7d1 | |||
| 51f1693dbd | |||
| b33a6e6fac | |||
| fc2c13a686 | |||
| f4602a120e | |||
| 7ccceeea0d | |||
| f81f78f294 | |||
| 6cab223f12 | |||
| 7b05c02508 | |||
| 5922bfb1a0 | |||
| 43f2e32231 | |||
| 20ebdc6289 | |||
| a80ae49a33 | |||
| 660197eef1 | |||
| 81274960f6 | |||
| 4786fc3a31 | |||
| f286d66cbc | |||
| f3eb823bc3 | |||
| 61c13db090 | |||
| ccbd793f52 | |||
| d13e6896a8 | |||
| 83a36ead10 | |||
| b61b74b0b5 | |||
| 01b068c50f | |||
| fee44ce960 | |||
| 1906504a86 | |||
| 36bcba332c | |||
| 304ab1964c | |||
| b286096c7b | |||
| a22a4b6e74 | |||
| 9a680d2374 | |||
| f80e212b07 | |||
| 8a39b3fd45 | |||
| 61ec938b00 | |||
| 6686de6788 | |||
| 79636cbb30 | |||
| 90d6178a0b | |||
| 2fa1bc6cdc | |||
| c5f6d822ca | |||
| 4de4bf9625 | |||
| 5d956080f2 | |||
| f8e18de2fc | |||
| 884482ec35 | |||
| 9b43948fa4 | |||
| bcd6cd99cc | |||
| 37ceba6b81 | |||
| dfe42e9016 | |||
| 38aa2dace8 | |||
| 136c3eff0c | |||
| 642999c8b1 | |||
| c5fc49b4fa | |||
| cd5a38b1eb | |||
| 595842c2c9 | |||
| 82d5276ade | |||
| 51eb782831 | |||
| de2980e1bc | |||
| 8a3c0d9a08 | |||
| 1a5e9f1005 | |||
| f42c013f33 | |||
| 42c9bda939 | |||
| cbce9fae3a | |||
| e44b15ecd5 | |||
| 7f6ca31757 | |||
| a1eb248474 | |||
| be2b1fd1ce | |||
| 20b65f549e | |||
| 1dc8be373c | |||
| 22b2e6b3d4 | |||
| 89e7107a47 | |||
| 0a69131c38 | |||
| 590f2c29b3 | |||
| 0ddcce6fe1 | |||
| 8a54fb7f23 | |||
| 5c280b024e | |||
| 033cc62ce7 | |||
| 4c69b7a64e | |||
| e7ab9b3f37 | |||
| 3143662f82 | |||
| 18964ba2a3 | |||
| f862404c5c | |||
| c292578f80 | |||
| 7b02d4104d | |||
| 2ef5d90e13 | |||
| d6a8021613 | |||
| c5231d37f6 | |||
| 4d803a40c9 | |||
| 1d709b551a | |||
| 335411de4c | |||
| 0e4abdf4b6 | |||
| 267b40b73c | |||
| ba9a0c5e3c | |||
| 9e0b7ff0d7 | |||
| 003bf7fdf3 | |||
| c3fdda026b | |||
| a53363d064 | |||
| ee21e1faa7 | |||
| e409a34a09 | |||
| 7177ab7f77 | |||
| 801f6fb661 | |||
| 805d82b8d9 | |||
| bd6d790495 | |||
| 2305163474 | |||
| dda53dcb16 | |||
| 2c3e768867 | |||
| 8d682ed9ad | |||
| 47fe497ca1 | |||
| 4d5f364663 | |||
| c3db8b972f | |||
| cfced63ba1 | |||
| 51aa55f963 | |||
| e7df24841e | |||
| e6fd4c32c4 | |||
| f6590aedbd | |||
| 3cb9e02533 | |||
| 4d792350ef |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When writing TypeScript:
|
||||||
|
|
||||||
|
Prefer to use types instead of interfaces.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating forms, use React form for validation and use Zod schemas.
|
||||||
@@ -34,3 +34,5 @@ build.ts
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
|
allowedDevOrigins.json
|
||||||
|
scratch/
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
- OS Type & Version:
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
|
- Edition (Community or Enterprise):
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Olm Version: (if applicable)
|
- Client Version:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
+19
-29
@@ -1,52 +1,42 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
dev-patch-updates:
|
npm-dependencies:
|
||||||
dependency-type: "development"
|
patterns:
|
||||||
update-types:
|
- "*"
|
||||||
- "patch"
|
|
||||||
dev-minor-updates:
|
|
||||||
dependency-type: "development"
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
prod-patch-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
|
||||||
- "patch"
|
|
||||||
prod-minor-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
patch-updates:
|
docker-dependencies:
|
||||||
update-types:
|
patterns:
|
||||||
- "patch"
|
- "*"
|
||||||
minor-updates:
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
|
groups:
|
||||||
|
github-actions-dependencies:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/install"
|
directory: "/install"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
patch-updates:
|
go-install-dependencies:
|
||||||
update-types:
|
patterns:
|
||||||
- "patch"
|
- "*"
|
||||||
minor-updates:
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -201,10 +201,10 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
@@ -407,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
+4
-2
@@ -17,9 +17,9 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite*
|
||||||
!Dockerfile.sqlite
|
!Dockerfile.sqlite
|
||||||
*.sqlite3
|
*.sqlite3*
|
||||||
*.log
|
*.log
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
@@ -54,3 +54,5 @@ hydrateSaas.ts
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
server/setup/migrations.ts
|
server/setup/migrations.ts
|
||||||
|
solo.yml
|
||||||
|
allowedDevOrigins.json
|
||||||
Vendored
+4
-1
@@ -18,5 +18,8 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"cSpell.words": [
|
||||||
|
"nessicary"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||||
|
|
||||||
|
* Lightweight user-space connector runs anywhere
|
||||||
|
* Punches through any firewall
|
||||||
|
* Doesn't require open ports or a public IP
|
||||||
|
* Strict network segmentation
|
||||||
|
* WireGuard-based
|
||||||
|
* Get alerts when a device or network resource goes down
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||||
|
|
||||||
### Browser-based reverse proxy access
|
### Browser-based reverse proxy access
|
||||||
|
|
||||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||||
|
|
||||||
|
* Expose a web panel anywhere
|
||||||
|
* Access via any web browser
|
||||||
|
* Single sign-on across all resources
|
||||||
|
* HTTPS resources
|
||||||
|
* Remote desktop in the browser with VNC and RDP
|
||||||
|
* In-browser SSH terminal with privileged access management (PAM)
|
||||||
|
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
|
||||||
|
|
||||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||||
|
|
||||||
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
|
|||||||
|
|
||||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||||
|
|
||||||
|
* Peer-to-peer with intelligent NAT traversal
|
||||||
|
* Hosts/IPs and port ranges
|
||||||
|
* Network ranges/CIDRs
|
||||||
|
* Friendly DNS aliases for network addresses
|
||||||
|
* Privileged access management (PAM) with SSH resources
|
||||||
|
* Private HTTPS resources only accessible on the private network
|
||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|
||||||
|
* Bring your existing identity provider (IdP) or use Pangolin identities
|
||||||
|
* Sync users and roles from your IdP
|
||||||
|
* User- and role-based access control
|
||||||
|
* Full network audit and access logs
|
||||||
|
|
||||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||||
|
|
||||||
|
### Find and launch resources from a personalized home page
|
||||||
|
|
||||||
|
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
|
||||||
|
|
||||||
|
* Single place for admins and non-admins to see accessible resources
|
||||||
|
* Create reusable views for common access patterns
|
||||||
|
|
||||||
|
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
Download the Pangolin client for your platform:
|
Download the Pangolin client for your platform:
|
||||||
@@ -107,7 +143,7 @@ the docs to illustrate some basic ideas.
|
|||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
type SetServerAdminArgs = {
|
||||||
|
email: string;
|
||||||
|
remove: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||||
|
command: "set-server-admin",
|
||||||
|
describe: "Add or remove server admin by email address",
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs
|
||||||
|
.option("email", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
describe: "User email address"
|
||||||
|
})
|
||||||
|
.option("remove", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "Remove server admin status from the user"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (argv: SetServerAdminArgs) => {
|
||||||
|
try {
|
||||||
|
const email = argv.email.trim().toLowerCase();
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`User with email '${email}' not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argv.remove) {
|
||||||
|
if (!user.serverAdmin) {
|
||||||
|
console.log(`User '${email}' is not a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverAdmins = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.serverAdmin, true));
|
||||||
|
|
||||||
|
if (serverAdmins.length <= 1) {
|
||||||
|
console.error(
|
||||||
|
"Cannot remove server admin: at least one server admin must exist"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ serverAdmin: false })
|
||||||
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
|
console.log(`Server admin status removed from user '${email}'`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.serverAdmin) {
|
||||||
|
console.log(`User '${email}' is already a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ serverAdmin: true })
|
||||||
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
|
console.log(`User '${email}' has been marked as a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { deleteClient } from "./commands/deleteClient";
|
|||||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||||
import { clearCertificates } from "./commands/clearCertificates";
|
import { clearCertificates } from "./commands/clearCertificates";
|
||||||
import { disableUser2fa } from "./commands/disableUser2fa";
|
import { disableUser2fa } from "./commands/disableUser2fa";
|
||||||
|
import { setServerAdmin } from "./commands/setServerAdmin";
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
@@ -23,5 +24,6 @@ yargs(hideBin(process.argv))
|
|||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
.command(clearCertificates)
|
.command(clearCertificates)
|
||||||
.command(disableUser2fa)
|
.command(disableUser2fa)
|
||||||
|
.command(setServerAdmin)
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
git push origin -d 1.11.0-s.0
|
|
||||||
git push origin -d 1.11.0-s.1
|
|
||||||
git push origin -d 1.11.0-s.2
|
|
||||||
git push origin -d 1.11.0-s.3
|
|
||||||
git push origin -d 1.11.0-s.4
|
|
||||||
git push origin -d 1.11.0-s.5
|
|
||||||
git push origin -d 1.11.1-s.0
|
|
||||||
git push origin -d 1.12.0-s.0
|
|
||||||
git push origin -d 1.12.2-s.0
|
|
||||||
git push origin -d 1.12.2-s.1
|
|
||||||
git push origin -d 1.12.2-s.2
|
|
||||||
git push origin -d 1.12.2-s.3
|
|
||||||
git push origin -d 1.12.2-s.4
|
|
||||||
git push origin -d 1.12.2-s.5
|
|
||||||
git push origin -d 1.13.0.s.0
|
|
||||||
git push origin -d 1.13.1-s.0
|
|
||||||
git push origin -d 1.14.0-s.2
|
|
||||||
git push origin -d 1.14.1-s.0
|
|
||||||
git push origin -d 1.14.1-s.1
|
|
||||||
git push origin -d 1.14.1-s.2
|
|
||||||
git push origin -d 1.14.1-s.3
|
|
||||||
git push origin -d 1.15.0-s.0
|
|
||||||
git push origin -d 1.15.0-s.1
|
|
||||||
git push origin -d 1.15.0-s.2
|
|
||||||
git push origin -d 1.15.0-s.3
|
|
||||||
git push origin -d 1.15.0-s.4
|
|
||||||
git push origin -d 1.15.0-s.5
|
|
||||||
git push origin -d 1.15.1-s.0
|
|
||||||
git push origin -d 1.15.1-s.1
|
|
||||||
git push origin -d 1.15.3-s.0
|
|
||||||
git push origin -d 1.15.3-s.1
|
|
||||||
git push origin -d 1.15.4-s.0
|
|
||||||
git push origin -d 1.15.4-s.1
|
|
||||||
git push origin -d 1.15.4-s.10
|
|
||||||
git push origin -d 1.15.4-s.2
|
|
||||||
git push origin -d 1.15.4-s.3
|
|
||||||
git push origin -d 1.15.4-s.4
|
|
||||||
git push origin -d 1.15.4-s.5
|
|
||||||
git push origin -d 1.15.4-s.6
|
|
||||||
git push origin -d 1.15.4-s.7
|
|
||||||
git push origin -d 1.15.4-s.8
|
|
||||||
git push origin -d 1.15.4-s.9
|
|
||||||
git push origin -d 1.16.0-s.0
|
|
||||||
git push origin -d 1.16.0-s.1
|
|
||||||
git push origin -d 1.16.1-s.0
|
|
||||||
git push origin -d 1.16.1-s.1
|
|
||||||
git push origin -d 1.16.2-s.0
|
|
||||||
git push origin -d 1.16.2-s.1
|
|
||||||
git push origin -d 1.16.2-s.10
|
|
||||||
git push origin -d 1.16.2-s.11
|
|
||||||
git push origin -d 1.16.2-s.12
|
|
||||||
git push origin -d 1.16.2-s.13
|
|
||||||
git push origin -d 1.16.2-s.14
|
|
||||||
git push origin -d 1.16.2-s.15
|
|
||||||
git push origin -d 1.16.2-s.16
|
|
||||||
git push origin -d 1.16.2-s.17
|
|
||||||
git push origin -d 1.16.2-s.18
|
|
||||||
git push origin -d 1.16.2-s.19
|
|
||||||
git push origin -d 1.16.2-s.2
|
|
||||||
git push origin -d 1.16.2-s.20
|
|
||||||
git push origin -d 1.16.2-s.21
|
|
||||||
git push origin -d 1.16.2-s.22
|
|
||||||
git push origin -d 1.16.2-s.3
|
|
||||||
git push origin -d 1.16.2-s.4
|
|
||||||
git push origin -d 1.16.2-s.5
|
|
||||||
git push origin -d 1.16.2-s.6
|
|
||||||
git push origin -d 1.16.2-s.7
|
|
||||||
git push origin -d 1.16.2-s.8
|
|
||||||
git push origin -d 1.16.2-s.9
|
|
||||||
git push origin -d 1.17.0-s.0
|
|
||||||
git push origin -d 1.17.0-s.1
|
|
||||||
git push origin -d 1.17.0-s.2
|
|
||||||
git push origin -d 1.17.0-s.3
|
|
||||||
git push origin -d 1.17.0-s.4
|
|
||||||
git push origin -d 1.17.1-s.0
|
|
||||||
git push origin -d 1.17.1-s.1
|
|
||||||
git push origin -d 1.17.1-s.2
|
|
||||||
git push origin -d 1.17.1-s.3
|
|
||||||
git push origin -d 1.17.1-s.4
|
|
||||||
git push origin -d 1.17.1-s.5
|
|
||||||
git push origin -d 1.17.1-s.6
|
|
||||||
git push origin -d 1.17.1-s.7
|
|
||||||
git push origin -d 1.18.0-s.0
|
|
||||||
git push origin -d 1.18.0-s.1
|
|
||||||
git push origin -d 1.18.0-s.2
|
|
||||||
git push origin -d 1.18.1-s.0
|
|
||||||
git push origin -d 1.18.1-s.1
|
|
||||||
git push origin -d 1.18.1-s.2
|
|
||||||
git push origin -d 1.18.1-s.3
|
|
||||||
git push origin -d 1.18.1-s.4
|
|
||||||
git push origin -d 1.18.1-s.5
|
|
||||||
git push origin -d 1.18.1-s.6
|
|
||||||
git push origin -d 1.18.1-s.7
|
|
||||||
git push origin -d 1.18.2-s.0
|
|
||||||
git push origin -d 1.18.2-s.1
|
|
||||||
git push origin -d 1.18.2-s.2
|
|
||||||
git push origin -d 1.18.2-s.3
|
|
||||||
git push origin -d 1.18.2-s.4
|
|
||||||
git push origin -d 1.18.2-s.5
|
|
||||||
git push origin -d 1.18.3-s.0
|
|
||||||
git push origin -d 1.18.3-s.1
|
|
||||||
git push origin -d 1.18.3-s.2
|
|
||||||
git push origin -d 1.18.3-s.3
|
|
||||||
git push origin -d 1.18.4-s.0
|
|
||||||
git push origin -d 1.18.4-s.1
|
|
||||||
git push origin -d 1.18.4-s.2
|
|
||||||
git push origin -d 1.18.4-s.3
|
|
||||||
git push origin -d 1.18.4-s.4
|
|
||||||
git push origin -d 1.18.4-s.5
|
|
||||||
git push origin -d 1.18.4-s.6
|
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.6
|
image: traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
@@ -1,54 +1,47 @@
|
|||||||
api:
|
api:
|
||||||
insecure: true
|
insecure: true
|
||||||
dashboard: true
|
dashboard: true
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
http:
|
http:
|
||||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
endpoint: http://pangolin:3001/api/v1/traefik-config
|
||||||
pollInterval: "5s"
|
pollInterval: 5s
|
||||||
file:
|
file:
|
||||||
filename: "/etc/traefik/dynamic_config.yml"
|
filename: /etc/traefik/dynamic_config.yml
|
||||||
|
|
||||||
experimental:
|
experimental:
|
||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: github.com/fosrl/badger
|
||||||
version: "{{.BadgerVersion}}"
|
version: v1.4.1
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: INFO
|
||||||
format: "common"
|
format: common
|
||||||
maxSize: 100
|
maxSize: 100
|
||||||
maxBackups: 3
|
maxBackups: 3
|
||||||
maxAge: 3
|
maxAge: 3
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
certificatesResolvers:
|
certificatesResolvers:
|
||||||
letsencrypt:
|
letsencrypt:
|
||||||
acme:
|
acme:
|
||||||
httpChallenge:
|
httpChallenge:
|
||||||
entryPoint: web
|
entryPoint: web
|
||||||
email: "{{.LetsEncryptEmail}}"
|
email: '{{.LetsEncryptEmail}}'
|
||||||
storage: "/letsencrypt/acme.json"
|
storage: /letsencrypt/acme.json
|
||||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
caServer: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
entryPoints:
|
entryPoints:
|
||||||
web:
|
web:
|
||||||
address: ":80"
|
address: ':80'
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ':443'
|
||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: 30m
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: letsencrypt
|
||||||
encodedCharacters:
|
encodedCharacters:
|
||||||
allowEncodedSlash: true
|
allowEncodedSlash: true
|
||||||
allowEncodedQuestionMark: true
|
allowEncodedQuestionMark: true
|
||||||
|
|
||||||
serversTransport:
|
serversTransport:
|
||||||
insecureSkipVerify: true
|
insecureSkipVerify: true
|
||||||
|
|
||||||
ping:
|
ping:
|
||||||
entryPoint: "web"
|
entryPoint: web
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ server:
|
|||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
credentials: false
|
credentials: false
|
||||||
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||||
|
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: "{{.EmailSMTPHost}}"
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
@@ -36,3 +37,6 @@ flags:
|
|||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1g
|
memory: 2g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256m
|
memory: 512m
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
condition: service_healthy{{end}}
|
||||||
|
{{if .IsRedis}}redis:
|
||||||
|
condition: service_healthy{{end}}
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- backend{{end}}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,8 +25,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
{{if .InstallGerbil}}
|
|
||||||
gerbil:
|
{{if .InstallGerbil}}gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -39,17 +47,16 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -60,8 +67,50 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: pangolin
|
||||||
|
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
|
||||||
|
POSTGRES_DB: pangolin
|
||||||
|
volumes:
|
||||||
|
- ./postgres18:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pangolin"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend{{end}}
|
||||||
|
|
||||||
|
{{if .IsRedis}}redis:
|
||||||
|
image: redis:8-trixie
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--save 3600 1000
|
||||||
|
--appendonly yes
|
||||||
|
--requirepass {{.IsRedisPass}}
|
||||||
|
volumes:
|
||||||
|
- ./redis8:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- backend{{end}}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}} backend:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin_backend
|
||||||
|
internal: true{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{if .IsRedis}}redis:
|
||||||
|
host: "redis"
|
||||||
|
port: 6379
|
||||||
|
password: "{{.IsRedisPass}}"{{end}}
|
||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.44.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
+52
-19
@@ -54,9 +54,13 @@ type Config struct {
|
|||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
TraefikBouncerKey string
|
TraefikBouncerKey string
|
||||||
DoCrowdsecInstall bool
|
DoCrowdsecInstall bool
|
||||||
EnableGeoblocking bool
|
EnableMaxMind bool
|
||||||
Secret string
|
Secret string
|
||||||
IsEnterprise bool
|
IsEnterprise bool
|
||||||
|
IsPostgreSQL bool
|
||||||
|
IsPostgreSQLPass string
|
||||||
|
IsRedis bool
|
||||||
|
IsRedisPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedContainer string
|
type SupportedContainer string
|
||||||
@@ -67,9 +71,12 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var redisFlag *bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
|
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
@@ -123,11 +130,11 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
// Download MaxMind database if requested
|
// Download MaxMind Country / ASN database if requested
|
||||||
if config.EnableGeoblocking {
|
if config.EnableMaxMind {
|
||||||
fmt.Println("\n=== Downloading MaxMind Database ===")
|
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
|
||||||
fmt.Println("You can download it manually later if needed.")
|
fmt.Println("You can download it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,15 +195,15 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
|
||||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -204,8 +211,10 @@ func main() {
|
|||||||
// Now you need to update your config file accordingly to enable geoblocking
|
// Now you need to update your config file accordingly to enable geoblocking
|
||||||
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
||||||
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
||||||
fmt.Println("Add the following line under the 'server' section:")
|
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
|
||||||
|
fmt.Println("Add the following lines under the 'server' section:")
|
||||||
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
||||||
|
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +493,17 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
|
if config.IsEnterprise {
|
||||||
|
if *redisFlag {
|
||||||
|
config.IsRedis = true
|
||||||
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
||||||
|
if config.IsPostgreSQL {
|
||||||
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
|
}
|
||||||
|
|
||||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
@@ -527,7 +547,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -780,29 +800,42 @@ func checkPortsAvailable(port int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadMaxMindDatabase() error {
|
func downloadMaxMindDatabase() error {
|
||||||
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
|
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
|
||||||
|
|
||||||
// Download the GeoLite2 Country database
|
// Download the GeoLite2 Country databases
|
||||||
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
||||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
|
||||||
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the database
|
// Extract the Country database
|
||||||
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the .mmdb file and move it to the config directory
|
// Find the .mmdb file and move it to the config directory
|
||||||
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
||||||
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
|
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
|
||||||
|
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the downloaded files
|
// Clean up the downloaded files
|
||||||
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
|
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
|
||||||
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
|
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
|
||||||
|
}
|
||||||
|
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
|
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+567
-49
File diff suppressed because it is too large
Load Diff
+565
-47
File diff suppressed because it is too large
Load Diff
+3820
File diff suppressed because it is too large
Load Diff
+558
-40
File diff suppressed because it is too large
Load Diff
+522
-57
File diff suppressed because it is too large
Load Diff
+565
-47
File diff suppressed because it is too large
Load Diff
+557
-39
File diff suppressed because it is too large
Load Diff
+565
-47
File diff suppressed because it is too large
Load Diff
+561
-43
File diff suppressed because it is too large
Load Diff
+565
-47
File diff suppressed because it is too large
Load Diff
+565
-47
File diff suppressed because it is too large
Load Diff
+559
-41
File diff suppressed because it is too large
Load Diff
+566
-48
File diff suppressed because it is too large
Load Diff
+557
-39
@@ -66,9 +66,15 @@
|
|||||||
"local": "Локальный",
|
"local": "Локальный",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"siteConfirmDelete": "Подтвердить удаление сайта",
|
"siteConfirmDelete": "Подтвердить удаление сайта",
|
||||||
|
"siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов",
|
||||||
"siteDelete": "Удалить сайт",
|
"siteDelete": "Удалить сайт",
|
||||||
|
"siteDeleteAndResources": "Удалить сайт и ресурсы",
|
||||||
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
|
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
|
||||||
|
"siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.",
|
||||||
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
|
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
|
||||||
|
"siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?",
|
||||||
|
"sitesTableDeleteSite": "Удалить сайт",
|
||||||
|
"sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы",
|
||||||
"siteManageSites": "Управление сайтами",
|
"siteManageSites": "Управление сайтами",
|
||||||
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
|
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
|
||||||
"sitesBannerTitle": "Подключить любую сеть",
|
"sitesBannerTitle": "Подключить любую сеть",
|
||||||
@@ -101,6 +107,8 @@
|
|||||||
"sitesTableViewPrivateResources": "Просмотр частных ресурсов",
|
"sitesTableViewPrivateResources": "Просмотр частных ресурсов",
|
||||||
"siteInstallNewt": "Установить Newt",
|
"siteInstallNewt": "Установить Newt",
|
||||||
"siteInstallNewtDescription": "Запустите Newt в вашей системе",
|
"siteInstallNewtDescription": "Запустите Newt в вашей системе",
|
||||||
|
"siteInstallKubernetesDocsDescription": "Для получения дополнительной информации об установке Kubernetes, см. <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
|
||||||
|
"siteInstallAdvantechDocsDescription": "Для инструкций по установке модема Advantech, см. <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
|
||||||
"WgConfiguration": "Конфигурация WireGuard",
|
"WgConfiguration": "Конфигурация WireGuard",
|
||||||
"WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети",
|
"WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети",
|
||||||
"operatingSystem": "Операционная система",
|
"operatingSystem": "Операционная система",
|
||||||
@@ -115,6 +123,16 @@
|
|||||||
"siteUpdated": "Сайт обновлён",
|
"siteUpdated": "Сайт обновлён",
|
||||||
"siteUpdatedDescription": "Сайт был успешно обновлён.",
|
"siteUpdatedDescription": "Сайт был успешно обновлён.",
|
||||||
"siteGeneralDescription": "Настройте общие параметры для этого сайта",
|
"siteGeneralDescription": "Настройте общие параметры для этого сайта",
|
||||||
|
"siteRestartTitle": "Перезагрузить сайт",
|
||||||
|
"siteRestartDescription": "Перезапустите туннель WireGuard для этого сайта. Это кратковременно прервет соединение.",
|
||||||
|
"siteRestartBody": "Используйте это, если туннель сайта не работает должным образом и вам нужно принудительно переподключиться без перезапуска хоста.",
|
||||||
|
"siteRestartButton": "Перезагрузить сайт",
|
||||||
|
"siteRestartDialogMessage": "Вы уверены, что хотите перезапустить туннель WireGuard для <b>{name}</b>? Сайт кратковременно потеряет соединение.",
|
||||||
|
"siteRestartWarning": "Сайт кратковременно отключится во время перезапуска туннеля.",
|
||||||
|
"siteRestarted": "Сайт перезапущен",
|
||||||
|
"siteRestartedDescription": "Туннель WireGuard был перезапущен.",
|
||||||
|
"siteErrorRestart": "Не удалось перезапустить сайт",
|
||||||
|
"siteErrorRestartDescription": "Произошла ошибка во время перезапуска сайта.",
|
||||||
"siteSettingDescription": "Настройка параметров на сайте",
|
"siteSettingDescription": "Настройка параметров на сайте",
|
||||||
"siteResourcesTab": "Ресурсы",
|
"siteResourcesTab": "Ресурсы",
|
||||||
"siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.",
|
"siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.",
|
||||||
@@ -157,7 +175,7 @@
|
|||||||
"shareDeleted": "Ссылка удалена",
|
"shareDeleted": "Ссылка удалена",
|
||||||
"shareDeletedDescription": "Ссылка была успешно удалена",
|
"shareDeletedDescription": "Ссылка была успешно удалена",
|
||||||
"shareDelete": "Удалить общую ссылку",
|
"shareDelete": "Удалить общую ссылку",
|
||||||
"shareDeleteConfirm": "Подтвердите удаление общей ссылки",
|
"shareDeleteConfirm": "Подтвердить удаление общей ссылки",
|
||||||
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
|
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
|
||||||
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
|
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
|
||||||
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
|
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
|
||||||
@@ -176,6 +194,8 @@
|
|||||||
"shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки",
|
"shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки",
|
||||||
"shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу",
|
"shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу",
|
||||||
"shareTitleOptional": "Заголовок (необязательно)",
|
"shareTitleOptional": "Заголовок (необязательно)",
|
||||||
|
"sharePathOptional": "Путь (необязательно)",
|
||||||
|
"sharePathDescription": "Ссылка перенаправит пользователей на этот путь после аутентификации.",
|
||||||
"expireIn": "Срок действия",
|
"expireIn": "Срок действия",
|
||||||
"neverExpire": "Бессрочный доступ",
|
"neverExpire": "Бессрочный доступ",
|
||||||
"shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.",
|
"shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.",
|
||||||
@@ -199,8 +219,8 @@
|
|||||||
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
|
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
|
||||||
"proxyResourceTitle": "Управление публичными ресурсами",
|
"proxyResourceTitle": "Управление публичными ресурсами",
|
||||||
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
|
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
|
||||||
"proxyResourcesBannerTitle": "Общедоступный доступ через веб",
|
"publicResourcesBannerTitle": "Веб-доступ к публичным ресурсам",
|
||||||
"proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
|
"publicResourcesBannerDescription": "Публичные ресурсы — это HTTPS-прокси, доступные для любого пользователя Интернета через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать в себя политики доступа, учитывающие идентичность и контекст.",
|
||||||
"clientResourceTitle": "Управление приватными ресурсами",
|
"clientResourceTitle": "Управление приватными ресурсами",
|
||||||
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
|
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
|
||||||
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
|
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
|
||||||
@@ -208,11 +228,37 @@
|
|||||||
"resourcesSearch": "Поиск ресурсов...",
|
"resourcesSearch": "Поиск ресурсов...",
|
||||||
"resourceAdd": "Добавить ресурс",
|
"resourceAdd": "Добавить ресурс",
|
||||||
"resourceErrorDelte": "Ошибка при удалении ресурса",
|
"resourceErrorDelte": "Ошибка при удалении ресурса",
|
||||||
|
"resourcePoliciesBannerTitle": "Повторное использование правил аутентификации и доступа",
|
||||||
|
"resourcePoliciesBannerDescription": "Политики общих ресурсов позволяют один раз определить методы аутентификации и правила доступа, а затем прикреплять их к нескольким публичным ресурсам. Когда вы обновляете политику, каждое связанное с ней наследует изменение автоматически.",
|
||||||
|
"resourcePoliciesBannerButtonText": "Узнать больше",
|
||||||
|
"resourcePoliciesTitle": "Управление политиками публичных ресурсов",
|
||||||
|
"resourcePoliciesAttachedResourcesColumnTitle": "Ресурсы",
|
||||||
|
"resourcePoliciesAttachedResources": "{count} ресурс(ов)",
|
||||||
|
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# ресурс} few {# ресурса} many {# ресурсов} other {# ресурсов}}",
|
||||||
|
"resourcePoliciesAttachedResourcesEmpty": "нет ресурсов",
|
||||||
|
"resourcePoliciesDescription": "Создание и управление политиками аутентификации для контроля доступа к вашим публичным ресурсам",
|
||||||
|
"resourcePoliciesSearch": "Поиск политик...",
|
||||||
|
"resourcePoliciesAdd": "Добавить политику",
|
||||||
|
"resourcePoliciesDefaultBadgeText": "Политика по умолчанию",
|
||||||
|
"resourcePoliciesCreate": "Создать политику публичного ресурса",
|
||||||
|
"resourcePoliciesCreateDescription": "Следуйте шагам ниже, чтобы создать новую политику",
|
||||||
|
"resourcePolicyName": "Имя политики",
|
||||||
|
"resourcePolicyNameDescription": "Дайте этой политике имя для идентификации ее в ваших ресурсах",
|
||||||
|
"resourcePolicyNamePlaceholder": "например, Политика внутреннего доступа",
|
||||||
|
"resourcePoliciesSeeAll": "Просмотреть все политики",
|
||||||
|
"resourcePolicyAuthMethodAdd": "Добавить метод аутентификации",
|
||||||
|
"resourcePolicyOtpEmailAdd": "Добавить OTP на email",
|
||||||
|
"resourcePolicyRulesAdd": "Добавить правила",
|
||||||
|
"resourcePolicyAuthMethodsDescription": "Разрешить доступ к ресурсам через дополнительные методы аутентификации",
|
||||||
|
"resourcePolicyUsersRolesDescription": "Настройте, какие пользователи и роли могут посещать связанные ресурсы",
|
||||||
|
"rulesResourcePolicyDescription": "Настройте правила для управления доступом к ресурсам, связанным с этой политикой",
|
||||||
"authentication": "Аутентификация",
|
"authentication": "Аутентификация",
|
||||||
"protected": "Защищён",
|
"protected": "Защищён",
|
||||||
"notProtected": "Не защищён",
|
"notProtected": "Не защищён",
|
||||||
"resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.",
|
"resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.",
|
||||||
"resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?",
|
"resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?",
|
||||||
|
"resourcePolicyMessageRemove": "После удаления политика ресурса больше не будет доступна. Все ресурсы, связанные с ресурсом, будут отключены и останутся без аутентификации.",
|
||||||
|
"resourcePolicyQuestionRemove": "Вы уверены, что хотите удалить политику ресурса из организации?",
|
||||||
"resourceHTTP": "HTTPS-ресурс",
|
"resourceHTTP": "HTTPS-ресурс",
|
||||||
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
||||||
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
||||||
@@ -220,8 +266,11 @@
|
|||||||
"resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.",
|
"resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.",
|
||||||
"resourceCreate": "Создание ресурса",
|
"resourceCreate": "Создание ресурса",
|
||||||
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
||||||
|
"resourcePublicCreate": "Создать публичный ресурс",
|
||||||
|
"resourcePublicCreateDescription": "Следуйте инструкциям ниже, чтобы создать новый публичный ресурс, доступный через веб-браузер",
|
||||||
|
"resourceCreateGeneralDescription": "Настройте основные параметры ресурса, включая его имя и тип",
|
||||||
"resourceSeeAll": "Посмотреть все ресурсы",
|
"resourceSeeAll": "Посмотреть все ресурсы",
|
||||||
"resourceInfo": "Информация о ресурсе",
|
"resourceCreateGeneral": "Общие",
|
||||||
"resourceNameDescription": "Отображаемое имя ресурса.",
|
"resourceNameDescription": "Отображаемое имя ресурса.",
|
||||||
"siteSelect": "Выберите сайт",
|
"siteSelect": "Выберите сайт",
|
||||||
"siteSearch": "Поиск сайта",
|
"siteSearch": "Поиск сайта",
|
||||||
@@ -231,12 +280,15 @@
|
|||||||
"noCountryFound": "Страна не найдена.",
|
"noCountryFound": "Страна не найдена.",
|
||||||
"siteSelectionDescription": "Этот сайт предоставит подключение к цели.",
|
"siteSelectionDescription": "Этот сайт предоставит подключение к цели.",
|
||||||
"resourceType": "Тип ресурса",
|
"resourceType": "Тип ресурса",
|
||||||
"resourceTypeDescription": "Определить как получить доступ к ресурсу",
|
"resourceTypeDescription": "Это контролирует протокол ресурса и то, как он будет отображаться в браузере. Это нельзя изменить позже.",
|
||||||
|
"resourceDomainDescription": "Ресурс будет предоставлен по этому полностью определенному доменному имени.",
|
||||||
"resourceHTTPSSettings": "Настройки HTTPS",
|
"resourceHTTPSSettings": "Настройки HTTPS",
|
||||||
"resourceHTTPSSettingsDescription": "Настройка доступа к ресурсу по HTTPS",
|
"resourceHTTPSSettingsDescription": "Настройка доступа к ресурсу по HTTPS",
|
||||||
|
"resourcePortDescription": "Внешний порт на экземпляре или узле Pangolin, где ресурс будет доступен.",
|
||||||
"domainType": "Тип домена",
|
"domainType": "Тип домена",
|
||||||
"subdomain": "Поддомен",
|
"subdomain": "Поддомен",
|
||||||
"baseDomain": "Базовый домен",
|
"baseDomain": "Базовый домен",
|
||||||
|
"configure": "Настроить",
|
||||||
"subdomnainDescription": "Поддомен, в котором ресурс будет доступен.",
|
"subdomnainDescription": "Поддомен, в котором ресурс будет доступен.",
|
||||||
"resourceRawSettings": "Настройки TCP/UDP",
|
"resourceRawSettings": "Настройки TCP/UDP",
|
||||||
"resourceRawSettingsDescription": "Настройка доступа к ресурсу по TCP/UDP",
|
"resourceRawSettingsDescription": "Настройка доступа к ресурсу по TCP/UDP",
|
||||||
@@ -247,14 +299,35 @@
|
|||||||
"back": "Назад",
|
"back": "Назад",
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"resourceConfig": "Фрагменты конфигурации",
|
"resourceConfig": "Фрагменты конфигурации",
|
||||||
"resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса",
|
"resourceConfigDescription": "Скопируйте и вставьте эти фрагменты конфигурации для настройки ресурса TCP/UDP.",
|
||||||
"resourceAddEntrypoints": "Traefik: Добавить точки входа",
|
"resourceAddEntrypoints": "Traefik: Добавить точки входа",
|
||||||
"resourceExposePorts": "Gerbil: Открыть порты в Docker Compose",
|
"resourceExposePorts": "Gerbil: Открыть порты в Docker Compose",
|
||||||
"resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы",
|
"resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы",
|
||||||
"resourceBack": "Назад к ресурсам",
|
"resourceBack": "Назад к ресурсам",
|
||||||
"resourceGoTo": "Перейти к ресурсу",
|
"resourceGoTo": "Перейти к ресурсу",
|
||||||
|
"resourcePolicyDelete": "Удалить политику ресурса",
|
||||||
|
"resourcePolicyDeleteConfirm": "Подтвердите удаление политики ресурса",
|
||||||
"resourceDelete": "Удалить ресурс",
|
"resourceDelete": "Удалить ресурс",
|
||||||
"resourceDeleteConfirm": "Подтвердить удаление",
|
"resourceDeleteConfirm": "Подтвердить удаление",
|
||||||
|
"labelDelete": "Удалить метку",
|
||||||
|
"labelAdd": "Добавить метку",
|
||||||
|
"labelCreateSuccessMessage": "Метка успешно создана",
|
||||||
|
"labelDuplicateError": "Повторяющаяся метка",
|
||||||
|
"labelDuplicateErrorDescription": "Метка с таким именем уже существует.",
|
||||||
|
"labelEditSuccessMessage": "Метка успешно изменена",
|
||||||
|
"labelNameField": "Название метки",
|
||||||
|
"labelColorField": "Цвет метки",
|
||||||
|
"labelPlaceholder": "Напр.: homelab",
|
||||||
|
"labelCreate": "Создать метку",
|
||||||
|
"createLabelDialogTitle": "Создать метку",
|
||||||
|
"createLabelDialogDescription": "Создайте новую метку, которая может быть прикреплена к этой организации",
|
||||||
|
"labelEdit": "Редактировать метку",
|
||||||
|
"editLabelDialogTitle": "Обновить метку",
|
||||||
|
"editLabelDialogDescription": "Измените новую метку, которую можно прикрепить к этой организации",
|
||||||
|
"labelDeleteConfirm": "Подтвердите удаление метки",
|
||||||
|
"labelErrorDelete": "Не удалось удалить метку",
|
||||||
|
"labelMessageRemove": "Это действие необратимо. Все сайты, ресурсы и клиенты, помеченные этой меткой, будут разметены.",
|
||||||
|
"labelQuestionRemove": "Вы уверены, что хотите удалить метку из организации?",
|
||||||
"visibility": "Видимость",
|
"visibility": "Видимость",
|
||||||
"enabled": "Включено",
|
"enabled": "Включено",
|
||||||
"disabled": "Отключено",
|
"disabled": "Отключено",
|
||||||
@@ -265,6 +338,8 @@
|
|||||||
"rules": "Правила",
|
"rules": "Правила",
|
||||||
"resourceSettingDescription": "Настройка параметров ресурса",
|
"resourceSettingDescription": "Настройка параметров ресурса",
|
||||||
"resourceSetting": "Настройки {resourceName}",
|
"resourceSetting": "Настройки {resourceName}",
|
||||||
|
"resourcePolicySettingDescription": "Настройте параметры этой политики публичного ресурса",
|
||||||
|
"resourcePolicySetting": "Настройки {policyName}",
|
||||||
"alwaysAllow": "Авторизация байпасса",
|
"alwaysAllow": "Авторизация байпасса",
|
||||||
"alwaysDeny": "Блокировать доступ",
|
"alwaysDeny": "Блокировать доступ",
|
||||||
"passToAuth": "Переход к аутентификации",
|
"passToAuth": "Переход к аутентификации",
|
||||||
@@ -374,8 +449,14 @@
|
|||||||
"provisioningManage": "Подготовка",
|
"provisioningManage": "Подготовка",
|
||||||
"provisioningDescription": "Управляйте предоставленными ключами и проверять непроверенные сайты, ожидающие утверждения.",
|
"provisioningDescription": "Управляйте предоставленными ключами и проверять непроверенные сайты, ожидающие утверждения.",
|
||||||
"pendingSites": "Ожидающие сайты",
|
"pendingSites": "Ожидающие сайты",
|
||||||
"siteApproveSuccess": "Сайт успешно утвержден",
|
"siteApproveSuccess": "Сайт и связанные ресурсы успешно одобрены",
|
||||||
"siteApproveError": "Ошибка при утверждении сайта",
|
"siteApproveError": "Ошибка при утверждении сайта",
|
||||||
|
"siteReject": "Отклонить сайт",
|
||||||
|
"siteQuestionReject": "Вы уверены, что хотите отклонить этот сайт?",
|
||||||
|
"siteMessageReject": "Этот процесс окончательно удалит сайт и все связанные с ним ресурсы, которые еще ожидают.",
|
||||||
|
"siteConfirmReject": "Подтвердите отклонение сайта",
|
||||||
|
"siteRejectSuccess": "Сайт успешно отклонен",
|
||||||
|
"siteRejectError": "Ошибка при отклонении сайта",
|
||||||
"provisioningKeys": "Ключи подготовки",
|
"provisioningKeys": "Ключи подготовки",
|
||||||
"searchProvisioningKeys": "Поиск подготовительных ключей...",
|
"searchProvisioningKeys": "Поиск подготовительных ключей...",
|
||||||
"provisioningKeysAdd": "Сгенерировать ключ подготовки",
|
"provisioningKeysAdd": "Сгенерировать ключ подготовки",
|
||||||
@@ -391,8 +472,8 @@
|
|||||||
"provisioningKeysSave": "Сохранить ключ подготовки",
|
"provisioningKeysSave": "Сохранить ключ подготовки",
|
||||||
"provisioningKeysSaveDescription": "Вы сможете увидеть это только один раз. Скопируйте его в безопасное место.",
|
"provisioningKeysSaveDescription": "Вы сможете увидеть это только один раз. Скопируйте его в безопасное место.",
|
||||||
"provisioningKeysErrorCreate": "Ошибка при создании ключа подготовки",
|
"provisioningKeysErrorCreate": "Ошибка при создании ключа подготовки",
|
||||||
"provisioningKeysList": "Новый подготовительный ключ",
|
"provisioningKeysList": "Новый ключ для подготовки",
|
||||||
"provisioningKeysMaxBatchSize": "Макс. размер партии",
|
"provisioningKeysMaxBatchSize": "Максимальный размер партии",
|
||||||
"provisioningKeysUnlimitedBatchSize": "Неограниченный размер партии (без ограничений)",
|
"provisioningKeysUnlimitedBatchSize": "Неограниченный размер партии (без ограничений)",
|
||||||
"provisioningKeysMaxBatchUnlimited": "Неограниченный",
|
"provisioningKeysMaxBatchUnlimited": "Неограниченный",
|
||||||
"provisioningKeysMaxBatchSizeInvalid": "Введите максимальный размер пакета (1–1,000,000).",
|
"provisioningKeysMaxBatchSizeInvalid": "Введите максимальный размер пакета (1–1,000,000).",
|
||||||
@@ -540,7 +621,8 @@
|
|||||||
"idpNameInternal": "Внутренний",
|
"idpNameInternal": "Внутренний",
|
||||||
"emailInvalid": "Неверный адрес Email",
|
"emailInvalid": "Неверный адрес Email",
|
||||||
"inviteValidityDuration": "Пожалуйста, выберите продолжительность",
|
"inviteValidityDuration": "Пожалуйста, выберите продолжительность",
|
||||||
"accessRoleSelectPlease": "Пожалуйста, выберите роль",
|
"accessRoleSelectPlease": "Пользователь должен принадлежать хотя бы к одной роли.",
|
||||||
|
"accessRoleRequired": "Требуется роль",
|
||||||
"removeOwnAdminRoleConfirmTitle": "Удалить доступ администратора?",
|
"removeOwnAdminRoleConfirmTitle": "Удалить доступ администратора?",
|
||||||
"removeOwnAdminRoleConfirmDescription": "После сохранения у вас больше не будет прав администратора в этой организации. Другой администратор может восстановить доступ, если это необходимо.",
|
"removeOwnAdminRoleConfirmDescription": "После сохранения у вас больше не будет прав администратора в этой организации. Другой администратор может восстановить доступ, если это необходимо.",
|
||||||
"removeOwnAdminRoleConfirmButton": "Удалить мой доступ администратора",
|
"removeOwnAdminRoleConfirmButton": "Удалить мой доступ администратора",
|
||||||
@@ -671,7 +753,7 @@
|
|||||||
"targetSubmit": "Добавить цель",
|
"targetSubmit": "Добавить цель",
|
||||||
"targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.",
|
"targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.",
|
||||||
"targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.",
|
"targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.",
|
||||||
"targetsSubmit": "Сохранить цели",
|
"targetsSubmit": "Сохранить настройки",
|
||||||
"addTarget": "Добавить цель",
|
"addTarget": "Добавить цель",
|
||||||
"proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.",
|
"proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.",
|
||||||
"targetErrorInvalidIp": "Неверный IP-адрес",
|
"targetErrorInvalidIp": "Неверный IP-адрес",
|
||||||
@@ -705,11 +787,11 @@
|
|||||||
"rulesErrorDuplicate": "Дублирующее правило",
|
"rulesErrorDuplicate": "Дублирующее правило",
|
||||||
"rulesErrorDuplicateDescription": "Правило с такими настройками уже существует",
|
"rulesErrorDuplicateDescription": "Правило с такими настройками уже существует",
|
||||||
"rulesErrorInvalidIpAddressRange": "Неверный CIDR",
|
"rulesErrorInvalidIpAddressRange": "Неверный CIDR",
|
||||||
"rulesErrorInvalidIpAddressRangeDescription": "Пожалуйста, введите корректное значение CIDR",
|
"rulesErrorInvalidIpAddressRangeDescription": "Введите действительный диапазон CIDR (например, 10.0.0.0/8).",
|
||||||
"rulesErrorInvalidUrl": "Неверный URL путь",
|
"rulesErrorInvalidUrl": "Неверный путь",
|
||||||
"rulesErrorInvalidUrlDescription": "Пожалуйста, введите корректное значение URL пути",
|
"rulesErrorInvalidUrlDescription": "Введите действительный URL-путь или шаблон (например, /api/*).",
|
||||||
"rulesErrorInvalidIpAddress": "Неверный IP",
|
"rulesErrorInvalidIpAddress": "Недействительный IP адрес",
|
||||||
"rulesErrorInvalidIpAddressDescription": "Пожалуйста, введите корректный IP адрес",
|
"rulesErrorInvalidIpAddressDescription": "Введите действительный адрес IPv4 или IPv6.",
|
||||||
"rulesErrorUpdate": "Не удалось обновить правила",
|
"rulesErrorUpdate": "Не удалось обновить правила",
|
||||||
"rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил",
|
"rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил",
|
||||||
"rulesUpdated": "Включить правила",
|
"rulesUpdated": "Включить правила",
|
||||||
@@ -718,14 +800,23 @@
|
|||||||
"rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)",
|
"rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)",
|
||||||
"rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)",
|
"rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)",
|
||||||
"rulesErrorInvalidPriority": "Неверный приоритет",
|
"rulesErrorInvalidPriority": "Неверный приоритет",
|
||||||
"rulesErrorInvalidPriorityDescription": "Пожалуйста, введите корректный приоритет",
|
"rulesErrorInvalidPriorityDescription": "Введите целое число 1 или больше.",
|
||||||
"rulesErrorDuplicatePriority": "Дублирующие приоритеты",
|
"rulesErrorDuplicatePriority": "Повторяющиеся приоритеты",
|
||||||
"rulesErrorDuplicatePriorityDescription": "Пожалуйста, введите уникальные приоритеты",
|
"rulesErrorDuplicatePriorityDescription": "Каждое правило должно иметь уникальный номер приоритета.",
|
||||||
|
"rulesErrorValidation": "Неверные правила",
|
||||||
|
"rulesErrorValidationRuleDescription": "Правило {ruleNumber}: {message}",
|
||||||
|
"rulesErrorInvalidMatchTypeDescription": "Выберите действительный тип совпадения (путь, IP, CIDR, страна, регион или ASN).",
|
||||||
|
"rulesErrorValueRequired": "Введите значение для этого правила.",
|
||||||
|
"rulesErrorInvalidCountry": "Недействительная страна",
|
||||||
|
"rulesErrorInvalidCountryDescription": "Выберите правильную страну.",
|
||||||
|
"rulesErrorInvalidAsn": "Недействительный ASN",
|
||||||
|
"rulesErrorInvalidAsnDescription": "Введите действительный ASN (например, AS15169).",
|
||||||
"ruleUpdated": "Правила обновлены",
|
"ruleUpdated": "Правила обновлены",
|
||||||
"ruleUpdatedDescription": "Правила успешно обновлены",
|
"ruleUpdatedDescription": "Правила успешно обновлены",
|
||||||
"ruleErrorUpdate": "Операция не удалась",
|
"ruleErrorUpdate": "Операция не удалась",
|
||||||
"ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения",
|
"ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения",
|
||||||
"rulesPriority": "Приоритет",
|
"rulesPriority": "Приоритет",
|
||||||
|
"rulesReorderDragHandle": "Перетащите, чтобы изменить приоритет правила",
|
||||||
"rulesAction": "Действие",
|
"rulesAction": "Действие",
|
||||||
"rulesMatchType": "Тип совпадения",
|
"rulesMatchType": "Тип совпадения",
|
||||||
"value": "Значение",
|
"value": "Значение",
|
||||||
@@ -744,9 +835,60 @@
|
|||||||
"rulesResource": "Конфигурация правил ресурса",
|
"rulesResource": "Конфигурация правил ресурса",
|
||||||
"rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу",
|
"rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу",
|
||||||
"ruleSubmit": "Добавить правило",
|
"ruleSubmit": "Добавить правило",
|
||||||
"rulesNoOne": "Нет правил. Добавьте правило с помощью формы.",
|
"rulesNoOne": "Пока нет правил.",
|
||||||
"rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.",
|
"rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.",
|
||||||
"rulesSubmit": "Сохранить правила",
|
"rulesSubmit": "Сохранить правила",
|
||||||
|
"policyErrorCreate": "Ошибка создания политики",
|
||||||
|
"policyErrorCreateDescription": "Произошла ошибка при создании политики",
|
||||||
|
"policyErrorCreateMessageDescription": "Произошла неожиданная ошибка",
|
||||||
|
"policyErrorUpdate": "Ошибка обновления политики",
|
||||||
|
"policyErrorUpdateDescription": "Произошла ошибка при обновлении политики",
|
||||||
|
"policyErrorUpdateMessageDescription": "Произошла неожиданная ошибка",
|
||||||
|
"policyCreatedSuccess": "Политика ресурса успешно создана",
|
||||||
|
"policyUpdatedSuccess": "Политика ресурса успешно обновлена",
|
||||||
|
"authMethodsSave": "Сохранить настройки",
|
||||||
|
"policyAuthStackTitle": "Аутентификация",
|
||||||
|
"policyAuthStackDescription": "Контроль, какие методы аутентификации требуются для доступа к этому ресурсу",
|
||||||
|
"policyAuthOrLogicTitle": "Несколько методов аутентификации активны",
|
||||||
|
"policyAuthOrLogicBanner": "Посетители могут аутентифицироваться, используя любой из активных методов ниже. Им не нужно выполнять все.",
|
||||||
|
"policyAuthMethodActive": "Активно",
|
||||||
|
"policyAuthMethodOff": "Отключено",
|
||||||
|
"policyAuthSsoTitle": "Платформа SSO",
|
||||||
|
"policyAuthSsoDescription": "Требуется войти через поставщика удостоверений вашей организации",
|
||||||
|
"policyAuthSsoSummary": "{idp} · {users} пользователей, {roles} ролей",
|
||||||
|
"policyAuthSsoDefaultIdp": "Поставщик по умолчанию",
|
||||||
|
"policyAuthAddDefaultIdentityProvider": "Добавить поставщика удостоверений по умолчанию",
|
||||||
|
"policyAuthOtherMethodsTitle": "Другие методы",
|
||||||
|
"policyAuthOtherMethodsDescription": "Дополнительные методы, которые посетители могут использовать вместо или вместе с платформой SSO",
|
||||||
|
"policyAuthPasscodeTitle": "Пароль",
|
||||||
|
"policyAuthPasscodeDescription": "Требуется общий буквенно-цифровой пароль для доступа к ресурсу",
|
||||||
|
"policyAuthPasscodeSummary": "Пароль установлен",
|
||||||
|
"policyAuthPincodeTitle": "ПИН-код",
|
||||||
|
"policyAuthPincodeDescription": "Краткий числовой код, необходимый для доступа к ресурсу",
|
||||||
|
"policyAuthPincodeSummary": "Установлен 6-значный PIN-код",
|
||||||
|
"policyAuthEmailTitle": "Белый список email",
|
||||||
|
"policyAuthEmailDescription": "Разрешить перечисленные email-адреса с одноразовыми паролями",
|
||||||
|
"policyAuthEmailSummary": "Разрешено адресов: {count}",
|
||||||
|
"policyAuthEmailOtpCallout": "Включение белого списка email отправляет одноразовый пароль на email посетителя при входе.",
|
||||||
|
"policyAuthHeaderAuthTitle": "Базовая аутентификация заголовка",
|
||||||
|
"policyAuthHeaderAuthDescription": "Проверка пользовательского имени и значения HTTP-заголовка для каждого запроса",
|
||||||
|
"policyAuthHeaderAuthSummary": "Заголовок настроен",
|
||||||
|
"policyAuthHeaderName": "Имя пользователя",
|
||||||
|
"policyAuthHeaderValue": "Пароль",
|
||||||
|
"policyAuthSetPasscode": "Установить пароль",
|
||||||
|
"policyAuthSetPincode": "Установить ПИН-код",
|
||||||
|
"policyAuthSetEmailWhitelist": "Установить белый список email",
|
||||||
|
"policyAuthSetHeaderAuth": "Установить базовую аутентификацию заголовка",
|
||||||
|
"policyAccessRulesTitle": "Правила доступа",
|
||||||
|
"policyAccessRulesEnableDescription": "При включении правила оцениваются в порядке убывания до тех пор, пока одно из них не оценивается как истинное.",
|
||||||
|
"policyAccessRulesFirstMatch": "Правила оцениваются сверху вниз. Первое совпадающее правило определяет результат.",
|
||||||
|
"policyAccessRulesHowItWorks": "Правила сопоставляют запросы по пути, IP-адресу, местоположению или другим критериям. Каждое правило применяет действие: обойти аутентификацию, заблокировать доступ или передать для аутентификации. Если правило не подписано, трафик продолжается для аутентификации.",
|
||||||
|
"policyAccessRulesFallthroughOff": "Когда правила отключены, весь трафик проходит для аутентификации.",
|
||||||
|
"policyAccessRulesFallthroughOn": "Когда правило не совпадает, трафик проходит для аутентификации.",
|
||||||
|
"rulesPlaceholderCidr": "10.0.0.0/8",
|
||||||
|
"rulesPlaceholderPath": "/admin/*",
|
||||||
|
"rulesPlaceholderGeo": "RU, KP",
|
||||||
|
"rulesSave": "Сохранить правила",
|
||||||
"resourceErrorCreate": "Ошибка при создании ресурса",
|
"resourceErrorCreate": "Ошибка при создании ресурса",
|
||||||
"resourceErrorCreateDescription": "Произошла ошибка при создании ресурса",
|
"resourceErrorCreateDescription": "Произошла ошибка при создании ресурса",
|
||||||
"resourceErrorCreateMessage": "Ошибка создания ресурса:",
|
"resourceErrorCreateMessage": "Ошибка создания ресурса:",
|
||||||
@@ -810,6 +952,17 @@
|
|||||||
"pincodeAdd": "Добавить PIN-код",
|
"pincodeAdd": "Добавить PIN-код",
|
||||||
"pincodeRemove": "Удалить PIN-код",
|
"pincodeRemove": "Удалить PIN-код",
|
||||||
"resourceAuthMethods": "Методы аутентификации",
|
"resourceAuthMethods": "Методы аутентификации",
|
||||||
|
"resourcePolicyAuthMethodsEmpty": "Нет метода аутентификации",
|
||||||
|
"resourcePolicyOtpEmpty": "Нет одноразового пароля",
|
||||||
|
"resourcePolicyReadOnly": "Эта политика только для чтения",
|
||||||
|
"resourcePolicyReadOnlyDescription": "Эта политика ресурса разделяется между несколькими ресурсами, вы не можете улучшить ее на этой странице.",
|
||||||
|
"editSharedPolicy": "Редактировать общую политику",
|
||||||
|
"resourcePolicyTypeSave": "Сохранить тип ресурса",
|
||||||
|
"resourcePolicySelect": "Выберите политику ресурса",
|
||||||
|
"resourcePolicySelectError": "Выберите политику ресурса",
|
||||||
|
"resourcePolicyNotFound": "Политика не найдена",
|
||||||
|
"resourcePolicySearch": "Поиск политик",
|
||||||
|
"resourcePolicyRulesEmpty": "Нет правил аутентификации",
|
||||||
"resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации",
|
"resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации",
|
||||||
"resourceAuthSettingsSave": "Успешно сохранено",
|
"resourceAuthSettingsSave": "Успешно сохранено",
|
||||||
"resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены",
|
"resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены",
|
||||||
@@ -845,6 +998,20 @@
|
|||||||
"resourcePincodeSetupTitle": "Установить PIN-код",
|
"resourcePincodeSetupTitle": "Установить PIN-код",
|
||||||
"resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса",
|
"resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса",
|
||||||
"resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.",
|
"resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.",
|
||||||
|
"resourcePolicySelectTitle": "Политика доступа к ресурсам",
|
||||||
|
"resourcePolicySelectDescription": "Выберите тип политики ресурса для аутентификации",
|
||||||
|
"resourcePolicyTypeLabel": "Тип политики",
|
||||||
|
"resourcePolicyLabel": "Политика ресурса",
|
||||||
|
"resourcePolicyInline": "Политика ресурса на месте",
|
||||||
|
"resourcePolicyInlineDescription": "Политика доступа ограничена только этим ресурсом",
|
||||||
|
"resourcePolicyShared": "Общая политика ресурса",
|
||||||
|
"resourcePolicySharedDescription": "Этот ресурс использует общую политику.",
|
||||||
|
"sharedPolicy": "Общая политика",
|
||||||
|
"sharedPolicyNoneDescription": "У этого ресурса есть своя политика.",
|
||||||
|
"resourceSharedPolicyOwnDescription": "У этого ресурса есть собственные средства управления аутентификацией и правилами доступа.",
|
||||||
|
"resourceSharedPolicyInheritedDescription": "Этот ресурс наследует от <policyLink>{policyName}</policyLink>.",
|
||||||
|
"resourceSharedPolicyAuthenticationNotice": "Этот ресурс использует общую политику. Некоторые настройки аутентификации можно изменить в этом ресурсе, чтобы добавить их в политику. Чтобы изменить основную политику, отредактируйте <policyLink>{policyName}</policyLink>.",
|
||||||
|
"resourceSharedPolicyRulesNotice": "Этот ресурс использует общую политику. Некоторые правила доступа могут быть отредактированы для этого ресурса. Чтобы изменить основную политику, вы должны отредактировать <policyLink>{policyName}</policyLink>.",
|
||||||
"resourceUsersRoles": "Контроль доступа",
|
"resourceUsersRoles": "Контроль доступа",
|
||||||
"resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу",
|
"resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу",
|
||||||
"resourceUsersRolesSubmit": "Сохранить контроль доступа",
|
"resourceUsersRolesSubmit": "Сохранить контроль доступа",
|
||||||
@@ -869,7 +1036,14 @@
|
|||||||
"resourceVisibilityTitle": "Видимость",
|
"resourceVisibilityTitle": "Видимость",
|
||||||
"resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса",
|
"resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса",
|
||||||
"resourceGeneral": "Общие настройки",
|
"resourceGeneral": "Общие настройки",
|
||||||
"resourceGeneralDescription": "Настройте общие параметры этого ресурса",
|
"resourceGeneralDescription": "Настройте имя, адрес и политику доступа для этого ресурса.",
|
||||||
|
"resourceGeneralDetailsSubsection": "Детали ресурса",
|
||||||
|
"resourceGeneralDetailsSubsectionDescription": "Установите отображаемое имя, идентификатор и публично доступный домен для этого ресурса.",
|
||||||
|
"resourceGeneralDetailsSubsectionPortDescription": "Установите отображаемое имя, идентификатор и публичный порт для этого ресурса.",
|
||||||
|
"resourceGeneralPublicAddressSubsection": "Публичный адрес",
|
||||||
|
"resourceGeneralPublicAddressSubsectionDescription": "Настройте, как пользователи будут получать доступ к этому ресурсу.",
|
||||||
|
"resourceGeneralAuthenticationAccessSubsection": "Аутентификация и доступ",
|
||||||
|
"resourceGeneralAuthenticationAccessSubsectionDescription": "Выберите, будет ли этот ресурс использовать собственную политику или наследовать от общей политики.",
|
||||||
"resourceEnable": "Ресурс активен",
|
"resourceEnable": "Ресурс активен",
|
||||||
"resourceTransfer": "Перенести ресурс",
|
"resourceTransfer": "Перенести ресурс",
|
||||||
"resourceTransferDescription": "Перенесите этот ресурс на другой сайт",
|
"resourceTransferDescription": "Перенесите этот ресурс на другой сайт",
|
||||||
@@ -1140,6 +1314,21 @@
|
|||||||
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
"idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.",
|
||||||
"idpErrorNotFound": "IdP не найден",
|
"idpErrorNotFound": "IdP не найден",
|
||||||
"inviteInvalid": "Недействительное приглашение",
|
"inviteInvalid": "Недействительное приглашение",
|
||||||
|
"labels": "Метки",
|
||||||
|
"orgLabelsDescription": "Управление метками в этой организации.",
|
||||||
|
"addLabels": "Добавить метки",
|
||||||
|
"siteLabelsTab": "Метки",
|
||||||
|
"siteLabelsDescription": "Управляйте метками, связанными с этим сайтом.",
|
||||||
|
"labelsNotFound": "Метки не найдены.",
|
||||||
|
"labelsEmptyCreateHint": "Начните печатать выше, чтобы создать метку.",
|
||||||
|
"labelSearch": "Поиск меток",
|
||||||
|
"labelSearchOrCreate": "Найти или создать метку",
|
||||||
|
"accessLabelFilterCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
|
||||||
|
"labelOverflowCount": "+{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
|
||||||
|
"accessLabelFilterClear": "Очистить фильтры меток",
|
||||||
|
"accessFilterClear": "Очистить фильтры",
|
||||||
|
"selectColor": "Выберите цвет",
|
||||||
|
"createNewLabel": "Создать новую метку организации \"{label}\"",
|
||||||
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
"inviteInvalidDescription": "Ссылка на приглашение недействительна.",
|
||||||
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
"inviteErrorWrongUser": "Приглашение не для этого пользователя",
|
||||||
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
|
"inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.",
|
||||||
@@ -1214,6 +1403,7 @@
|
|||||||
"createOrgUser": "Создать пользователя Org",
|
"createOrgUser": "Создать пользователя Org",
|
||||||
"actionUpdateOrg": "Обновить организацию",
|
"actionUpdateOrg": "Обновить организацию",
|
||||||
"actionRemoveInvitation": "Удалить приглашение",
|
"actionRemoveInvitation": "Удалить приглашение",
|
||||||
|
"actionRemoveUserRole": "Удалить роль пользователя",
|
||||||
"actionUpdateUser": "Обновить пользователя",
|
"actionUpdateUser": "Обновить пользователя",
|
||||||
"actionGetUser": "Получить пользователя",
|
"actionGetUser": "Получить пользователя",
|
||||||
"actionGetOrgUser": "Получить пользователя организации",
|
"actionGetOrgUser": "Получить пользователя организации",
|
||||||
@@ -1231,10 +1421,13 @@
|
|||||||
"actionApplyBlueprint": "Применить чертёж",
|
"actionApplyBlueprint": "Применить чертёж",
|
||||||
"actionListBlueprints": "Список чертежей",
|
"actionListBlueprints": "Список чертежей",
|
||||||
"actionGetBlueprint": "Получить чертёж",
|
"actionGetBlueprint": "Получить чертёж",
|
||||||
|
"actionCreateOrgWideLauncherView": "Создать вид запуска на уровне организации",
|
||||||
"setupToken": "Код настройки",
|
"setupToken": "Код настройки",
|
||||||
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
|
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
|
||||||
"setupTokenRequired": "Токен настройки обязателен",
|
"setupTokenRequired": "Токен настройки обязателен",
|
||||||
"actionUpdateSite": "Обновить сайт",
|
"actionUpdateSite": "Обновить сайт",
|
||||||
|
"actionApproveSite": "Одобрить сайт",
|
||||||
|
"actionRejectSite": "Отклонить сайт",
|
||||||
"actionResetSiteBandwidth": "Сброс пропускной способности организации",
|
"actionResetSiteBandwidth": "Сброс пропускной способности организации",
|
||||||
"actionListSiteRoles": "Список разрешенных ролей сайта",
|
"actionListSiteRoles": "Список разрешенных ролей сайта",
|
||||||
"actionCreateResource": "Создать ресурс",
|
"actionCreateResource": "Создать ресурс",
|
||||||
@@ -1250,6 +1443,15 @@
|
|||||||
"actionSetResourcePincode": "Установить ПИН-код ресурса",
|
"actionSetResourcePincode": "Установить ПИН-код ресурса",
|
||||||
"actionSetResourceEmailWhitelist": "Настроить белый список ресурсов email",
|
"actionSetResourceEmailWhitelist": "Настроить белый список ресурсов email",
|
||||||
"actionGetResourceEmailWhitelist": "Получить белый список ресурсов email",
|
"actionGetResourceEmailWhitelist": "Получить белый список ресурсов email",
|
||||||
|
"actionGetResourcePolicy": "Получить политику ресурса",
|
||||||
|
"actionUpdateResourcePolicy": "Обновить политику ресурса",
|
||||||
|
"actionSetResourcePolicyUsers": "Установить пользователей политики ресурса",
|
||||||
|
"actionSetResourcePolicyRoles": "Установить роли политики ресурса",
|
||||||
|
"actionSetResourcePolicyPassword": "Установить пароль политики ресурса",
|
||||||
|
"actionSetResourcePolicyPincode": "Установить ПИН-код политики ресурса",
|
||||||
|
"actionSetResourcePolicyHeaderAuth": "Установить аутентификацию по заголовкам для политики ресурса",
|
||||||
|
"actionSetResourcePolicyWhitelist": "Установить белый список по email для политики ресурса",
|
||||||
|
"actionSetResourcePolicyRules": "Установить правила политики ресурса",
|
||||||
"actionCreateTarget": "Создать цель",
|
"actionCreateTarget": "Создать цель",
|
||||||
"actionDeleteTarget": "Удалить цель",
|
"actionDeleteTarget": "Удалить цель",
|
||||||
"actionGetTarget": "Получить цель",
|
"actionGetTarget": "Получить цель",
|
||||||
@@ -1269,6 +1471,7 @@
|
|||||||
"actionGenerateAccessToken": "Сгенерировать токен доступа",
|
"actionGenerateAccessToken": "Сгенерировать токен доступа",
|
||||||
"actionDeleteAccessToken": "Удалить токен доступа",
|
"actionDeleteAccessToken": "Удалить токен доступа",
|
||||||
"actionListAccessTokens": "Список токенов доступа",
|
"actionListAccessTokens": "Список токенов доступа",
|
||||||
|
"actionCreateResourceSessionToken": "Создать токен сеанса ресурса",
|
||||||
"actionCreateResourceRule": "Создать правило ресурса",
|
"actionCreateResourceRule": "Создать правило ресурса",
|
||||||
"actionDeleteResourceRule": "Удалить правило ресурса",
|
"actionDeleteResourceRule": "Удалить правило ресурса",
|
||||||
"actionListResourceRules": "Список правил ресурса",
|
"actionListResourceRules": "Список правил ресурса",
|
||||||
@@ -1308,6 +1511,10 @@
|
|||||||
"actionListInvitations": "Список приглашений",
|
"actionListInvitations": "Список приглашений",
|
||||||
"actionExportLogs": "Экспорт журналов",
|
"actionExportLogs": "Экспорт журналов",
|
||||||
"actionViewLogs": "Просмотр журналов",
|
"actionViewLogs": "Просмотр журналов",
|
||||||
|
"actionCreateSiteProvisioningKey": "Создать ключ конфигурации сайта",
|
||||||
|
"actionListSiteProvisioningKeys": "Список ключей конфигурации сайтов",
|
||||||
|
"actionUpdateSiteProvisioningKey": "Обновить ключ конфигурации сайта",
|
||||||
|
"actionDeleteSiteProvisioningKey": "Удалить ключ конфигурации сайта",
|
||||||
"noneSelected": "Ничего не выбрано",
|
"noneSelected": "Ничего не выбрано",
|
||||||
"orgNotFound2": "Организации не найдены.",
|
"orgNotFound2": "Организации не найдены.",
|
||||||
"search": "Поиск…",
|
"search": "Поиск…",
|
||||||
@@ -1322,10 +1529,35 @@
|
|||||||
"otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.",
|
"otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.",
|
||||||
"otpAuthSubmit": "Отправить код",
|
"otpAuthSubmit": "Отправить код",
|
||||||
"idpContinue": "Или продолжить с",
|
"idpContinue": "Или продолжить с",
|
||||||
|
"idpLastUsed": "Последнее использование",
|
||||||
"otpAuthBack": "Назад к паролю",
|
"otpAuthBack": "Назад к паролю",
|
||||||
"navbar": "Навигационное меню",
|
"navbar": "Навигационное меню",
|
||||||
"navbarDescription": "Главное навигационное меню приложения",
|
"navbarDescription": "Главное навигационное меню приложения",
|
||||||
"navbarDocsLink": "Документация",
|
"navbarDocsLink": "Документация",
|
||||||
|
"commandPaletteTitle": "Палитра команд",
|
||||||
|
"commandPaletteDescription": "Поиск страниц, организаций, ресурсов и действий",
|
||||||
|
"commandPaletteSearchPlaceholder": "Поиск страниц, ресурсов, действий...",
|
||||||
|
"commandPaletteNoResults": "Результаты не найдены.",
|
||||||
|
"commandPaletteSearching": "Поиск...",
|
||||||
|
"commandPaletteNavigation": "Навигация",
|
||||||
|
"commandPaletteOrganizations": "Организации",
|
||||||
|
"commandPaletteSites": "Сайты",
|
||||||
|
"commandPaletteResources": "Ресурсы",
|
||||||
|
"commandPaletteUsers": "Пользователи",
|
||||||
|
"commandPaletteClients": "Клиенты машин",
|
||||||
|
"commandPaletteActions": "Действия",
|
||||||
|
"commandPaletteCreateSite": "Создать сайт",
|
||||||
|
"commandPaletteCreateProxyResource": "Создать публичный ресурс",
|
||||||
|
"commandPaletteCreatePrivateResource": "Создать частный ресурс",
|
||||||
|
"commandPaletteCreateUser": "Создать пользователя",
|
||||||
|
"commandPaletteCreateApiKey": "Создать ключ API",
|
||||||
|
"commandPaletteCreateMachineClient": "Создать клиент машин",
|
||||||
|
"commandPaletteCreateAlertRule": "Создать правило предупреждения",
|
||||||
|
"commandPaletteCreateIdentityProvider": "Создать поставщика удостоверений",
|
||||||
|
"commandPaletteToggleTheme": "Переключить тему",
|
||||||
|
"commandPaletteChooseOrganization": "Выбрать организацию",
|
||||||
|
"commandPaletteShortcutMac": "⌘K",
|
||||||
|
"commandPaletteShortcutWindows": "Ctrl K",
|
||||||
"otpErrorEnable": "Невозможно включить 2FA",
|
"otpErrorEnable": "Невозможно включить 2FA",
|
||||||
"otpErrorEnableDescription": "Произошла ошибка при включении 2FA",
|
"otpErrorEnableDescription": "Произошла ошибка при включении 2FA",
|
||||||
"otpSetupCheckCode": "Пожалуйста, введите 6-значный код",
|
"otpSetupCheckCode": "Пожалуйста, введите 6-значный код",
|
||||||
@@ -1374,6 +1606,8 @@
|
|||||||
"sidebarResources": "Ресурсы",
|
"sidebarResources": "Ресурсы",
|
||||||
"sidebarProxyResources": "Публичный",
|
"sidebarProxyResources": "Публичный",
|
||||||
"sidebarClientResources": "Приватный",
|
"sidebarClientResources": "Приватный",
|
||||||
|
"sidebarPolicies": "Общие политики",
|
||||||
|
"sidebarResourcePolicies": "Публичные ресурсы",
|
||||||
"sidebarAccessControl": "Контроль доступа",
|
"sidebarAccessControl": "Контроль доступа",
|
||||||
"sidebarLogsAndAnalytics": "Журналы и аналитика",
|
"sidebarLogsAndAnalytics": "Журналы и аналитика",
|
||||||
"sidebarTeam": "Команда",
|
"sidebarTeam": "Команда",
|
||||||
@@ -1381,7 +1615,7 @@
|
|||||||
"sidebarAdmin": "Админ",
|
"sidebarAdmin": "Админ",
|
||||||
"sidebarInvitations": "Приглашения",
|
"sidebarInvitations": "Приглашения",
|
||||||
"sidebarRoles": "Роли",
|
"sidebarRoles": "Роли",
|
||||||
"sidebarShareableLinks": "Ссылки",
|
"sidebarShareableLinks": "Общие ссылки",
|
||||||
"sidebarApiKeys": "API ключи",
|
"sidebarApiKeys": "API ключи",
|
||||||
"sidebarProvisioning": "Подготовка",
|
"sidebarProvisioning": "Подготовка",
|
||||||
"sidebarSettings": "Настройки",
|
"sidebarSettings": "Настройки",
|
||||||
@@ -1401,6 +1635,45 @@
|
|||||||
"sidebarManagement": "Управление",
|
"sidebarManagement": "Управление",
|
||||||
"sidebarBillingAndLicenses": "Биллинг и лицензии",
|
"sidebarBillingAndLicenses": "Биллинг и лицензии",
|
||||||
"sidebarLogsAnalytics": "Статистика",
|
"sidebarLogsAnalytics": "Статистика",
|
||||||
|
"commandSites": "Сайты",
|
||||||
|
"commandActionModeInfo": "Введите \">\", чтобы открыть режим действий",
|
||||||
|
"commandResources": "Ресурсы",
|
||||||
|
"commandProxyResources": "Публичные ресурсы",
|
||||||
|
"commandClientResources": "Частные ресурсы",
|
||||||
|
"commandClients": "Клиенты",
|
||||||
|
"commandUserDevices": "Устройства пользователей",
|
||||||
|
"commandMachineClients": "Клиенты машин",
|
||||||
|
"commandDomains": "Домены",
|
||||||
|
"commandRemoteExitNodes": "Удаленные узлы",
|
||||||
|
"commandTeam": "Команда",
|
||||||
|
"commandUsers": "Пользователи",
|
||||||
|
"commandRoles": "Роли",
|
||||||
|
"commandInvitations": "Приглашения",
|
||||||
|
"commandPolicies": "Общие политики",
|
||||||
|
"commandResourcePolicies": "Политики публичных ресурсов",
|
||||||
|
"commandIdentityProviders": "Поставщики удостоверений",
|
||||||
|
"commandApprovals": "Запросы на одобрение",
|
||||||
|
"commandShareableLinks": "Общие ссылки",
|
||||||
|
"commandOrganization": "Организация",
|
||||||
|
"commandLogsAndAnalytics": "Логи и аналитика",
|
||||||
|
"commandLogsAnalytics": "Аналитика",
|
||||||
|
"commandLogsRequest": "HTTP журналы запросов",
|
||||||
|
"commandLogsAccess": "Журналы аутентификации",
|
||||||
|
"commandLogsAction": "Журналы административных действий",
|
||||||
|
"commandLogsConnection": "Журнал сетевых подключений",
|
||||||
|
"commandLogsStreaming": "Трансляция события",
|
||||||
|
"commandManagement": "Управление",
|
||||||
|
"commandAlerting": "Оповещения",
|
||||||
|
"commandProvisioning": "Провиженинг",
|
||||||
|
"commandBluePrints": "Шаблоны",
|
||||||
|
"commandApiKeys": "API ключи",
|
||||||
|
"commandBillingAndLicenses": "Выставление счетов и лицензии",
|
||||||
|
"commandBilling": "Выставление счетов",
|
||||||
|
"commandEnterpriseLicenses": "Лицензии",
|
||||||
|
"commandSettings": "Настройки",
|
||||||
|
"commandLauncher": "Запускатор",
|
||||||
|
"commandResourceLauncher": "Запускатор ресурсов",
|
||||||
|
"commandSearchResults": "Результаты поиска",
|
||||||
"alertingTitle": "Оповещения",
|
"alertingTitle": "Оповещения",
|
||||||
"alertingDescription": "Определите источники, триггеры и действия для уведомлений",
|
"alertingDescription": "Определите источники, триггеры и действия для уведомлений",
|
||||||
"alertingRules": "Правила оповещений",
|
"alertingRules": "Правила оповещений",
|
||||||
@@ -1557,7 +1830,8 @@
|
|||||||
"standaloneHcFilterSiteIdFallback": "Сайт {id}",
|
"standaloneHcFilterSiteIdFallback": "Сайт {id}",
|
||||||
"standaloneHcFilterResourceIdFallback": "Ресурс {id}",
|
"standaloneHcFilterResourceIdFallback": "Ресурс {id}",
|
||||||
"blueprints": "Чертежи",
|
"blueprints": "Чертежи",
|
||||||
"blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски",
|
"blueprintsLog": "Журнал чертежей",
|
||||||
|
"blueprintsDescription": "Просмотреть предыдущие приложения с чертежами и их результаты или применить новый чертеж",
|
||||||
"blueprintAdd": "Добавить чертёж",
|
"blueprintAdd": "Добавить чертёж",
|
||||||
"blueprintGoBack": "Посмотреть все чертежи",
|
"blueprintGoBack": "Посмотреть все чертежи",
|
||||||
"blueprintCreate": "Создать чертёж",
|
"blueprintCreate": "Создать чертёж",
|
||||||
@@ -1575,7 +1849,17 @@
|
|||||||
"contents": "Содержание",
|
"contents": "Содержание",
|
||||||
"parsedContents": "Переработанное содержимое (только для чтения)",
|
"parsedContents": "Переработанное содержимое (только для чтения)",
|
||||||
"enableDockerSocket": "Включить чертёж Docker",
|
"enableDockerSocket": "Включить чертёж Docker",
|
||||||
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
|
"enableDockerSocketDescription": "Включить сбор меток Docker Socket для чертежей. Путь сокета должен быть предоставлен подключателю сайта. Прочтите о том, как это работает, в <docsLink>документации</docsLink>.",
|
||||||
|
"newtAutoUpdate": "Включить автообновление сайта",
|
||||||
|
"newtAutoUpdateDescription": "При включении разъемы сайта автоматически загрузят последнюю версию и перезапустятся. Это можно переопределить на уровне каждого сайта.",
|
||||||
|
"siteAutoUpdate": "Автообновление сайта",
|
||||||
|
"siteAutoUpdateLabel": "Включить автообновление",
|
||||||
|
"siteAutoUpdateDescription": "При включении разъем этого сайта автоматически скачает последнюю версию и перезапустится.",
|
||||||
|
"siteAutoUpdateOrgDefault": "Значение по умолчанию для организации: {state}",
|
||||||
|
"siteAutoUpdateOverriding": "Переопределение настройки организации",
|
||||||
|
"siteAutoUpdateResetToOrg": "Сброс до значения по умолчанию для организации",
|
||||||
|
"siteAutoUpdateEnabled": "включено",
|
||||||
|
"siteAutoUpdateDisabled": "отключено",
|
||||||
"viewDockerContainers": "Просмотр контейнеров Docker",
|
"viewDockerContainers": "Просмотр контейнеров Docker",
|
||||||
"containersIn": "Контейнеры в {siteName}",
|
"containersIn": "Контейнеры в {siteName}",
|
||||||
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
|
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
|
||||||
@@ -1620,6 +1904,7 @@
|
|||||||
"certificateStatus": "Сертификат",
|
"certificateStatus": "Сертификат",
|
||||||
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
"certificateStatusAutoRefreshHint": "Статус обновляется автоматически.",
|
||||||
"loading": "Загрузка",
|
"loading": "Загрузка",
|
||||||
|
"loadingEllipsis": "Загрузка...",
|
||||||
"loadingAnalytics": "Загрузка аналитики",
|
"loadingAnalytics": "Загрузка аналитики",
|
||||||
"restart": "Перезагрузка",
|
"restart": "Перезагрузка",
|
||||||
"domains": "Домены",
|
"domains": "Домены",
|
||||||
@@ -1667,9 +1952,9 @@
|
|||||||
"accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!",
|
"accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!",
|
||||||
"documentation": "Документация",
|
"documentation": "Документация",
|
||||||
"saveAllSettings": "Сохранить все настройки",
|
"saveAllSettings": "Сохранить все настройки",
|
||||||
"saveResourceTargets": "Сохранить цели",
|
"saveResourceTargets": "Сохранить настройки",
|
||||||
"saveResourceHttp": "Сохранить настройки прокси",
|
"saveResourceHttp": "Сохранить настройки",
|
||||||
"saveProxyProtocol": "Сохранить настройки прокси-протокола",
|
"saveProxyProtocol": "Сохранить настройки",
|
||||||
"settingsUpdated": "Настройки обновлены",
|
"settingsUpdated": "Настройки обновлены",
|
||||||
"settingsUpdatedDescription": "Настройки успешно обновлены",
|
"settingsUpdatedDescription": "Настройки успешно обновлены",
|
||||||
"settingsErrorUpdate": "Не удалось обновить настройки",
|
"settingsErrorUpdate": "Не удалось обновить настройки",
|
||||||
@@ -1720,6 +2005,9 @@
|
|||||||
"billingDomains": "Домены",
|
"billingDomains": "Домены",
|
||||||
"billingOrganizations": "Орги",
|
"billingOrganizations": "Орги",
|
||||||
"billingRemoteExitNodes": "Удаленные узлы",
|
"billingRemoteExitNodes": "Удаленные узлы",
|
||||||
|
"billingPublicResources": "Публичные ресурсы",
|
||||||
|
"billingPrivateResources": "Частные ресурсы",
|
||||||
|
"billingMachineClients": "Клиенты машин",
|
||||||
"billingNoLimitConfigured": "Лимит не установлен",
|
"billingNoLimitConfigured": "Лимит не установлен",
|
||||||
"billingEstimatedPeriod": "Предполагаемый период выставления счетов",
|
"billingEstimatedPeriod": "Предполагаемый период выставления счетов",
|
||||||
"billingIncludedUsage": "Включенное использование",
|
"billingIncludedUsage": "Включенное использование",
|
||||||
@@ -1748,6 +2036,9 @@
|
|||||||
"billingUsersInfo": "Сколько пользователей вы можете использовать",
|
"billingUsersInfo": "Сколько пользователей вы можете использовать",
|
||||||
"billingDomainInfo": "Сколько доменов вы можете использовать",
|
"billingDomainInfo": "Сколько доменов вы можете использовать",
|
||||||
"billingRemoteExitNodesInfo": "Сколько удаленных узлов вы можете использовать",
|
"billingRemoteExitNodesInfo": "Сколько удаленных узлов вы можете использовать",
|
||||||
|
"billingPublicResourcesInfo": "Сколько публичных ресурсов вы можете использовать",
|
||||||
|
"billingPrivateResourcesInfo": "Сколько частных ресурсов вы можете использовать",
|
||||||
|
"billingMachineClientsInfo": "Сколько машинных клиентов вы можете использовать",
|
||||||
"billingLicenseKeys": "Лицензионные ключи",
|
"billingLicenseKeys": "Лицензионные ключи",
|
||||||
"billingLicenseKeysDescription": "Управление подписками на лицензионные ключи",
|
"billingLicenseKeysDescription": "Управление подписками на лицензионные ключи",
|
||||||
"billingLicenseSubscription": "Лицензионное соглашение",
|
"billingLicenseSubscription": "Лицензионное соглашение",
|
||||||
@@ -1846,6 +2137,7 @@
|
|||||||
"billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга",
|
"billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга",
|
||||||
"billingCurrentKeys": "Текущие ключи",
|
"billingCurrentKeys": "Текущие ключи",
|
||||||
"billingModifyCurrentPlan": "Изменить текущий план",
|
"billingModifyCurrentPlan": "Изменить текущий план",
|
||||||
|
"billingManageLicenseSubscriptionDescription": "Управление вашей подпиской на платные ключи лицензии для самостоятельной установки и загрузка счетов.",
|
||||||
"billingConfirmUpgrade": "Подтвердить обновление",
|
"billingConfirmUpgrade": "Подтвердить обновление",
|
||||||
"billingConfirmDowngrade": "Подтверждение понижения",
|
"billingConfirmDowngrade": "Подтверждение понижения",
|
||||||
"billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.",
|
"billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.",
|
||||||
@@ -1892,6 +2184,7 @@
|
|||||||
"subnetPlaceholder": "Подсеть",
|
"subnetPlaceholder": "Подсеть",
|
||||||
"addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.",
|
"addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.",
|
||||||
"selectSites": "Выберите сайты",
|
"selectSites": "Выберите сайты",
|
||||||
|
"selectLabels": "Выберите метки",
|
||||||
"sitesDescription": "Клиент будет иметь подключение к выбранным сайтам",
|
"sitesDescription": "Клиент будет иметь подключение к выбранным сайтам",
|
||||||
"clientInstallOlm": "Установить Olm",
|
"clientInstallOlm": "Установить Olm",
|
||||||
"clientInstallOlmDescription": "Запустите Olm на вашей системе",
|
"clientInstallOlmDescription": "Запустите Olm на вашей системе",
|
||||||
@@ -1925,13 +2218,13 @@
|
|||||||
"healthCheckUnknown": "Неизвестно",
|
"healthCheckUnknown": "Неизвестно",
|
||||||
"healthCheck": "Проверка здоровья",
|
"healthCheck": "Проверка здоровья",
|
||||||
"configureHealthCheck": "Настроить проверку здоровья",
|
"configureHealthCheck": "Настроить проверку здоровья",
|
||||||
"configureHealthCheckDescription": "Настройте мониторинг состояния для {target}",
|
"configureHealthCheckDescription": "Настройте мониторинг вашего ресурса, чтобы обеспечить его постоянную доступность",
|
||||||
"enableHealthChecks": "Включить проверки здоровья",
|
"enableHealthChecks": "Включить проверки здоровья",
|
||||||
"healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.",
|
"healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.",
|
||||||
"enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.",
|
"enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.",
|
||||||
"healthScheme": "Метод",
|
"healthScheme": "Метод",
|
||||||
"healthSelectScheme": "Выберите метод",
|
"healthSelectScheme": "Выберите метод",
|
||||||
"healthCheckPortInvalid": "Порт проверки здоровья должен быть от 1 до 65535",
|
"healthCheckPortInvalid": "Порт должен быть в диапазоне от 1 до 65535",
|
||||||
"healthCheckPath": "Путь",
|
"healthCheckPath": "Путь",
|
||||||
"healthHostname": "IP / хост",
|
"healthHostname": "IP / хост",
|
||||||
"healthPort": "Порт",
|
"healthPort": "Порт",
|
||||||
@@ -1943,7 +2236,42 @@
|
|||||||
"timeIsInSeconds": "Время указано в секундах",
|
"timeIsInSeconds": "Время указано в секундах",
|
||||||
"requireDeviceApproval": "Требовать подтверждения устройства",
|
"requireDeviceApproval": "Требовать подтверждения устройства",
|
||||||
"requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.",
|
"requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.",
|
||||||
"sshAccess": "SSH доступ",
|
"sshSettings": "Настройки SSH",
|
||||||
|
"sshAccess": "Доступ по SSH",
|
||||||
|
"rdpSettings": "Настройки RDP",
|
||||||
|
"vncSettings": "Настройки VNC",
|
||||||
|
"sshServer": "SSH сервер",
|
||||||
|
"rdpServer": "RDP сервер",
|
||||||
|
"vncServer": "VNC сервер",
|
||||||
|
"sshServerDescription": "Настройка метода аутентификации, местоположения демона и пункта назначения сервера",
|
||||||
|
"rdpServerDescription": "Настройте пункт назначения и порт RDP-сервера",
|
||||||
|
"vncServerDescription": "Настройте пункт назначения и порт VNC-сервера",
|
||||||
|
"sshServerMode": "Режим",
|
||||||
|
"sshServerModeStandard": "Стандартный SSH-сервер",
|
||||||
|
"sshServerModePangolin": "SSH Pangolin",
|
||||||
|
"sshServerModeStandardDescription": "Маршрутизация команд по сети к SSH-серверу, такому как OpenSSH.",
|
||||||
|
"sshServerModeNative": "Родной SSH-сервер",
|
||||||
|
"sshServerModeNativeDescription": "Выполняет команды напрямую на хосте через сайт-коннектор. Настройка сети не требуется.",
|
||||||
|
"sshAuthenticationMethod": "Метод аутентификации",
|
||||||
|
"sshAuthMethodManual": "Ручная аутентификация",
|
||||||
|
"sshAuthMethodManualDescription": "Требуется наличие существующих учетных данных хоста. Обходит автоматическое предоставление.",
|
||||||
|
"sshAuthMethodAutomated": "Автоматизированное предоставление",
|
||||||
|
"sshAuthMethodAutomatedDescription": "Автоматически создает пользователей, группы и разрешения sudo на хосте.",
|
||||||
|
"sshAuthDaemonLocation": "Местоположение демона аутентификации",
|
||||||
|
"sshDaemonLocationSiteDescription": "Выполняется локально на машине, размещающей сайт-коннектор.",
|
||||||
|
"sshDaemonLocationRemote": "На удаленном хосте",
|
||||||
|
"sshDaemonLocationRemoteDescription": "Выполняется на отдельной целевой машине в той же сети.",
|
||||||
|
"sshDaemonDisclaimer": "Убедитесь, что целевой хост правильно настроен для запуска демона аутентификации перед завершением этой настройки, иначе предоставление не удастся.",
|
||||||
|
"sshDaemonPort": "Порт демона",
|
||||||
|
"sshServerDestination": "Пункт назначения сервера",
|
||||||
|
"sshServerDestinationDescription": "Настройте адрес сервера SSH",
|
||||||
|
"destination": "Пункт назначения",
|
||||||
|
"destinationRequired": "Требуется указание пункта назначения.",
|
||||||
|
"domainRequired": "Требуется домен.",
|
||||||
|
"proxyPortRequired": "Требуется порт.",
|
||||||
|
"invalidPathConfiguration": "Недействительная конфигурация пути.",
|
||||||
|
"invalidRewritePathConfiguration": "Недействительная конфигурация пути переписывания.",
|
||||||
|
"bgTargetMultiSiteDisclaimer": "Выбор нескольких сайтов включает в себя устойчивую маршрутизацию и автоматический отказ для обеспечения высокой доступности.",
|
||||||
"roleAllowSsh": "Разрешить SSH",
|
"roleAllowSsh": "Разрешить SSH",
|
||||||
"roleAllowSshAllow": "Разрешить",
|
"roleAllowSshAllow": "Разрешить",
|
||||||
"roleAllowSshDisallow": "Запретить",
|
"roleAllowSshDisallow": "Запретить",
|
||||||
@@ -1957,10 +2285,25 @@
|
|||||||
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
|
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
|
||||||
"sshSudo": "Разрешить sudo",
|
"sshSudo": "Разрешить sudo",
|
||||||
"sshSudoCommands": "Sudo Команды",
|
"sshSudoCommands": "Sudo Команды",
|
||||||
"sshSudoCommandsDescription": "Список команд, разделенных запятыми, которые пользователю разрешено запускать с помощью sudo.",
|
"sshSudoCommandsDescription": "Список команд, которые пользователь может запускать с sudo, разделенный запятыми, пробелами или новыми строками. Должны использоваться абсолютные пути.",
|
||||||
"sshCreateHomeDir": "Создать домашний каталог",
|
"sshCreateHomeDir": "Создать домашний каталог",
|
||||||
"sshUnixGroups": "Unix группы",
|
"sshUnixGroups": "Unix группы",
|
||||||
"sshUnixGroupsDescription": "Группы Unix через запятую, чтобы добавить пользователя на целевой хост.",
|
"sshUnixGroupsDescription": "Группы Unix, к которым пользователь добавляется на целевом хосте, разделяются запятыми, пробелами или новыми строками.",
|
||||||
|
"roleTextFieldPlaceholder": "Введите значения или перетащите файл .txt или .csv",
|
||||||
|
"roleTextImportTitle": "Импорт из файла",
|
||||||
|
"roleTextImportDescription": "Импортирую {fileName} в {fieldLabel}.",
|
||||||
|
"roleTextImportSkipHeader": "Пропустить первую строку (заголовок)",
|
||||||
|
"roleTextImportOverride": "Заменить существующее",
|
||||||
|
"roleTextImportAppend": "Добавить к существующему",
|
||||||
|
"roleTextImportMode": "Режим импорта",
|
||||||
|
"roleTextImportPreview": "Предпросмотр",
|
||||||
|
"roleTextImportItemCount": "{count, plural, =0 {Нет элементов для импорта} one {# элемент для импорта} few {# элемента для импорта} many {# элементов для импорта} other {# элементов для импорта}}",
|
||||||
|
"roleTextImportTotalCount": "{existing} существующих + {imported} импортированных = {total} всего",
|
||||||
|
"roleTextImportConfirm": "Импортировать",
|
||||||
|
"roleTextImportInvalidFile": "Неподдерживаемый тип файла",
|
||||||
|
"roleTextImportInvalidFileDescription": "Поддерживаются только файлы .txt и .csv.",
|
||||||
|
"roleTextImportEmpty": "Элементы в файле не найдены",
|
||||||
|
"roleTextImportEmptyDescription": "Файл не содержит элементов, которые можно импортировать.",
|
||||||
"retryAttempts": "Количество попыток повторного запроса",
|
"retryAttempts": "Количество попыток повторного запроса",
|
||||||
"expectedResponseCodes": "Ожидаемые коды ответов",
|
"expectedResponseCodes": "Ожидаемые коды ответов",
|
||||||
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
||||||
@@ -2064,11 +2407,19 @@
|
|||||||
"createInternalResourceDialogClose": "Закрыть",
|
"createInternalResourceDialogClose": "Закрыть",
|
||||||
"createInternalResourceDialogCreateClientResource": "Создать приватный ресурс",
|
"createInternalResourceDialogCreateClientResource": "Создать приватный ресурс",
|
||||||
"createInternalResourceDialogCreateClientResourceDescription": "Создать новый ресурс, который будет доступен только клиентам, подключенным к организации",
|
"createInternalResourceDialogCreateClientResourceDescription": "Создать новый ресурс, который будет доступен только клиентам, подключенным к организации",
|
||||||
|
"privateResourceGeneralDescription": "Настройте имя, идентификатор и другие общие параметры ресурса.",
|
||||||
|
"privateResourceCreatePageSeeAll": "Посмотреть все частные ресурсы",
|
||||||
|
"privateResourceAllowIcmpPing": "Разрешить ICMP Ping",
|
||||||
|
"privateResourceNetworkAccess": "Сетевой доступ",
|
||||||
|
"privateResourceNetworkAccessDescription": "Управляйте доступом к портам TCP/UDP и настройте разрешение ICMP ping для данного ресурса.",
|
||||||
|
"hostSettings": "Настройки хоста",
|
||||||
|
"cidrSettings": "Настройки CIDR",
|
||||||
"createInternalResourceDialogResourceProperties": "Свойства ресурса",
|
"createInternalResourceDialogResourceProperties": "Свойства ресурса",
|
||||||
"createInternalResourceDialogName": "Имя",
|
"createInternalResourceDialogName": "Имя",
|
||||||
"createInternalResourceDialogSite": "Сайт",
|
"createInternalResourceDialogSite": "Сайт",
|
||||||
"selectSite": "Выберите сайт...",
|
"selectSite": "Выберите сайт...",
|
||||||
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}",
|
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}",
|
||||||
|
"labelsSelectorLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
|
||||||
"noSitesFound": "Сайты не найдены.",
|
"noSitesFound": "Сайты не найдены.",
|
||||||
"createInternalResourceDialogProtocol": "Протокол",
|
"createInternalResourceDialogProtocol": "Протокол",
|
||||||
"createInternalResourceDialogTcp": "TCP",
|
"createInternalResourceDialogTcp": "TCP",
|
||||||
@@ -2109,6 +2460,7 @@
|
|||||||
"createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.",
|
"createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.",
|
||||||
"createInternalResourceDialogAlias": "Alias",
|
"createInternalResourceDialogAlias": "Alias",
|
||||||
"createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
|
"createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
|
||||||
|
"internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.",
|
||||||
"internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов",
|
"internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов",
|
||||||
"internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов",
|
"internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов",
|
||||||
"siteConfiguration": "Конфигурация",
|
"siteConfiguration": "Конфигурация",
|
||||||
@@ -2142,6 +2494,21 @@
|
|||||||
"sidebarRemoteExitNodes": "Удаленные узлы",
|
"sidebarRemoteExitNodes": "Удаленные узлы",
|
||||||
"remoteExitNodeId": "ID",
|
"remoteExitNodeId": "ID",
|
||||||
"remoteExitNodeSecretKey": "Секретный ключ",
|
"remoteExitNodeSecretKey": "Секретный ключ",
|
||||||
|
"remoteExitNodeNetworkingTitle": "Настройки сети",
|
||||||
|
"remoteExitNodeNetworkingDescription": "Настройте, как этот удаленный узел выхода маршрутизирует трафик и какие сайты предпочитают подключаться через него. Расширенные функции для использования с конфигурациями магистральной сети.",
|
||||||
|
"remoteExitNodeNetworkingSave": "Сохранить настройки",
|
||||||
|
"remoteExitNodeNetworkingSaveSuccessTitle": "Сетевые настройки сохранены",
|
||||||
|
"remoteExitNodeNetworkingSaveSuccessDescription": "Сетевые настройки были успешно обновлены.",
|
||||||
|
"remoteExitNodeNetworkingSaveError": "Не удалось сохранить сетевые настройки",
|
||||||
|
"remoteExitNodeNetworkingSubnetsTitle": "Удалённые подсети",
|
||||||
|
"remoteExitNodeNetworkingSubnetsDescription": "Определите диапазоны CIDR, которые этот удаленный узел выхода будет использовать для маршрутизации трафика. Введите действительный CIDR (например, <code>10.0.0.0/8</code>) и нажмите Enter, чтобы добавить.",
|
||||||
|
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
|
||||||
|
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
|
||||||
|
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
|
||||||
|
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
|
||||||
|
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
|
||||||
|
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
|
||||||
|
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
|
||||||
"remoteExitNodeCreate": {
|
"remoteExitNodeCreate": {
|
||||||
"title": "Создать удалённый узел",
|
"title": "Создать удалённый узел",
|
||||||
"description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера",
|
"description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера",
|
||||||
@@ -2195,6 +2562,7 @@
|
|||||||
"noRemoteExitNodesAvailableDescription": "Для этой организации узлы не доступны. Сначала создайте узел, чтобы использовать локальные сайты.",
|
"noRemoteExitNodesAvailableDescription": "Для этой организации узлы не доступны. Сначала создайте узел, чтобы использовать локальные сайты.",
|
||||||
"exitNode": "Узел выхода",
|
"exitNode": "Узел выхода",
|
||||||
"country": "Страна",
|
"country": "Страна",
|
||||||
|
"countryIsNot": "Страна не является",
|
||||||
"rulesMatchCountry": "В настоящее время основано на исходном IP",
|
"rulesMatchCountry": "В настоящее время основано на исходном IP",
|
||||||
"region": "Регион",
|
"region": "Регион",
|
||||||
"selectRegion": "Выберите регион",
|
"selectRegion": "Выберите регион",
|
||||||
@@ -2235,7 +2603,7 @@
|
|||||||
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
"description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками",
|
||||||
"introTitle": "Управляемый Само-Хост Панголина",
|
"introTitle": "Управляемый Само-Хост Панголина",
|
||||||
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
"introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.",
|
||||||
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - туннели, TLS, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:",
|
"introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin - ваши туннели, завершение TLS и трафик остаются на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через наш облачный интерфейс, что открывает ряд преимуществ:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "Более простые операции",
|
"title": "Более простые операции",
|
||||||
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
"description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки."
|
||||||
@@ -2320,6 +2688,7 @@
|
|||||||
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
|
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
|
||||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||||
"subnet": "Подсеть",
|
"subnet": "Подсеть",
|
||||||
|
"utilitySubnet": "Утилита подсети",
|
||||||
"subnetDescription": "Подсеть для конфигурации сети этой организации.",
|
"subnetDescription": "Подсеть для конфигурации сети этой организации.",
|
||||||
"customDomain": "Пользовательский домен",
|
"customDomain": "Пользовательский домен",
|
||||||
"authPage": "Страницы аутентификации",
|
"authPage": "Страницы аутентификации",
|
||||||
@@ -2403,6 +2772,9 @@
|
|||||||
"twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.",
|
"twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.",
|
||||||
"additionalSecurityRequired": "Требуется дополнительная безопасность",
|
"additionalSecurityRequired": "Требуется дополнительная безопасность",
|
||||||
"organizationRequiresAdditionalSteps": "Эта организация требует дополнительных шагов безопасности, прежде чем вы сможете получить доступ к ресурсам.",
|
"organizationRequiresAdditionalSteps": "Эта организация требует дополнительных шагов безопасности, прежде чем вы сможете получить доступ к ресурсам.",
|
||||||
|
"sessionExpired": "Сессия истекла",
|
||||||
|
"sessionExpiredReauthRequired": "Ваша сессия истекла согласно политике безопасности вашей организации. Пожалуйста, повторно пройдите аутентификацию, чтобы продолжить.",
|
||||||
|
"reauthenticate": "Повторная аутентификация",
|
||||||
"completeTheseSteps": "Выполните эти шаги",
|
"completeTheseSteps": "Выполните эти шаги",
|
||||||
"enableTwoFactorAuthentication": "Включить двухфакторную аутентификацию",
|
"enableTwoFactorAuthentication": "Включить двухфакторную аутентификацию",
|
||||||
"completeSecuritySteps": "Пройти шаги безопасности",
|
"completeSecuritySteps": "Пройти шаги безопасности",
|
||||||
@@ -2738,15 +3110,17 @@
|
|||||||
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
|
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
|
||||||
"loadingDNSRecords": "Загрузка записей DNS...",
|
"loadingDNSRecords": "Загрузка записей DNS...",
|
||||||
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
|
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
|
||||||
|
"updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.",
|
||||||
"client": "Клиент",
|
"client": "Клиент",
|
||||||
"proxyProtocol": "Настройки протокола прокси",
|
"proxyProtocol": "Настройки протокола прокси",
|
||||||
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
|
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
|
||||||
"enableProxyProtocol": "Включить Прокси Протокол",
|
"enableProxyProtocol": "Включить Прокси Протокол",
|
||||||
"proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP",
|
"proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP",
|
||||||
"proxyProtocolVersion": "Версия протокола прокси",
|
"proxyProtocolVersion": "Версия протокола прокси",
|
||||||
"version1": " Версия 1 (рекомендуется)",
|
"version1": "Версия 1 (рекомендуется)",
|
||||||
"version2": "Версия 2",
|
"version2": "Версия 2",
|
||||||
"versionDescription": "Версия 1 основана на тексте и широко поддерживается. Версия 2 является бинарной и более эффективной, но менее совместимой.",
|
"version1Description": "Основано на тексте и широко поддерживается. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.",
|
||||||
|
"version2Description": "Бинарная и более эффективная, но менее совместимая. Убедитесь, что транспорт сервера добавлен в динамическую конфигурацию.",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.",
|
"proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.",
|
||||||
"restarting": "Перезапуск...",
|
"restarting": "Перезапуск...",
|
||||||
@@ -2903,14 +3277,14 @@
|
|||||||
"enterConfirmation": "Введите подтверждение",
|
"enterConfirmation": "Введите подтверждение",
|
||||||
"blueprintViewDetails": "Подробности",
|
"blueprintViewDetails": "Подробности",
|
||||||
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
|
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
|
||||||
"defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.",
|
"defaultIdentityProviderDescription": "Пользователь будет автоматически перенаправлен к этому поставщику удостоверений для аутентификации.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Настройки сети",
|
"editInternalResourceDialogNetworkSettings": "Настройки сети",
|
||||||
"editInternalResourceDialogAccessPolicy": "Политика доступа",
|
"editInternalResourceDialogAccessPolicy": "Политика доступа",
|
||||||
"editInternalResourceDialogAddRoles": "Добавить роли",
|
"editInternalResourceDialogAddRoles": "Добавить роли",
|
||||||
"editInternalResourceDialogAddUsers": "Добавить пользователей",
|
"editInternalResourceDialogAddUsers": "Добавить пользователей",
|
||||||
"editInternalResourceDialogAddClients": "Добавить клиентов",
|
"editInternalResourceDialogAddClients": "Добавить клиентов",
|
||||||
"editInternalResourceDialogDestinationLabel": "Пункт назначения",
|
"editInternalResourceDialogDestinationLabel": "Пункт назначения",
|
||||||
"editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.",
|
"editInternalResourceDialogDestinationDescription": "Настройте, как клиенты получают доступ к этому ресурсу.",
|
||||||
"internalResourceFormMultiSiteRoutingHelp": "Выбор нескольких сайтов позволяет обеспечить отказоустойчивую маршрутизацию и фейловер для высокой доступности.",
|
"internalResourceFormMultiSiteRoutingHelp": "Выбор нескольких сайтов позволяет обеспечить отказоустойчивую маршрутизацию и фейловер для высокой доступности.",
|
||||||
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Узнать больше",
|
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Узнать больше",
|
||||||
"editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.",
|
"editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.",
|
||||||
@@ -2939,11 +3313,12 @@
|
|||||||
"learnMore": "Узнать больше",
|
"learnMore": "Узнать больше",
|
||||||
"backToHome": "Вернуться домой",
|
"backToHome": "Вернуться домой",
|
||||||
"needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?",
|
"needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?",
|
||||||
"maintenanceMode": "Режим обслуживания",
|
"maintenanceMode": "Страница обслуживания",
|
||||||
"maintenanceModeDescription": "Показать страницу обслуживания посетителям",
|
"maintenanceModeDescription": "Показать страницу обслуживания посетителям",
|
||||||
"maintenanceModeType": "Тип режима обслуживания",
|
"maintenanceModeType": "Тип режима обслуживания",
|
||||||
"showMaintenancePage": "Показать страницу обслуживания посетителям",
|
"showMaintenancePage": "Показать страницу обслуживания посетителям",
|
||||||
"enableMaintenanceMode": "Включить режим обслуживания",
|
"enableMaintenanceMode": "Включить режим обслуживания",
|
||||||
|
"enableMaintenanceModeDescription": "Когда включено, посетители увидят страницу обслуживания вместо вашего ресурса.",
|
||||||
"automatic": "Автоматический",
|
"automatic": "Автоматический",
|
||||||
"automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.",
|
"automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.",
|
||||||
"forced": "Принудительно",
|
"forced": "Принудительно",
|
||||||
@@ -2951,6 +3326,8 @@
|
|||||||
"warning:": "Предупреждение:",
|
"warning:": "Предупреждение:",
|
||||||
"forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.",
|
"forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.",
|
||||||
"pageTitle": "Заголовок страницы",
|
"pageTitle": "Заголовок страницы",
|
||||||
|
"maintenancePageContentSubsection": "Содержимое страницы",
|
||||||
|
"maintenancePageContentSubsectionDescription": "Настройте содержимое, отображаемое на странице обслуживания",
|
||||||
"pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания",
|
"pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания",
|
||||||
"maintenancePageMessage": "Сообщение об обслуживании",
|
"maintenancePageMessage": "Сообщение об обслуживании",
|
||||||
"maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.",
|
"maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.",
|
||||||
@@ -2969,6 +3346,7 @@
|
|||||||
"maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:",
|
"maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:",
|
||||||
"createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.",
|
"createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.",
|
||||||
"available": "Доступно",
|
"available": "Доступно",
|
||||||
|
"disabledResourceDescription": "Когда отключено, ресурс будет недоступен для всех.",
|
||||||
"archived": "Архивировано",
|
"archived": "Архивировано",
|
||||||
"noArchivedDevices": "Архивные устройства не найдены",
|
"noArchivedDevices": "Архивные устройства не найдены",
|
||||||
"deviceArchived": "Устройство архивировано",
|
"deviceArchived": "Устройство архивировано",
|
||||||
@@ -3214,6 +3592,8 @@
|
|||||||
"idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?",
|
"idpUnassociateQuestion": "Вы уверены, что хотите рассоединить этого поставщика удостоверений с этой организацией?",
|
||||||
"idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.",
|
"idpUnassociateDescription": "Все пользователи, связанные с этим поставщиком удостоверений, будут удалены из этой организации, но поставщик удостоверений будет продолжать существовать для других связанных организаций.",
|
||||||
"idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений",
|
"idpUnassociateConfirm": "Подтвердите рассоединение поставщика удостоверений",
|
||||||
|
"idpConfirmDeleteAndRemoveMeFromOrg": "УДАЛИТЬ И ИЗВЛЕЧЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ",
|
||||||
|
"idpUnassociateAndRemoveMeFromOrg": "РАЗОРВАТЬ СВЯЗЬ И УДАЛИТЬ МЕНЯ ИЗ ОРГАНИЗАЦИИ",
|
||||||
"idpUnassociateWarning": "Это не может быть отменено для этой организации.",
|
"idpUnassociateWarning": "Это не может быть отменено для этой организации.",
|
||||||
"idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией",
|
"idpUnassociatedDescription": "Поставщик удостоверений успешно рассоединен с этой организацией",
|
||||||
"idpUnassociateMenu": "Рассоединить",
|
"idpUnassociateMenu": "Рассоединить",
|
||||||
@@ -3297,6 +3677,144 @@
|
|||||||
"memberPortalEmailWhitelist": "Белый список email",
|
"memberPortalEmailWhitelist": "Белый список email",
|
||||||
"memberPortalResourceDisabled": "Ресурс отключён",
|
"memberPortalResourceDisabled": "Ресурс отключён",
|
||||||
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
|
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
|
||||||
|
"resourceLauncherTitle": "Запуск ресурса",
|
||||||
|
"resourceSidebarLauncherTitle": "Запускатор",
|
||||||
|
"resourceLauncherDescription": "Просмотрите все доступные ресурсы и запустите их из одного централизованного узла",
|
||||||
|
"resourceLauncherSearchPlaceholder": "Поиск ваших ресурсов...",
|
||||||
|
"resourceLauncherDefaultView": "По умолчанию",
|
||||||
|
"resourceLauncherSaveView": "Сохранить вид",
|
||||||
|
"resourceLauncherSaveToCurrentView": "Сохранить в текущий вид",
|
||||||
|
"resourceLauncherSaveDefaultPersonal": "Сохранить для меня",
|
||||||
|
"resourceLauncherResetView": "Сбросить вид",
|
||||||
|
"resourceLauncherResetSystemDefault": "Сбросить на системные настройки по умолчанию",
|
||||||
|
"resourceLauncherSystemDefaultRestored": "Системные настройки по умолчанию восстановлены",
|
||||||
|
"resourceLauncherSystemDefaultRestoredDescription": "Вид по умолчанию был сброшен до исходных настроек.",
|
||||||
|
"resourceLauncherSaveAsNewView": "Сохранить как новый вид",
|
||||||
|
"resourceLauncherSaveAsNewViewDescription": "Дайте этому виду имя, чтобы сохранить текущие фильтры и макет.",
|
||||||
|
"resourceLauncherSaveForEveryone": "Сохранить для всех",
|
||||||
|
"resourceLauncherSaveForEveryoneDescription": "Поделитесь этим видом со всеми членами организации. Если не отмечено, видимость только для вас.",
|
||||||
|
"resourceLauncherMakePersonal": "Сделать личным",
|
||||||
|
"resourceLauncherFilter": "Фильтр",
|
||||||
|
"resourceLauncherFilterWithCount": "Фильтр, применено {count}",
|
||||||
|
"resourceLauncherSort": "Сортировать",
|
||||||
|
"resourceLauncherSortAscending": "Сортировать по возрастанию",
|
||||||
|
"resourceLauncherSortDescending": "Сортировать по убыванию",
|
||||||
|
"resourceLauncherSettings": "Настройки",
|
||||||
|
"resourceLauncherGroupBy": "Группировать по",
|
||||||
|
"resourceLauncherGroupBySite": "Сайт",
|
||||||
|
"resourceLauncherGroupByLabel": "Метка",
|
||||||
|
"resourceLauncherGroupByNone": "Нет",
|
||||||
|
"resourceLauncherLayout": "Макет",
|
||||||
|
"resourceLauncherLayoutGrid": "Сетка",
|
||||||
|
"resourceLauncherLayoutList": "Список",
|
||||||
|
"resourceLauncherShowLabels": "Показать метки",
|
||||||
|
"resourceLauncherShowSiteTags": "Показать теги сайта",
|
||||||
|
"resourceLauncherShowRecents": "Показать недавно",
|
||||||
|
"resourceLauncherDeleteView": "Удалить вид",
|
||||||
|
"resourceLauncherDeleteViewTitle": "Удалить вид",
|
||||||
|
"resourceLauncherDeleteViewQuestion": "Вы уверены, что хотите удалить этот вид запускатора?",
|
||||||
|
"resourceLauncherDeleteViewConfirm": "Удалить вид",
|
||||||
|
"resourceLauncherViewAsAdmin": "Просмотр как администратор",
|
||||||
|
"resourceLauncherResourceDetailsDescription": "Информация о подключении и статус для данного ресурса.",
|
||||||
|
"resourceLauncherResourceDetails": "Детали ресурса",
|
||||||
|
"resourceLauncherAuthMethodsDescription": "Методы аутентификации, включенные для этого ресурса.",
|
||||||
|
"resourceLauncherPrivateClientRequired": "Подключитесь с клиентом на устройстве для доступа к этому ресурсу в частном порядке.",
|
||||||
|
"resourceLauncherPrivateClientRequiredTitle": "Требуется подключение клиента",
|
||||||
|
"resourceLauncherDownloadClient": "Скачать клиент",
|
||||||
|
"resourceLauncherFailedToLoadDetails": "Не удалось загрузить детали ресурса. Возможно, у вас больше нет доступа к этому ресурсу.",
|
||||||
|
"resourceLauncherNoPortRestrictions": "Нет ограничений портов",
|
||||||
|
"resourceLauncherTcp": "TCP",
|
||||||
|
"resourceLauncherUdp": "UDP",
|
||||||
|
"resourceLauncherUnlabeled": "Без меток",
|
||||||
|
"resourceLauncherNoSite": "Без сайта",
|
||||||
|
"resourceLauncherNoResourcesInGroup": "Нет ресурсов в данной группе",
|
||||||
|
"resourceLauncherEmptyStateTitle": "Нет доступных ресурсов",
|
||||||
|
"resourceLauncherEmptyStateDescription": "У вас пока нет доступа ни к одному ресурсу. Обратитесь к администратору, чтобы запросить доступ.",
|
||||||
|
"resourceLauncherEmptyStateNoResultsTitle": "Ресурсы не найдены",
|
||||||
|
"resourceLauncherEmptyStateNoResultsDescription": "Ни один ресурс не соответствует вашему текущему поисковому запросу или фильтрам. Попробуйте их изменить, чтобы найти нужное.",
|
||||||
|
"resourceLauncherEmptyStateNoResultsWithQuery": "Ни один ресурс не соответствует \"{query}\". Попробуйте изменить параметры поиска или очистить фильтры, чтобы увидеть все ресурсы.",
|
||||||
|
"resourceLauncherSearchFirstTitle": "Поиск или фильтр для просмотра",
|
||||||
|
"resourceLauncherSearchFirstDescription": "У вас есть доступ ко многим ресурсам. Используйте поиск или фильтр по сайту или метке, чтобы найти, что вам нужно.",
|
||||||
|
"resourceLauncherSiteGroupingDisabled": "Группировка по сайту недоступна в этом масштабе. Отфильтруйте по сайту, чтобы сгруппировать меньший набор.",
|
||||||
|
"resourceLauncherLabelGroupingDisabled": "Группировка по меткам недоступна в этом масштабе.",
|
||||||
|
"resourceLauncherCompactModeHint": "Показывается упрощённый список для более быстрого просмотра. Используйте поиск или фильтры, чтобы сузить результаты.",
|
||||||
|
"resourceLauncherCompactGroupingHint": "Примените фильтры сайта или меток, чтобы включить группировку.",
|
||||||
|
"resourceLauncherCopiedToClipboard": "Скопировано в буфер обмена",
|
||||||
|
"resourceLauncherCopiedAccessDescription": "Доступ к ресурсу был скопирован в ваш буфер обмена.",
|
||||||
|
"resourceLauncherViewNamePlaceholder": "Имя вида",
|
||||||
|
"resourceLauncherViewNameLabel": "Имя вида",
|
||||||
|
"resourceLauncherViewSaved": "Вид сохранён",
|
||||||
|
"resourceLauncherViewSavedDescription": "Ваш вид запуска был сохранён.",
|
||||||
|
"resourceLauncherViewSaveFailed": "Не удалось сохранить вид",
|
||||||
|
"resourceLauncherViewSaveFailedDescription": "Не удалось сохранить вид. Пожалуйста, попробуйте еще раз.",
|
||||||
|
"resourceLauncherViewDeleted": "Вид удалён",
|
||||||
|
"resourceLauncherViewDeletedDescription": "Вид запуска был удалён.",
|
||||||
|
"resourceLauncherViewDeleteFailed": "Не удалось удалить вид",
|
||||||
|
"resourceLauncherViewDeleteFailedDescription": "Не удалось удалить вид. Пожалуйста, попробуйте еще раз.",
|
||||||
"memberPortalPrevious": "Предыдущий",
|
"memberPortalPrevious": "Предыдущий",
|
||||||
"memberPortalNext": "Следующий"
|
"memberPortalNext": "Следующий",
|
||||||
|
"httpSettings": "Настройки HTTP",
|
||||||
|
"tcpSettings": "Настройки TCP",
|
||||||
|
"udpSettings": "Настройки UDP",
|
||||||
|
"sshTitle": "SSH",
|
||||||
|
"sshConnectingDescription": "Установление защищенного соединения…",
|
||||||
|
"sshConnecting": "Подключение…",
|
||||||
|
"sshInitializing": "Инициализация…",
|
||||||
|
"sshSignInTitle": "Вход в SSH",
|
||||||
|
"sshSignInDescription": "Введите свои учетные данные SSH для подключения",
|
||||||
|
"sshPasswordTab": "Пароль",
|
||||||
|
"sshPrivateKeyTab": "Закрытый ключ",
|
||||||
|
"sshPrivateKeyField": "Закрытый ключ",
|
||||||
|
"sshPrivateKeyDisclaimer": "Ваш закрытый ключ не хранится и не виден для Pangolin. Вместо этого вы можете использовать краткосрочные сертификаты для бесшовной аутентификации с использованием вашей текущей идентификации Pangolin.",
|
||||||
|
"sshLearnMore": "Узнать больше",
|
||||||
|
"sshPrivateKeyFile": "Файл закрытого ключа",
|
||||||
|
"sshAuthenticate": "Подключиться",
|
||||||
|
"sshTerminate": "Завершить",
|
||||||
|
"sshPoweredBy": "Разработано",
|
||||||
|
"sshErrorNoTarget": "Цель не указана",
|
||||||
|
"sshErrorWebSocket": "Подключение WebSocket не удалось",
|
||||||
|
"sshErrorAuthFailed": "Ошибка аутентификации",
|
||||||
|
"sshErrorConnectionClosed": "Подключение закрыто до завершения аутентификации",
|
||||||
|
"sitePangolinSshDescription": "Разрешить доступ по SSH к ресурсам на этом сайте. Это можно изменить позже.",
|
||||||
|
"browserGatewayNoResourceForDomain": "Ресурс для этого домена не найден",
|
||||||
|
"browserGatewayNoTarget": "Нет цели",
|
||||||
|
"browserGatewayConnect": "Подключиться",
|
||||||
|
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
|
||||||
|
"sshErrorSignKeyFailed": "Не удалось подписать ключ SSH для аутентификации через PAM push. Проверьте, вошли ли вы как пользователь?",
|
||||||
|
"sshTerminalError": "Ошибка: {error}",
|
||||||
|
"sshConnectionClosedCode": "Соединение закрыто (код {code})",
|
||||||
|
"sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----",
|
||||||
|
"sshPrivateKeyRequired": "Требуется личный ключ",
|
||||||
|
"vncTitle": "VNC",
|
||||||
|
"vncSignInDescription": "Введите ваши учетные данные VNC для подключения",
|
||||||
|
"vncUsernameOptional": "Имя пользователя (необязательно)",
|
||||||
|
"vncPasswordOptional": "Пароль (необязательно)",
|
||||||
|
"vncNoResourceTarget": "Отсутствует целевой ресурс",
|
||||||
|
"vncFailedToLoadNovnc": "Не удалось загрузить noVNC",
|
||||||
|
"vncAuthFailedStatus": "Статус {status}",
|
||||||
|
"vncPasteClipboard": "Вставить из буфера обмена",
|
||||||
|
"rdpTitle": "RDP",
|
||||||
|
"rdpSignInTitle": "Вход в удаленный рабочий стол",
|
||||||
|
"rdpSignInDescription": "Введите учетные данные Windows для подключения",
|
||||||
|
"rdpLoadingModule": "Загрузка модуля...",
|
||||||
|
"rdpFailedToLoadModule": "Не удалось загрузить модуль RDP",
|
||||||
|
"rdpNotReady": "Не готово",
|
||||||
|
"rdpModuleInitializing": "Модуль RDP все еще инициализируется",
|
||||||
|
"rdpDownloadingFiles": "Загрузка {count} файлов с удалённого сервера…",
|
||||||
|
"rdpDownloadFailed": "Ошибка загрузки: {fileName}",
|
||||||
|
"rdpUploaded": "Загружено: {fileName}",
|
||||||
|
"rdpNoConnectionTarget": "Доступная цель подключения отсутствует",
|
||||||
|
"rdpConnectionFailed": "Ошибка соединения",
|
||||||
|
"rdpFit": "Подгонка",
|
||||||
|
"rdpFull": "Полный",
|
||||||
|
"rdpReal": "Настоящий",
|
||||||
|
"rdpMeta": "Метаданные",
|
||||||
|
"rdpUploadFiles": "Загрузить файлы",
|
||||||
|
"rdpFilesReadyToPaste": "Файлы готовы к вставке",
|
||||||
|
"rdpFilesReadyToPasteDescription": "{count, plural, one {# файл скопирован в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} few {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} many {# файлов скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.} other {# файла скопированы в удалённый буфер обмена — нажмите Ctrl+V на удалённом рабочем столе, чтобы вставить.}}",
|
||||||
|
"rdpUploadFailed": "Ошибка загрузки",
|
||||||
|
"rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode",
|
||||||
|
"sessionToolbarShow": "Показать панель инструментов",
|
||||||
|
"sessionToolbarHide": "Скрыть панель инструментов",
|
||||||
|
"actionUpdateSiteApprovals": "Обновить утверждения сайта"
|
||||||
}
|
}
|
||||||
|
|||||||
+566
-48
File diff suppressed because it is too large
Load Diff
+607
-89
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -152,8 +152,8 @@
|
|||||||
"shareErrorSelectResource": "請選擇一個資源",
|
"shareErrorSelectResource": "請選擇一個資源",
|
||||||
"proxyResourceTitle": "管理公開資源",
|
"proxyResourceTitle": "管理公開資源",
|
||||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||||
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
||||||
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||||
"clientResourceTitle": "管理私有資源",
|
"clientResourceTitle": "管理私有資源",
|
||||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||||
"privateResourcesBannerTitle": "零信任私有存取",
|
"privateResourcesBannerTitle": "零信任私有存取",
|
||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"actionGenerateAccessToken": "生成訪問令牌",
|
"actionGenerateAccessToken": "生成訪問令牌",
|
||||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||||
"actionListAccessTokens": "訪問令牌",
|
"actionListAccessTokens": "訪問令牌",
|
||||||
|
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
||||||
"actionCreateResourceRule": "創建資源規則",
|
"actionCreateResourceRule": "創建資源規則",
|
||||||
"actionDeleteResourceRule": "刪除資源規則",
|
"actionDeleteResourceRule": "刪除資源規則",
|
||||||
"actionListResourceRules": "列出資源規則",
|
"actionListResourceRules": "列出資源規則",
|
||||||
|
|||||||
+31
-7
@@ -1,18 +1,42 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
// read allowedDevOrigins.json if it exists
|
||||||
|
let allowedDevOrigins: string[] = [];
|
||||||
|
const allowedDevOriginsPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"allowedDevOrigins.json"
|
||||||
|
);
|
||||||
|
if (fs.existsSync(allowedDevOriginsPath)) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
|
||||||
|
allowedDevOrigins = JSON.parse(data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
|
reactCompiler: true,
|
||||||
transpilePackages: ["@novnc/novnc"],
|
transpilePackages: ["@novnc/novnc"],
|
||||||
eslint: {
|
output: "standalone",
|
||||||
ignoreDuringBuilds: true
|
allowedDevOrigins,
|
||||||
},
|
async redirects() {
|
||||||
experimental: {
|
return [
|
||||||
reactCompiler: true
|
{
|
||||||
},
|
source: "/:orgId/settings/resources/proxy/:path*",
|
||||||
output: "standalone"
|
destination: "/:orgId/settings/resources/public/:path*",
|
||||||
|
permanent: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:orgId/settings/resources/client/:path*",
|
||||||
|
destination: "/:orgId/settings/resources/private/:path*",
|
||||||
|
permanent: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
Generated
+2234
-3568
File diff suppressed because it is too large
Load Diff
+59
-59
@@ -32,13 +32,12 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
"@novnc/novnc": "^1.7.0",
|
"@novnc/novnc": "^1.7.0",
|
||||||
@@ -62,19 +61,20 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -86,77 +86,76 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.2.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.11.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.2.0",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "1.17.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.2.6",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.13.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "9.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.21.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.35.6",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.76.1",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"semver": "7.8.1",
|
||||||
"semver": "7.7.4",
|
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "22.2.0",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.21.0",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.69.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.5.0",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.9.1",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sshpk": "1.17.4",
|
"@types/sshpk": "1.17.4",
|
||||||
@@ -166,21 +165,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.4.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.15",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.5.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.60.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.1",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 556 KiB |
+38
-15
@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export enum ActionsEnum {
|
export enum ActionsEnum {
|
||||||
createOrgUser = "createOrgUser",
|
createOrgUser = "createOrgUser",
|
||||||
@@ -20,6 +21,8 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
|
updateSiteApprovals = "updateSiteApprovals",
|
||||||
|
restartSite = "restartSite",
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
@@ -69,6 +72,7 @@ export enum ActionsEnum {
|
|||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
getResourceWhitelist = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
generateAccessToken = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
|
createResourceSessionToken = "createResourceSessionToken",
|
||||||
deleteAcessToken = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
listAccessTokens = "listAccessTokens",
|
listAccessTokens = "listAccessTokens",
|
||||||
createResourceRule = "createResourceRule",
|
createResourceRule = "createResourceRule",
|
||||||
@@ -163,7 +167,22 @@ export enum ActionsEnum {
|
|||||||
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||||
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||||
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||||
listBrowserGatewayTargets = "listBrowserGatewayTargets"
|
listBrowserGatewayTargets = "listBrowserGatewayTargets",
|
||||||
|
listResourcePolicies = "listResourcePolicies",
|
||||||
|
getResourcePolicy = "getResourcePolicy",
|
||||||
|
createResourcePolicy = "createResourcePolicy",
|
||||||
|
updateResourcePolicy = "updateResourcePolicy",
|
||||||
|
deleteResourcePolicy = "deleteResourcePolicy",
|
||||||
|
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||||
|
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||||
|
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||||
|
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||||
|
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||||
|
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||||
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
|
setResourcePolicyRules = "setResourcePolicyRules",
|
||||||
|
createOrgWideLauncherView = "createOrgWideLauncherView"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
@@ -196,6 +215,23 @@ export async function checkUserActionPermission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no direct permission, check role-based permission (any of user's roles)
|
||||||
|
const roleActionPermission = await db
|
||||||
|
.select()
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.actionId, actionId),
|
||||||
|
inArray(roleActions.roleId, userOrgRoleIds),
|
||||||
|
eq(roleActions.orgId, req.userOrgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleActionPermission.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user has direct permission for the action in the current org
|
// Check if the user has direct permission for the action in the current org
|
||||||
const userActionPermission = await db
|
const userActionPermission = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -213,20 +249,7 @@ export async function checkUserActionPermission(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no direct permission, check role-based permission (any of user's roles)
|
return false;
|
||||||
const roleActionPermission = await db
|
|
||||||
.select()
|
|
||||||
.from(roleActions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleActions.actionId, actionId),
|
|
||||||
inArray(roleActions.roleId, userOrgRoleIds),
|
|
||||||
eq(roleActions.orgId, req.userOrgId!)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return roleActionPermission.length > 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking user action permission:", error);
|
console.error("Error checking user action permission:", error);
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { roleResources, userResources } from "@server/db";
|
import {
|
||||||
|
rolePolicies,
|
||||||
|
roleResources,
|
||||||
|
resources,
|
||||||
|
userPolicies,
|
||||||
|
userResources
|
||||||
|
} from "@server/db";
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
@@ -11,9 +17,14 @@ export async function canUserAccessResource({
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const roleResourceAccess =
|
const [
|
||||||
|
roleResourceAccess,
|
||||||
|
rolePolicyAccess,
|
||||||
|
userResourceAccess,
|
||||||
|
userPolicyAccess
|
||||||
|
] = await Promise.all([
|
||||||
roleIds.length > 0
|
roleIds.length > 0
|
||||||
? await db
|
? db
|
||||||
.select()
|
.select()
|
||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(
|
.where(
|
||||||
@@ -23,26 +34,87 @@ export async function canUserAccessResource({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: [];
|
: [],
|
||||||
|
roleIds.length > 0
|
||||||
if (roleResourceAccess.length > 0) {
|
? db
|
||||||
return true;
|
.select({
|
||||||
}
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
const userResourceAccess = await db
|
})
|
||||||
.select()
|
.from(rolePolicies)
|
||||||
.from(userResources)
|
.innerJoin(
|
||||||
.where(
|
resources,
|
||||||
and(
|
// Shared policy wins; only use default policy when no shared
|
||||||
eq(userResources.userId, userId),
|
// policy is assigned to the resource.
|
||||||
eq(userResources.resourceId, resourceId)
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: [],
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
userId: userPolicies.userId,
|
||||||
|
resourcePolicyId: userPolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
if (userResourceAccess.length > 0) {
|
return (
|
||||||
return true;
|
roleResourceAccess.length > 0 ||
|
||||||
}
|
rolePolicyAccess.length > 0 ||
|
||||||
|
userResourceAccess.length > 0 ||
|
||||||
return false;
|
userPolicyAccess.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
@@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function invalidateAllSessionsExceptCurrent(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const userSessions = await trx
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
ne(sessions.sessionId, currentSessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userSessions.length > 0) {
|
||||||
|
await trx.delete(resourceSessions).where(
|
||||||
|
inArray(
|
||||||
|
resourceSessions.userSessionId,
|
||||||
|
userSessions.map((s) => s.sessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(sessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
ne(sessions.sessionId, currentSessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to invalidate user sessions except current", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
token: string,
|
token: string,
|
||||||
isSecure: boolean,
|
isSecure: boolean,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export async function createResourceSession(opts: {
|
|||||||
userSessionId?: string | null;
|
userSessionId?: string | null;
|
||||||
whitelistId?: number | null;
|
whitelistId?: number | null;
|
||||||
accessTokenId?: string | null;
|
accessTokenId?: string | null;
|
||||||
|
policyPasswordId?: number | null;
|
||||||
|
policyPincodeId?: number | null;
|
||||||
|
policyWhitelistId?: number | null;
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength?: number | null;
|
sessionLength?: number | null;
|
||||||
@@ -28,7 +31,10 @@ export async function createResourceSession(opts: {
|
|||||||
!opts.pincodeId &&
|
!opts.pincodeId &&
|
||||||
!opts.whitelistId &&
|
!opts.whitelistId &&
|
||||||
!opts.accessTokenId &&
|
!opts.accessTokenId &&
|
||||||
!opts.userSessionId
|
!opts.userSessionId &&
|
||||||
|
!opts.policyPasswordId &&
|
||||||
|
!opts.policyPincodeId &&
|
||||||
|
!opts.policyWhitelistId
|
||||||
) {
|
) {
|
||||||
throw new Error("Auth method must be provided");
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
@@ -49,6 +55,9 @@ export async function createResourceSession(opts: {
|
|||||||
whitelistId: opts.whitelistId || null,
|
whitelistId: opts.whitelistId || null,
|
||||||
doNotExtend: opts.doNotExtend || false,
|
doNotExtend: opts.doNotExtend || false,
|
||||||
accessTokenId: opts.accessTokenId || null,
|
accessTokenId: opts.accessTokenId || null,
|
||||||
|
policyPasswordId: opts.policyPasswordId || null,
|
||||||
|
policyPincodeId: opts.policyPincodeId || null,
|
||||||
|
policyWhitelistId: opts.policyWhitelistId || null,
|
||||||
isRequestToken: opts.isRequestToken || false,
|
isRequestToken: opts.isRequestToken || false,
|
||||||
userSessionId: opts.userSessionId || null,
|
userSessionId: opts.userSessionId || null,
|
||||||
issuedAt: new Date().getTime()
|
issuedAt: new Date().getTime()
|
||||||
|
|||||||
@@ -795,10 +795,13 @@ export const COUNTRIES = [
|
|||||||
name: "Serbia",
|
name: "Serbia",
|
||||||
code: "RS"
|
code: "RS"
|
||||||
},
|
},
|
||||||
{
|
// Removed as this is a deprecated ISO country code, not supported anymore
|
||||||
name: "Serbia and Montenegro",
|
// Also the individual flags for Serbia & Montenegro are already included in the list
|
||||||
code: "CS"
|
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
|
||||||
},
|
// {
|
||||||
|
// name: "Serbia and Montenegro",
|
||||||
|
// code: "CS"
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
name: "Seychelles",
|
name: "Seychelles",
|
||||||
code: "SC"
|
code: "SC"
|
||||||
|
|||||||
+36
-1
@@ -1,6 +1,12 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { clients, db, resources, siteResources } from "@server/db";
|
import {
|
||||||
|
clients,
|
||||||
|
db,
|
||||||
|
resourcePolicies,
|
||||||
|
resources,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
import { randomInt } from "crypto";
|
import { randomInt } from "crypto";
|
||||||
import { exitNodes, sites } from "@server/db";
|
import { exitNodes, sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUniqueResourcePolicyName(
|
||||||
|
orgId: string
|
||||||
|
): Promise<string> {
|
||||||
|
let loops = 0;
|
||||||
|
while (true) {
|
||||||
|
if (loops > 100) {
|
||||||
|
throw new Error("Could not generate a unique name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = generateName();
|
||||||
|
const policyCount = await db
|
||||||
|
.select({
|
||||||
|
niceId: resourcePolicies.niceId,
|
||||||
|
orgId: resourcePolicies.orgId
|
||||||
|
})
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, name),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyCount.length === 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
loops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUniqueSiteResourceName(
|
export async function getUniqueSiteResourceName(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
|
export { alias } from "drizzle-orm/pg-core";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
|||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
import { db as mainDb } from "./driver";
|
||||||
import { createPool } from "./poolConfig";
|
import { createPool } from "./poolConfig";
|
||||||
|
|
||||||
function createLogsDb() {
|
function createLogsDb() {
|
||||||
@@ -63,8 +63,7 @@ function createLogsDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections =
|
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
||||||
poolConfig?.max_replica_connections || 20;
|
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = createPool(
|
||||||
conn.connection_string,
|
conn.connection_string,
|
||||||
@@ -91,4 +90,4 @@ function createLogsDb() {
|
|||||||
|
|
||||||
export const logsDb = createLogsDb();
|
export const logsDb = createLogsDb();
|
||||||
export default logsDb;
|
export default logsDb;
|
||||||
export const primaryLogsDb = logsDb.$primary;
|
export const primaryLogsDb = logsDb.$primary;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import config from "@server/lib/config";
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
pool.on("error", (err) => {
|
pool.on("error", (err) => {
|
||||||
// This catches errors on idle clients in the pool. Without this
|
// This catches errors on idle clients in the pool. Without this
|
||||||
// handler an unexpected disconnect would crash the process.
|
// handler an unexpected disconnect would crash the process.
|
||||||
logger.error(
|
console.error(
|
||||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -36,10 +36,32 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
// Set a statement timeout on every new connection so a single slow
|
// Set a statement timeout on every new connection so a single slow
|
||||||
// query can't block the pool forever
|
// query can't block the pool forever
|
||||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||||
logger.warn(
|
console.warn(
|
||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Disable JIT compilation for this connection. Our hot-path queries
|
||||||
|
// (e.g. resource-by-domain lookups) join many tables but only ever
|
||||||
|
// return a handful of rows. When planner row estimates drift (e.g.
|
||||||
|
// due to autovacuum lag under write-heavy load), Postgres decides
|
||||||
|
// these plans are expensive enough to JIT-compile, which can add
|
||||||
|
// multiple seconds of pure compilation overhead per query and
|
||||||
|
// saturate the connection pool. JIT never pays off for these
|
||||||
|
// short-lived OLTP queries, so it's disabled outright rather than
|
||||||
|
// relying on statistics staying fresh.
|
||||||
|
//
|
||||||
|
// Set via a runtime SET command rather than the `options: "-c
|
||||||
|
// jit=off"` startup parameter: connections in SaaS mode go through
|
||||||
|
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
||||||
|
// options with a protocol_violation (08P01) error.
|
||||||
|
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
||||||
|
client.query("SET jit = off").catch((err: Error) => {
|
||||||
|
console.warn(
|
||||||
|
`Failed to set jit=off on ${label} client: ${err.message}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +82,4 @@ export function createPool(
|
|||||||
);
|
);
|
||||||
attachPoolErrorHandlers(pool, label);
|
attachPoolErrorHandlers(pool, label);
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
|
unique,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -19,12 +20,13 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sessions,
|
|
||||||
clients,
|
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites
|
sites,
|
||||||
|
clients,
|
||||||
|
sessions,
|
||||||
|
labels
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -197,6 +199,42 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: varchar("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = pgTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: serial(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -207,17 +245,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginPage = pgTable("loginPage", {
|
export const loginPage = pgTable(
|
||||||
loginPageId: serial("loginPageId").primaryKey(),
|
"loginPage",
|
||||||
subdomain: varchar("subdomain"),
|
{
|
||||||
fullDomain: varchar("fullDomain"),
|
loginPageId: serial("loginPageId").primaryKey(),
|
||||||
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
subdomain: varchar("subdomain"),
|
||||||
onDelete: "set null"
|
fullDomain: varchar("fullDomain"),
|
||||||
}),
|
exitNodeId: integer("exitNodeId").references(
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
() => exitNodes.exitNodeId,
|
||||||
onDelete: "set null"
|
{
|
||||||
})
|
onDelete: "set null"
|
||||||
});
|
}
|
||||||
|
),
|
||||||
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_loginpage_fulldomain")
|
||||||
|
.on(t.fullDomain)
|
||||||
|
.where(sql`${t.fullDomain} IS NOT NULL`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||||
loginPageId: integer("loginPageId")
|
loginPageId: integer("loginPageId")
|
||||||
@@ -580,24 +629,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: varchar("authToken").notNull(),
|
|
||||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: varchar("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -645,6 +676,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
+664
-324
File diff suppressed because it is too large
Load Diff
@@ -17,22 +17,37 @@ import {
|
|||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
ResourceHeaderAuth,
|
ResourceHeaderAuth,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
|
resourcePolicyRules,
|
||||||
resources,
|
resources,
|
||||||
roleResources,
|
roleResources,
|
||||||
|
rolePolicies,
|
||||||
sessions,
|
sessions,
|
||||||
userResources,
|
userResources,
|
||||||
|
userPolicies,
|
||||||
users,
|
users,
|
||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
ResourcePolicyPincode,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
ResourcePolicyPassword,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
ResourcePolicyHeaderAuth
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
import { alias } from "@server/db";
|
||||||
|
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export type ResourceWithAuth = {
|
export type ResourceWithAuth = {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
pincode: ResourcePincode | null;
|
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
||||||
password: ResourcePassword | null;
|
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||||
headerAuth: ResourceHeaderAuth | null;
|
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
|
applyRules: boolean | null;
|
||||||
|
sso: boolean | null;
|
||||||
|
emailWhitelistEnabled: boolean | null;
|
||||||
org: Org;
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +72,33 @@ export async function getResourceByDomain(
|
|||||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||||
|
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||||
|
const sharedPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"sharedPolicyPincode"
|
||||||
|
);
|
||||||
|
const defaultPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"defaultPolicyPincode"
|
||||||
|
);
|
||||||
|
const sharedPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"sharedPolicyPassword"
|
||||||
|
);
|
||||||
|
const defaultPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"defaultPolicyPassword"
|
||||||
|
);
|
||||||
|
const sharedPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"sharedPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
const defaultPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"defaultPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
|
||||||
const potentialResults = await db
|
const potentialResults = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -79,6 +121,59 @@ export async function getResourceByDomain(
|
|||||||
resources.resourceId
|
resources.resourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicy,
|
||||||
|
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyPincode,
|
||||||
|
eq(
|
||||||
|
sharedPolicyPincode.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyPassword,
|
||||||
|
eq(
|
||||||
|
sharedPolicyPassword.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
sharedPolicyHeaderAuth.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicy,
|
||||||
|
eq(
|
||||||
|
defaultPolicy.resourcePolicyId,
|
||||||
|
resources.defaultResourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPincode,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPincode.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPassword,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPassword.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
defaultPolicyHeaderAuth.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
@@ -108,13 +203,51 @@ export async function getResourceByDomain(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a shared (custom) policy is assigned to the resource, use ONLY
|
||||||
|
// its values — do not fall back to the default policy. The default
|
||||||
|
// policy is only consulted when no shared policy is assigned at all.
|
||||||
|
const hasSharedPolicy = result.sharedPolicy !== null;
|
||||||
|
|
||||||
|
const effectivePolicyPincode = hasSharedPolicy
|
||||||
|
? result.sharedPolicyPincode
|
||||||
|
: (result.defaultPolicyPincode ?? null);
|
||||||
|
const effectivePolicyPassword = hasSharedPolicy
|
||||||
|
? result.sharedPolicyPassword
|
||||||
|
: (result.defaultPolicyPassword ?? null);
|
||||||
|
const effectivePolicyHeaderAuth = hasSharedPolicy
|
||||||
|
? result.sharedPolicyHeaderAuth
|
||||||
|
: (result.defaultPolicyHeaderAuth ?? null);
|
||||||
|
const selectedPolicy = hasSharedPolicy
|
||||||
|
? result.sharedPolicy
|
||||||
|
: result.defaultPolicy;
|
||||||
|
const effectiveApplyRules =
|
||||||
|
selectedPolicy?.applyRules ?? result.resources.applyRules;
|
||||||
|
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
|
||||||
|
const effectiveEmailWhitelistEnabled =
|
||||||
|
selectedPolicy?.emailWhitelistEnabled ??
|
||||||
|
result.resources.emailWhitelistEnabled;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resource: result.resources,
|
resource: {
|
||||||
pincode: result.resourcePincode,
|
...result.resources,
|
||||||
password: result.resourcePassword,
|
applyRules: effectiveApplyRules,
|
||||||
headerAuth: result.resourceHeaderAuth,
|
sso: effectiveSSO,
|
||||||
headerAuthExtendedCompatibility:
|
emailWhitelistEnabled: effectiveEmailWhitelistEnabled
|
||||||
result.resourceHeaderAuthExtendedCompatibility,
|
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct
|
||||||
|
pincode: effectivePolicyPincode ?? result.resourcePincode,
|
||||||
|
password: effectivePolicyPassword ?? result.resourcePassword,
|
||||||
|
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
|
||||||
|
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
|
||||||
|
? ({
|
||||||
|
headerAuthExtendedCompatibilityId: 0,
|
||||||
|
resourceId: result.resources.resourceId,
|
||||||
|
extendedCompatibilityIsActivated:
|
||||||
|
effectivePolicyHeaderAuth.extendedCompatibility
|
||||||
|
} as ResourceHeaderAuthExtendedCompatibility)
|
||||||
|
: result.resourceHeaderAuthExtendedCompatibility,
|
||||||
|
applyRules: effectiveApplyRules,
|
||||||
|
sso: effectiveSSO,
|
||||||
|
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
|
||||||
org: result.orgs
|
org: result.orgs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,58 +287,165 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if role has access to resource
|
* Check if role has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getRoleResourceAccess(
|
export async function getRoleResourceAccess(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
roleIds: number[]
|
roleIds: number[]
|
||||||
) {
|
) {
|
||||||
const roleResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(roleResources)
|
.select()
|
||||||
.where(
|
.from(roleResources)
|
||||||
and(
|
.where(
|
||||||
eq(roleResources.resourceId, resourceId),
|
and(
|
||||||
inArray(roleResources.roleId, roleIds)
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
inArray(roleResources.roleId, roleIds)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
const combined = [...direct, ...viaPolicies];
|
||||||
|
return combined.length > 0 ? combined : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has direct access to resource
|
* Check if user has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getUserResourceAccess(
|
export async function getUserResourceAccess(
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceId: number
|
resourceId: number
|
||||||
) {
|
) {
|
||||||
const userResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(userResources)
|
.select()
|
||||||
.where(
|
.from(userResources)
|
||||||
and(
|
.where(
|
||||||
eq(userResources.userId, userId),
|
and(
|
||||||
eq(userResources.resourceId, resourceId)
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
userId: userPolicies.userId,
|
||||||
|
resourcePolicyId: userPolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
return direct[0] ?? viaPolicies[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resource rules for a given resource
|
* Get resource rules for a given resource (direct and via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getResourceRules(
|
export async function getResourceRules(
|
||||||
resourceId: number
|
resourceId: number
|
||||||
): Promise<ResourceRule[]> {
|
): Promise<ResourceRule[]> {
|
||||||
const rules = await db
|
const [directRules, policyRules] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(resourceRules)
|
.select()
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number>`${resourceId}`,
|
||||||
|
enabled: resourcePolicyRules.enabled,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
]);
|
||||||
|
|
||||||
return rules;
|
const maxDirectPriority = directRules.reduce(
|
||||||
|
(max, r) => Math.max(max, r.priority),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const offsetPolicyRules = policyRules.map((r) => ({
|
||||||
|
...r,
|
||||||
|
priority: maxDirectPriority + r.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-50
@@ -1,6 +1,5 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import type BetterSqlite3 from "better-sqlite3";
|
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -12,68 +11,31 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
|
||||||
* execution, freeing native sqlite3_stmt memory deterministically instead
|
|
||||||
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
|
||||||
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
|
||||||
* reusable .prepare() builders. No such usage exists in this codebase.
|
|
||||||
*/
|
|
||||||
function autoFinalizeStatement(
|
|
||||||
stmt: BetterSqlite3.Statement
|
|
||||||
): BetterSqlite3.Statement {
|
|
||||||
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
|
||||||
return function (this: any, ...args: any[]) {
|
|
||||||
try {
|
|
||||||
return fn.apply(this, args);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
// finalize() exists on the native Statement at runtime but
|
|
||||||
// is missing from @types/better-sqlite3.
|
|
||||||
(stmt as any).finalize();
|
|
||||||
} catch {
|
|
||||||
// Already finalized — harmless
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as unknown as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
stmt.run = wrapExec(stmt.run);
|
|
||||||
stmt.get = wrapExec(stmt.get);
|
|
||||||
stmt.all = wrapExec(stmt.all);
|
|
||||||
|
|
||||||
return stmt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
|
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
||||||
|
// env var does NOT revert an existing WAL database.
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
// No busy_timeout pragma: better-sqlite3 already arms
|
||||||
// retry loops that accumulate memory.
|
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
||||||
sqlite.pragma("busy_timeout = 5000");
|
// (lib/database.js), so an explicit pragma is redundant.
|
||||||
|
|
||||||
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
||||||
// TraefikConfigManager JOINs that block the event loop.
|
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
||||||
sqlite.pragma("cache_size = -65536");
|
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
||||||
|
|
||||||
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
||||||
// reducing event-loop blocking.
|
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
||||||
sqlite.pragma("mmap_size = 268435456");
|
// fresh statement per query (no statement cache), so statements cannot
|
||||||
|
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
||||||
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
|
||||||
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
|
||||||
const originalPrepare = sqlite.prepare.bind(sqlite);
|
|
||||||
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
|
||||||
return autoFinalizeStatement(originalPrepare(source));
|
|
||||||
};
|
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
|
export { alias } from "drizzle-orm/sqlite-core";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
labels,
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -21,9 +22,6 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { serial, varchar } from "drizzle-orm/mysql-core";
|
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
|
||||||
import { bigint } from "zod";
|
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -195,6 +193,44 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: text("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = sqliteTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: integer(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey({ autoIncrement: true }),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
@@ -588,26 +624,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: text("authToken").notNull(),
|
|
||||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: text("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -647,6 +663,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
|
customCertResolver: text("customCertResolver"),
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
errorMessage: text("errorMessage")
|
errorMessage: text("errorMessage"),
|
||||||
|
lastCheckedAt: integer("lastCheckedAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -62,7 +64,13 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||||
billingOrgId: text("billingOrgId")
|
billingOrgId: text("billingOrgId"),
|
||||||
|
settingsEnableGlobalNewtAutoUpdate: integer(
|
||||||
|
"settingsEnableGlobalNewtAutoUpdate",
|
||||||
|
{ mode: "boolean" }
|
||||||
|
)
|
||||||
|
.notNull()
|
||||||
|
.default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -110,17 +118,36 @@ export const sites = sqliteTable("sites", {
|
|||||||
// exit node stuff that is how to connect to the site when it has a wg server
|
// exit node stuff that is how to connect to the site when it has a wg server
|
||||||
address: text("address"), // this is the address of the wireguard interface in newt
|
address: text("address"), // this is the address of the wireguard interface in newt
|
||||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||||
|
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
||||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
|
||||||
|
mode: "boolean"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = sqliteTable("resources", {
|
export const resources = sqliteTable("resources", {
|
||||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{ onDelete: "set null" }
|
||||||
|
),
|
||||||
|
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||||
|
() => resourcePolicies.resourcePolicyId,
|
||||||
|
{
|
||||||
|
onDelete: "restrict"
|
||||||
|
}
|
||||||
|
),
|
||||||
resourceGuid: text("resourceGuid", { length: 36 })
|
resourceGuid: text("resourceGuid", { length: 36 })
|
||||||
.unique()
|
.unique()
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -141,14 +168,12 @@ export const resources = sqliteTable("resources", {
|
|||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
|
||||||
proxyPort: integer("proxyPort"),
|
proxyPort: integer("proxyPort"),
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
sso: integer("sso", { mode: "boolean" }),
|
||||||
.notNull()
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||||
.default(false),
|
mode: "boolean"
|
||||||
applyRules: integer("applyRules", { mode: "boolean" })
|
}),
|
||||||
.notNull()
|
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||||
.default(false),
|
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
stickySession: integer("stickySession", { mode: "boolean" })
|
stickySession: integer("stickySession", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -185,7 +210,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
@@ -199,6 +225,23 @@ export const labels = sqliteTable("labels", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const launcherViews = sqliteTable("launcherViews", {
|
||||||
|
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
config: text("config").notNull(),
|
||||||
|
isDefault: integer("isDefault", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
createdAt: text("createdAt").notNull(),
|
||||||
|
updatedAt: text("updatedAt").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const siteLabels = sqliteTable(
|
export const siteLabels = sqliteTable(
|
||||||
"siteLabels",
|
"siteLabels",
|
||||||
{
|
{
|
||||||
@@ -298,7 +341,12 @@ export const targets = sqliteTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100)
|
priority: integer("priority").notNull().default(100),
|
||||||
|
mode: text("mode")
|
||||||
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
|
.notNull()
|
||||||
|
.default("http"),
|
||||||
|
authToken: text("authToken")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
@@ -401,7 +449,8 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain")
|
fullDomain: text("fullDomain"),
|
||||||
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const networks = sqliteTable("networks", {
|
export const networks = sqliteTable("networks", {
|
||||||
@@ -1008,6 +1057,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
|||||||
headerAuthHash: text("headerAuthHash").notNull()
|
headerAuthHash: text("headerAuthHash").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
|
||||||
|
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
|
||||||
|
pincodeHash: text("pincodeHash").notNull(),
|
||||||
|
digitLength: integer("digitLength").notNull(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
|
||||||
|
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
|
||||||
|
passwordHash: text("passwordHash").notNull(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyHeaderAuth = sqliteTable(
|
||||||
|
"resourcePolicyHeaderAuth",
|
||||||
|
{
|
||||||
|
headerAuthId: integer("headerAuthId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
headerAuthHash: text("headerAuthHash").notNull(),
|
||||||
|
extendedCompatibility: integer("extendedCompatibility", {
|
||||||
|
mode: "boolean"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
.default(true),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||||
"resourceHeaderAuthExtendedCompatibility",
|
"resourceHeaderAuthExtendedCompatibility",
|
||||||
{
|
{
|
||||||
@@ -1036,11 +1126,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
|
userId: text("userId").references(() => users.userId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
path: text("path"),
|
||||||
tokenHash: text("tokenHash").notNull(),
|
tokenHash: text("tokenHash").notNull(),
|
||||||
sessionLength: integer("sessionLength").notNull(),
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
persistSession: integer("persistSession", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1082,6 +1179,24 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
policyPasswordId: integer("policyPasswordId").references(
|
||||||
|
() => resourcePolicyPassword.passwordId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
policyPincodeId: integer("policyPincodeId").references(
|
||||||
|
() => resourcePolicyPincode.pincodeId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
policyWhitelistId: integer("policyWhitelistId").references(
|
||||||
|
() => resourcePolicyWhiteList.whitelistId,
|
||||||
|
{
|
||||||
|
onDelete: "cascade"
|
||||||
|
}
|
||||||
|
),
|
||||||
issuedAt: integer("issuedAt")
|
issuedAt: integer("issuedAt")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1118,10 +1233,101 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match").notNull(), // CIDR, PATH, IP
|
match: text("match")
|
||||||
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rolePolicies = sqliteTable("rolePolicies", {
|
||||||
|
roleId: integer("roleId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userPolicies = sqliteTable("userPolicies", {
|
||||||
|
userId: text("userId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
|
||||||
|
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
||||||
|
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||||
|
resourcePolicyId: integer("resourcePolicyId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => resourcePolicies.resourcePolicyId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
|
priority: integer("priority").notNull(),
|
||||||
|
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
|
match: text("match")
|
||||||
|
.$type<
|
||||||
|
| "CIDR"
|
||||||
|
| "PATH"
|
||||||
|
| "IP"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION"
|
||||||
|
>()
|
||||||
|
.notNull(),
|
||||||
|
value: text("value").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourcePolicies = sqliteTable("resourcePolicies", {
|
||||||
|
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
|
||||||
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
|
applyRules: integer("applyRules", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
scope: text("scope")
|
||||||
|
.$type<"global" | "resource">()
|
||||||
|
.notNull()
|
||||||
|
.default("global"),
|
||||||
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
niceId: text("niceId").notNull(),
|
||||||
|
idpId: integer("idpId").references(() => idp.idpId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
}),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
orgId: text("orgId")
|
||||||
|
.references(() => orgs.orgId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export const supporterKey = sqliteTable("supporterKey", {
|
export const supporterKey = sqliteTable("supporterKey", {
|
||||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
@@ -1389,3 +1595,16 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
|
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
||||||
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
|
export type ResourcePolicyPincode = InferSelectModel<
|
||||||
|
typeof resourcePolicyPincode
|
||||||
|
>;
|
||||||
|
export type ResourcePolicyPassword = InferSelectModel<
|
||||||
|
typeof resourcePolicyPassword
|
||||||
|
>;
|
||||||
|
export type ResourcePolicyHeaderAuth = InferSelectModel<
|
||||||
|
typeof resourcePolicyHeaderAuth
|
||||||
|
>;
|
||||||
|
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||||
|
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
|
|||||||
const isLastDay = daysRemaining === 1;
|
const isLastDay = daysRemaining === 1;
|
||||||
|
|
||||||
const previewText = hasEnded
|
const previewText = hasEnded
|
||||||
? `Your trial for ${orgName} has ended.`
|
? `Your cloud trial for ${orgName} has ended.`
|
||||||
: isLastDay
|
: isLastDay
|
||||||
? `Your trial for ${orgName} ends tomorrow.`
|
? `Your cloud trial for ${orgName} ends tomorrow.`
|
||||||
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
|
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||||
|
|
||||||
const heading = hasEnded
|
const heading = hasEnded
|
||||||
? "Your Trial Ended"
|
? "Your Cloud Trial Ended"
|
||||||
: "Your Trial is Ending Soon";
|
: "Your Cloud Trial is Ending Soon";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
{hasEnded ? (
|
{hasEnded ? (
|
||||||
<>
|
<>
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Your free trial for{" "}
|
Your cloud free trial for{" "}
|
||||||
<strong>{orgName}</strong> ended on{" "}
|
<strong>{orgName}</strong> ended on{" "}
|
||||||
<strong>{trialEndsAt}</strong>. Your account
|
<strong>{trialEndsAt}</strong>. Your account
|
||||||
has been moved to the free plan, which
|
has been moved to the free plan, which
|
||||||
@@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({
|
|||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Some features and resources may now be
|
Some features and resources may now be
|
||||||
restricted. To restore full
|
restricted. To restore full access and
|
||||||
access and continue using all the features
|
continue using all the features you had
|
||||||
you had during your trial, please upgrade to
|
during your trial, please upgrade to a paid
|
||||||
a paid plan.
|
plan. This does not effect any self hosted
|
||||||
|
licenses.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
@@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({
|
|||||||
<EmailText>
|
<EmailText>
|
||||||
After your trial ends, your account will be
|
After your trial ends, your account will be
|
||||||
moved to the free plan and some
|
moved to the free plan and some
|
||||||
functionality may be restricted.
|
functionality may be restricted. This does
|
||||||
|
not effect any self hosted licenses.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
@@ -24,6 +24,7 @@ import license from "#dynamic/license/license";
|
|||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
|
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -41,6 +42,7 @@ async function startServers() {
|
|||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
initAcmeCertSync();
|
initAcmeCertSync();
|
||||||
|
startRebuildQueueProcessor();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
import { registry } from "./openApi";
|
import { registry, openApiTags } from "./openApi";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { APP_PATH } from "./lib/consts";
|
import { APP_PATH } from "./lib/consts";
|
||||||
@@ -152,11 +152,19 @@ function getOpenApiDocumentation() {
|
|||||||
|
|
||||||
if (!hasExistingResponses) {
|
if (!hasExistingResponses) {
|
||||||
def.route.responses = {
|
def.route.responses = {
|
||||||
"*": {
|
"200": {
|
||||||
description: "",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({})
|
schema: z.object({
|
||||||
|
data: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +181,8 @@ function getOpenApiDocumentation() {
|
|||||||
version: "v1",
|
version: "v1",
|
||||||
title: "Pangolin Integration API"
|
title: "Pangolin Integration API"
|
||||||
},
|
},
|
||||||
servers: [{ url: "/v1" }]
|
servers: [{ url: "/v1" }],
|
||||||
|
tags: openApiTags
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
|
|||||||
@@ -221,10 +221,18 @@ async function handleResource(
|
|||||||
)
|
)
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
|
const monitoredTargets = otherTargets.filter(
|
||||||
|
(t) => t.hcHealth !== "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
const allUnknown = monitoredTargets.length === 0;
|
||||||
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
const allHealthy = monitoredTargets.every(
|
||||||
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
(t) => t.hcHealth === "healthy"
|
||||||
|
);
|
||||||
|
const allUnhealthy = monitoredTargets.every(
|
||||||
|
(t) => t.hcHealth === "unhealthy"
|
||||||
|
);
|
||||||
|
|
||||||
if (allUnknown) {
|
if (allUnknown) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
export enum FeatureId {
|
export enum LimitId {
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
SITES = "sites",
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
ORGINIZATIONS = "organizations",
|
ORGANIZATIONS = "organizations",
|
||||||
|
PUBLIC_RESOURCES = "publicResources",
|
||||||
|
PRIVATE_RESOURCES = "privateResources",
|
||||||
|
MACHINE_CLIENTS = "machineClients",
|
||||||
TIER1 = "tier1"
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
export async function getFeatureDisplayName(
|
||||||
|
featureId: LimitId
|
||||||
|
): Promise<string> {
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case FeatureId.USERS:
|
case LimitId.USERS:
|
||||||
return "Users";
|
return "Users";
|
||||||
case FeatureId.SITES:
|
case LimitId.SITES:
|
||||||
return "Sites";
|
return "Sites";
|
||||||
case FeatureId.EGRESS_DATA_MB:
|
case LimitId.EGRESS_DATA_MB:
|
||||||
return "Egress Data (MB)";
|
return "Egress Data (MB)";
|
||||||
case FeatureId.DOMAINS:
|
case LimitId.DOMAINS:
|
||||||
return "Domains";
|
return "Domains";
|
||||||
case FeatureId.REMOTE_EXIT_NODES:
|
case LimitId.REMOTE_EXIT_NODES:
|
||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
case FeatureId.ORGINIZATIONS:
|
case LimitId.ORGANIZATIONS:
|
||||||
return "Organizations";
|
return "Organizations";
|
||||||
case FeatureId.TIER1:
|
case LimitId.PUBLIC_RESOURCES:
|
||||||
|
return "Public Resources";
|
||||||
|
case LimitId.PRIVATE_RESOURCES:
|
||||||
|
return "Private Resources";
|
||||||
|
case LimitId.MACHINE_CLIENTS:
|
||||||
|
return "Machine Clients";
|
||||||
|
case LimitId.TIER1:
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
return featureId;
|
return featureId;
|
||||||
@@ -30,15 +41,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is from the old system
|
// this is from the old system
|
||||||
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
|
||||||
|
// right now we are not charging for any data
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -49,22 +61,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByMetricId(
|
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
|
||||||
metricId: string
|
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
|
||||||
): FeatureId | undefined {
|
|
||||||
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
|
|
||||||
([_, v]) => v === metricId
|
([_, v]) => v === metricId
|
||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
|
||||||
|
|
||||||
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
||||||
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -79,11 +89,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||||
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -98,11 +108,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||||
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -116,7 +126,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
||||||
// Check all feature price sets
|
// Check all feature price sets
|
||||||
const allPriceSets = [
|
const allPriceSets = [
|
||||||
getTier1FeaturePriceSet(),
|
getTier1FeaturePriceSet(),
|
||||||
@@ -125,7 +135,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const priceSet of allPriceSets) {
|
for (const priceSet of allPriceSets) {
|
||||||
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
|
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
|
||||||
([_, price]) => price === priceId
|
([_, price]) => price === priceId
|
||||||
);
|
);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { FeatureId, FeaturePriceSet } from "./features";
|
import { LimitId, FeaturePriceSet } from "./features";
|
||||||
import { usageService } from "./usageService";
|
import { usageService } from "./usageService";
|
||||||
|
|
||||||
export async function getLineItems(
|
export async function getLineItems(
|
||||||
featurePriceSet: FeaturePriceSet,
|
featurePriceSet: FeaturePriceSet,
|
||||||
orgId: string,
|
orgId: string
|
||||||
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||||
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
const users = await usageService.getUsage(orgId, LimitId.USERS);
|
||||||
|
|
||||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
||||||
let quantity: number | undefined;
|
let quantity: number | undefined;
|
||||||
|
|
||||||
if (featureId === FeatureId.USERS) {
|
if (featureId === LimitId.USERS) {
|
||||||
quantity = users?.instantaneousValue || 1;
|
quantity = users?.instantaneousValue || 1;
|
||||||
} else if (featureId === FeatureId.TIER1) {
|
} else if (featureId === LimitId.TIER1) {
|
||||||
quantity = 1;
|
quantity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +1,82 @@
|
|||||||
import { FeatureId } from "./features";
|
import { LimitId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = Partial<{
|
export type LimitSet = Partial<{
|
||||||
[key in FeatureId]: {
|
[key in LimitId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
[LimitId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
[LimitId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
|
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
|
||||||
|
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
|
||||||
|
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
|
||||||
|
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1LimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
[LimitId.USERS]: { value: 7, description: "Home limit" },
|
||||||
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
[LimitId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
|
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
|
||||||
|
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
|
||||||
|
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
|
||||||
|
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: {
|
[LimitId.USERS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.SITES]: {
|
[LimitId.SITES]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.DOMAINS]: {
|
[LimitId.DOMAINS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[LimitId.REMOTE_EXIT_NODES]: {
|
||||||
value: 3,
|
value: 3,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[FeatureId.ORGINIZATIONS]: {
|
[LimitId.ORGANIZATIONS]: {
|
||||||
value: 1,
|
value: 1,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
}
|
},
|
||||||
|
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
|
||||||
|
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
|
||||||
|
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: {
|
[LimitId.USERS]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.SITES]: {
|
[LimitId.SITES]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.DOMAINS]: {
|
[LimitId.DOMAINS]: {
|
||||||
value: 100,
|
value: 100,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: {
|
[LimitId.REMOTE_EXIT_NODES]: {
|
||||||
value: 20,
|
value: 20,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[FeatureId.ORGINIZATIONS]: {
|
[LimitId.ORGANIZATIONS]: {
|
||||||
value: 5,
|
value: 5,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
|
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
|
||||||
|
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
|
||||||
|
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, limits } from "@server/db";
|
import { db, limits } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { LimitSet } from "./limitSet";
|
import { LimitSet } from "./limitSet";
|
||||||
import { FeatureId } from "./features";
|
import { LimitId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
class LimitService {
|
class LimitService {
|
||||||
@@ -38,7 +38,7 @@ class LimitService {
|
|||||||
|
|
||||||
async getOrgLimit(
|
async getOrgLimit(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId
|
featureId: LimitId
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const limitId = `${orgId}-${featureId}`;
|
const limitId = `${orgId}-${featureId}`;
|
||||||
const [limit] = await db
|
const [limit] = await db
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ export enum TierFeature {
|
|||||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||||
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||||
SshPam = "sshPam",
|
|
||||||
FullRbac = "fullRbac",
|
FullRbac = "fullRbac",
|
||||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||||
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
|
||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
Labels = "labels"
|
NewtAutoUpdate = "newtAutoUpdate",
|
||||||
|
ResourcePolicies = "resourcePolicies",
|
||||||
|
AdvancedPublicResources = "advancedPublicResources",
|
||||||
|
AdvancedPrivateResources = "advancedPrivateResources"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
@@ -60,13 +60,15 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
|
||||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||||
[TierFeature.SIEM]: ["enterprise"],
|
[TierFeature.SIEM]: ["enterprise"],
|
||||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
|
||||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
|
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||||
|
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
||||||
|
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
orgs
|
orgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { FeatureId, getFeatureMeterId } from "./features";
|
import { LimitId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import cache from "#dynamic/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -22,7 +22,6 @@ export function noop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
@@ -38,7 +37,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async add(
|
public async add(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: LimitId,
|
||||||
value: number,
|
value: number,
|
||||||
transaction: any = null
|
transaction: any = null
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
@@ -57,7 +56,10 @@ export class UsageService {
|
|||||||
try {
|
try {
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
const orgIdToUse = await this.getBillingOrg(
|
||||||
|
orgId,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -112,7 +114,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async internalAddUsage(
|
private async internalAddUsage(
|
||||||
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||||
featureId: FeatureId,
|
featureId: LimitId,
|
||||||
value: number,
|
value: number,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<Usage> {
|
): Promise<Usage> {
|
||||||
@@ -161,7 +163,7 @@ export class UsageService {
|
|||||||
|
|
||||||
async updateCount(
|
async updateCount(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: LimitId,
|
||||||
value?: number,
|
value?: number,
|
||||||
customerId?: string
|
customerId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -225,7 +227,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async getCustomerId(
|
private async getCustomerId(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId
|
featureId: LimitId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
@@ -267,18 +269,19 @@ export class UsageService {
|
|||||||
|
|
||||||
public async getUsage(
|
public async getUsage(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: FeatureId,
|
featureId: LimitId,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
let orgIdToUse = orgId;
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -338,8 +341,12 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
throw error;
|
if (process.env.NODE_ENV !== "development") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -374,7 +381,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId?: FeatureId,
|
featureId?: LimitId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -382,13 +389,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
|
let orgIdToUse = orgId;
|
||||||
try {
|
try {
|
||||||
|
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
@@ -422,7 +429,7 @@ export class UsageService {
|
|||||||
} else {
|
} else {
|
||||||
currentUsage = await this.getUsage(
|
currentUsage = await this.getUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
limit.featureId as FeatureId,
|
limit.featureId as LimitId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,37 @@ import {
|
|||||||
newts,
|
newts,
|
||||||
blueprints,
|
blueprints,
|
||||||
Blueprint,
|
Blueprint,
|
||||||
Site,
|
|
||||||
siteResources,
|
siteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
userSiteResources,
|
userSiteResources,
|
||||||
clientSiteResources
|
clientSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { Config, ConfigSchema } from "./types";
|
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
|
||||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
import {
|
||||||
|
PublicResourcesResults,
|
||||||
|
updatePublicResources
|
||||||
|
} from "./publicResources";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, isNotNull } from "drizzle-orm";
|
import { eq, and, isNotNull } from "drizzle-orm";
|
||||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
import {
|
||||||
import { addTargets as addClientTargets } from "@server/routers/client/targets";
|
addTargets as addProxyTargets,
|
||||||
|
sendBrowserGatewayTargets
|
||||||
|
} from "@server/routers/newt/targets";
|
||||||
import {
|
import {
|
||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updateClientResources
|
updatePrivateResources
|
||||||
} from "./clientResources";
|
} from "./privateResources";
|
||||||
|
import { updateResourcePolicies } from "./resourcePolicies";
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { faker } from "@faker-js/faker";
|
import { generateName } from "@server/db/names";
|
||||||
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
import {
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
handleMessagingForUpdatedSiteResource,
|
||||||
|
rebuildClientAssociationsFromSiteResource,
|
||||||
|
waitForSiteResourceRebuildIdle
|
||||||
|
} from "../rebuildClientAssociations";
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -42,40 +50,39 @@ export async function applyBlueprint({
|
|||||||
name,
|
name,
|
||||||
source = "API"
|
source = "API"
|
||||||
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
||||||
// Validate the input data
|
|
||||||
const validationResult = ConfigSchema.safeParse(configData);
|
|
||||||
if (!validationResult.success) {
|
|
||||||
throw new Error(fromError(validationResult.error).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Config = validationResult.data;
|
|
||||||
let blueprintSucceeded: boolean = false;
|
let blueprintSucceeded: boolean = false;
|
||||||
let blueprintMessage: string;
|
let blueprintMessage = "";
|
||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
const validationResult = ConfigSchema.safeParse(configData);
|
||||||
let clientResourcesResults: ClientResourcesResults = [];
|
if (!validationResult.success) {
|
||||||
await db.transaction(async (trx) => {
|
throw new Error(fromError(validationResult.error).toString());
|
||||||
proxyResourcesResults = await updateProxyResources(
|
}
|
||||||
orgId,
|
|
||||||
config,
|
|
||||||
trx,
|
|
||||||
siteId
|
|
||||||
);
|
|
||||||
clientResourcesResults = await updateClientResources(
|
|
||||||
orgId,
|
|
||||||
config,
|
|
||||||
trx,
|
|
||||||
siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
const config: Config = validationResult.data;
|
||||||
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
|
|
||||||
|
let publicResourcesResults: PublicResourcesResults = [];
|
||||||
|
let privateResourcesResults: ClientResourcesResults = [];
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await updateResourcePolicies(orgId, config, trx);
|
||||||
|
|
||||||
|
publicResourcesResults = await updatePublicResources(
|
||||||
|
orgId,
|
||||||
|
config,
|
||||||
|
trx,
|
||||||
|
siteId
|
||||||
|
);
|
||||||
|
privateResourcesResults = await updatePrivateResources(
|
||||||
|
orgId,
|
||||||
|
config,
|
||||||
|
trx,
|
||||||
|
siteId
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of proxyResourcesResults) {
|
for (const result of publicResourcesResults) {
|
||||||
for (const target of result.targetsToUpdate) {
|
for (const target of result.targetsToUpdate) {
|
||||||
const [site] = await trx
|
const [site] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -102,178 +109,63 @@ export async function applyBlueprint({
|
|||||||
(hc) => hc.targetId === target.targetId
|
(hc) => hc.targetId === target.targetId
|
||||||
);
|
);
|
||||||
|
|
||||||
await addProxyTargets(
|
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||||
site.newt.newtId,
|
await addProxyTargets(
|
||||||
[target],
|
site.newt.newtId,
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
[target],
|
||||||
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
matchingHealthcheck
|
||||||
site.newt.version
|
? [matchingHealthcheck]
|
||||||
);
|
: [],
|
||||||
|
result.proxyResource.mode === "udp"
|
||||||
|
? "udp"
|
||||||
|
: "tcp",
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||||
|
) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
site.newt.newtId,
|
||||||
|
[target],
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
|
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of clientResourcesResults) {
|
for (const result of privateResourcesResults) {
|
||||||
if (
|
rebuildClientAssociationsFromSiteResource(
|
||||||
result.oldSiteResource &&
|
result.newSiteResource
|
||||||
JSON.stringify(result.newSites?.sort()) !==
|
)
|
||||||
JSON.stringify(result.oldSites?.sort())
|
.then(() =>
|
||||||
) {
|
waitForSiteResourceRebuildIdle(
|
||||||
// query existing associations
|
result.newSiteResource.siteResourceId
|
||||||
const existingRoleIds = await trx
|
|
||||||
.select()
|
|
||||||
.from(roleSiteResources)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
roleSiteResources.siteResourceId,
|
|
||||||
result.oldSiteResource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.then((rows) => rows.map((row) => row.roleId));
|
)
|
||||||
|
.then(() =>
|
||||||
const existingUserIds = await trx
|
handleMessagingForUpdatedSiteResource(
|
||||||
.select()
|
result.oldSiteResource,
|
||||||
.from(userSiteResources)
|
result.newSiteResource,
|
||||||
.where(
|
result.oldSites.map((s) => s.siteId),
|
||||||
eq(
|
result.newSites.map((s) => s.siteId)
|
||||||
userSiteResources.siteResourceId,
|
|
||||||
result.oldSiteResource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.then((rows) => rows.map((row) => row.userId));
|
)
|
||||||
|
.catch((e) => {
|
||||||
const existingClientIds = await trx
|
logger.error(
|
||||||
.select()
|
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
||||||
.from(clientSiteResources)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
clientSiteResources.siteResourceId,
|
|
||||||
result.oldSiteResource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then((rows) => rows.map((row) => row.clientId));
|
|
||||||
|
|
||||||
// delete the existing site resource
|
|
||||||
await trx
|
|
||||||
.delete(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
result.oldSiteResource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
const [insertedSiteResource] = await trx
|
|
||||||
.insert(siteResources)
|
|
||||||
.values({
|
|
||||||
...result.newSiteResource
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// wait some time to allow for messages to be handled
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
|
||||||
|
|
||||||
if (existingRoleIds.length > 0) {
|
|
||||||
await trx.insert(roleSiteResources).values(
|
|
||||||
existingRoleIds.map((roleId) => ({
|
|
||||||
roleId,
|
|
||||||
siteResourceId:
|
|
||||||
insertedSiteResource!.siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUserIds.length > 0) {
|
|
||||||
await trx.insert(userSiteResources).values(
|
|
||||||
existingUserIds.map((userId) => ({
|
|
||||||
userId,
|
|
||||||
siteResourceId:
|
|
||||||
insertedSiteResource!.siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingClientIds.length > 0) {
|
|
||||||
await trx.insert(clientSiteResources).values(
|
|
||||||
existingClientIds.map((clientId) => ({
|
|
||||||
clientId,
|
|
||||||
siteResourceId:
|
|
||||||
insertedSiteResource!.siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
|
||||||
insertedSiteResource,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
let good = true;
|
|
||||||
for (const newSite of result.newSites) {
|
|
||||||
const [site] = await trx
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sites.siteId, newSite.siteId),
|
|
||||||
eq(sites.orgId, orgId),
|
|
||||||
eq(sites.type, "newt"),
|
|
||||||
isNotNull(sites.pubKey)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
logger.debug(
|
|
||||||
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
|
||||||
);
|
|
||||||
good = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!good) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleMessagingForUpdatedSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
result.newSiteResource,
|
|
||||||
result.newSites.map((site) => ({
|
|
||||||
siteId: site.siteId,
|
|
||||||
orgId: result.newSiteResource.orgId
|
|
||||||
})),
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// await addClientTargets(
|
|
||||||
// site.newt.newtId,
|
|
||||||
// result.resource.destination,
|
|
||||||
// result.resource.destinationPort,
|
|
||||||
// result.resource.protocol,
|
|
||||||
// result.resource.proxyPort
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
blueprintSucceeded = true;
|
blueprintSucceeded = true;
|
||||||
@@ -281,7 +173,9 @@ export async function applyBlueprint({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
blueprintSucceeded = false;
|
blueprintSucceeded = false;
|
||||||
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
||||||
logger.error(blueprintMessage);
|
logger.debug(
|
||||||
|
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
|
||||||
|
);
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,9 +185,7 @@ export async function applyBlueprint({
|
|||||||
.insert(blueprints)
|
.insert(blueprints)
|
||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
name:
|
name: name ?? generateName(),
|
||||||
name ??
|
|
||||||
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
|
|
||||||
contents: stringifyYaml(configData),
|
contents: stringifyYaml(configData),
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
succeeded: blueprintSucceeded,
|
succeeded: blueprintSucceeded,
|
||||||
|
|||||||
@@ -1,10 +1,56 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { processContainerLabels } from "./parseDockerContainers";
|
import { processContainerLabels } from "./parseDockerContainers";
|
||||||
import { applyBlueprint } from "./applyBlueprint";
|
import { applyBlueprint } from "./applyBlueprint";
|
||||||
|
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
|
||||||
import { db, sites } from "@server/db";
|
import { db, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
type BlueprintResult = ReturnType<typeof processContainerLabels>;
|
||||||
|
|
||||||
|
function filterInvalidResources(blueprint: BlueprintResult): {
|
||||||
|
skippedCount: number;
|
||||||
|
skippedKeys: string[];
|
||||||
|
} {
|
||||||
|
const skippedKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const section of ["proxy-resources", "public-resources"] as const) {
|
||||||
|
const resources = blueprint[section];
|
||||||
|
for (const [key, value] of Object.entries(resources)) {
|
||||||
|
const result = PublicResourceSchema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues
|
||||||
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("; ");
|
||||||
|
logger.warn(
|
||||||
|
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
||||||
|
);
|
||||||
|
delete resources[key];
|
||||||
|
skippedKeys.push(`${section}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of ["client-resources", "private-resources"] as const) {
|
||||||
|
const resources = blueprint[section];
|
||||||
|
for (const [key, value] of Object.entries(resources)) {
|
||||||
|
const result = PrivateResourceSchema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
const errors = result.error.issues
|
||||||
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||||
|
.join("; ");
|
||||||
|
logger.warn(
|
||||||
|
`Skipping invalid Docker ${section} "${key}": ${errors}`
|
||||||
|
);
|
||||||
|
delete resources[key];
|
||||||
|
skippedKeys.push(`${section}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skippedCount: skippedKeys.length, skippedKeys };
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyNewtDockerBlueprint(
|
export async function applyNewtDockerBlueprint(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
newtId: string,
|
newtId: string,
|
||||||
@@ -21,17 +67,27 @@ export async function applyNewtDockerBlueprint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
|
let skippedCount = 0;
|
||||||
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
|
let skippedKeys: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blueprint = processContainerLabels(containers);
|
// Some Newt clients can report null/undefined containers when Docker
|
||||||
|
// labels are unavailable. Treat that as an empty blueprint payload.
|
||||||
|
const safeContainers = Array.isArray(containers) ? containers : [];
|
||||||
|
const blueprint = processContainerLabels(safeContainers);
|
||||||
|
|
||||||
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
|
logger.debug(
|
||||||
|
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
|
||||||
|
);
|
||||||
|
|
||||||
// make sure this is not an empty object
|
const filterResult = filterInvalidResources(blueprint);
|
||||||
if (isEmptyObject(blueprint)) {
|
skippedCount = filterResult.skippedCount;
|
||||||
return;
|
skippedKeys = filterResult.skippedKeys;
|
||||||
|
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -40,6 +96,15 @@ export async function applyNewtDockerBlueprint(
|
|||||||
isEmptyObject(blueprint["public-resources"]) &&
|
isEmptyObject(blueprint["public-resources"]) &&
|
||||||
isEmptyObject(blueprint["private-resources"])
|
isEmptyObject(blueprint["private-resources"])
|
||||||
) {
|
) {
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
await sendToClient(newtId, {
|
||||||
|
type: "newt/blueprint/results",
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +116,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to update database from config: ${error}`);
|
logger.debug(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
@@ -66,7 +131,10 @@ export async function applyNewtDockerBlueprint(
|
|||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Config updated successfully"
|
message:
|
||||||
|
skippedCount > 0
|
||||||
|
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
|
||||||
|
: "Config updated successfully"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-16
@@ -3,6 +3,7 @@ import {
|
|||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
|
roleActions,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
Site,
|
Site,
|
||||||
@@ -19,8 +20,14 @@ import { sites } from "@server/db";
|
|||||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||||
import { Config } from "./types";
|
import { Config } from "./types";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { LimitId } from "../billing";
|
||||||
|
import { usageService } from "../billing/usageService";
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -101,7 +108,7 @@ export type ClientResourcesResults = {
|
|||||||
oldSites: { siteId: number }[];
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateClientResources(
|
export async function updatePrivateResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
@@ -112,6 +119,30 @@ export async function updateClientResources(
|
|||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["client-resources"]
|
config["client-resources"]
|
||||||
)) {
|
)) {
|
||||||
|
if (resourceData.mode === "http") {
|
||||||
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasHttpFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.mode === "ssh") {
|
||||||
|
const hasSshFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasSshFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"SSH private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [existingResource] = await trx
|
const [existingResource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -167,17 +198,19 @@ export async function updateClientResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||||
if (siteId && allSites.length === 0) {
|
if (siteId && allSites.length === 0) {
|
||||||
// only add if there are not provided sites
|
// only add if there are not provided sites
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
const [siteSingle] = await trx
|
const [siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, status: sites.status })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (siteSingle) {
|
if (siteSingle) {
|
||||||
allSites.push(siteSingle);
|
allSites.push(siteSingle);
|
||||||
}
|
}
|
||||||
|
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSites.length === 0) {
|
if (allSites.length === 0) {
|
||||||
@@ -186,6 +219,13 @@ export async function updateClientResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceEnabled =
|
||||||
|
resourceData.enabled == undefined || resourceData.enabled == null
|
||||||
|
? true
|
||||||
|
: resourceStatusFromSite === "pending"
|
||||||
|
? false
|
||||||
|
: resourceData.enabled;
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
@@ -199,6 +239,31 @@ export async function updateClientResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceData.alias) {
|
||||||
|
const [aliasConflict] = await trx
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.alias, resourceData.alias),
|
||||||
|
ne(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
existingResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (aliasConflict) {
|
||||||
|
throw new Error(
|
||||||
|
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
@@ -209,8 +274,7 @@ export async function updateClientResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: resourceEnabled,
|
||||||
// enabled: resourceData.enabled ?? true,
|
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
@@ -229,7 +293,8 @@ export async function updateClientResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -336,8 +401,7 @@ export async function updateClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
// Re-add specified roles but we need to get the roleIds from the role name in the array
|
const existingRoles = await trx
|
||||||
const rolesToUpdate = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -347,7 +411,30 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||||
|
const missingNames = resourceData.roles.filter(
|
||||||
|
(n) => !foundNames.has(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of missingNames) {
|
||||||
|
const [created] = await trx
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name, orgId })
|
||||||
|
.returning();
|
||||||
|
await trx.insert(roleActions).values(
|
||||||
|
defaultRoleAllowedActions.map((action) => ({
|
||||||
|
roleId: created.roleId,
|
||||||
|
actionId: action,
|
||||||
|
orgId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
existingRoles.push(created);
|
||||||
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
@@ -363,9 +450,47 @@ export async function updateClientResources(
|
|||||||
oldSites: existingSiteIds
|
oldSites: existingSiteIds
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// create a brand new resource
|
||||||
|
|
||||||
|
if (build == "saas") {
|
||||||
|
const usage = await usageService.getUsage(
|
||||||
|
orgId,
|
||||||
|
LimitId.PRIVATE_RESOURCES
|
||||||
|
);
|
||||||
|
if (!usage) {
|
||||||
|
throw new Error(
|
||||||
|
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rejectResource = await usageService.checkLimitSet(
|
||||||
|
orgId,
|
||||||
|
|
||||||
|
LimitId.PRIVATE_RESOURCES,
|
||||||
|
{
|
||||||
|
...usage,
|
||||||
|
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
||||||
|
} // We need to add one to know if we are violating the limit
|
||||||
|
);
|
||||||
|
if (rejectResource) {
|
||||||
|
throw new Error(
|
||||||
|
"Private resource limit exceeded. Please upgrade your plan."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
|
if (
|
||||||
|
resourceData.mode === "host" ||
|
||||||
|
resourceData.mode === "http" ||
|
||||||
|
resourceData.mode === "ssh"
|
||||||
|
) {
|
||||||
|
const { value, release } = await getNextAvailableAliasAddress(
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
aliasAddress = value;
|
||||||
|
releaseAliasLock = release;
|
||||||
}
|
}
|
||||||
|
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
@@ -380,6 +505,27 @@ export async function updateClientResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceData.alias) {
|
||||||
|
const [aliasConflict] = await trx
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.alias, resourceData.alias)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (aliasConflict) {
|
||||||
|
throw new Error(
|
||||||
|
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [network] = await trx
|
const [network] = await trx
|
||||||
.insert(networks)
|
.insert(networks)
|
||||||
.values({
|
.values({
|
||||||
@@ -402,8 +548,7 @@ export async function updateClientResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: resourceEnabled,
|
||||||
// enabled: resourceData.enabled ?? true,
|
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
aliasAddress: aliasAddress,
|
aliasAddress: aliasAddress,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
@@ -423,10 +568,13 @@ export async function updateClientResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
await releaseAliasLock?.();
|
||||||
|
|
||||||
const siteResourceId = newResource.siteResourceId;
|
const siteResourceId = newResource.siteResourceId;
|
||||||
|
|
||||||
for (const site of allSites) {
|
for (const site of allSites) {
|
||||||
@@ -452,8 +600,7 @@ export async function updateClientResources(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
// get roleIds from role names
|
const existingRoles = await trx
|
||||||
const rolesToUpdate = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -463,7 +610,30 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||||
|
const missingNames = resourceData.roles.filter(
|
||||||
|
(n) => !foundNames.has(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of missingNames) {
|
||||||
|
const [created] = await trx
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name, orgId })
|
||||||
|
.returning();
|
||||||
|
await trx.insert(roleActions).values(
|
||||||
|
defaultRoleAllowedActions.map((action) => ({
|
||||||
|
roleId: created.roleId,
|
||||||
|
actionId: action,
|
||||||
|
orgId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
existingRoles.push(created);
|
||||||
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
@@ -525,6 +695,8 @@ export async function updateClientResources(
|
|||||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
newSiteResource: newResource,
|
newSiteResource: newResource,
|
||||||
newSites: allSites,
|
newSites: allSites,
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,654 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
idpOrg,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
rolePolicies,
|
||||||
|
roles,
|
||||||
|
Transaction,
|
||||||
|
userOrgs,
|
||||||
|
userPolicies,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and, or } from "drizzle-orm";
|
||||||
|
import { Config, ResourcePolicyData } from "./types";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
|
export type ResourcePoliciesResults = {
|
||||||
|
resourcePolicyId: number;
|
||||||
|
niceId: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export async function updateResourcePolicies(
|
||||||
|
orgId: string,
|
||||||
|
config: Config,
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<ResourcePoliciesResults> {
|
||||||
|
const results: ResourcePoliciesResults = [];
|
||||||
|
|
||||||
|
for (const [policyNiceId, policyData] of Object.entries(
|
||||||
|
config["public-policies"]
|
||||||
|
)) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.resourcePolicies
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rules
|
||||||
|
for (const rule of policyData.rules) {
|
||||||
|
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
rule.match === "path" &&
|
||||||
|
!isValidUrlGlobPattern(rule.value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auto-login-idp if provided
|
||||||
|
if (policyData["auto-login-idp"]) {
|
||||||
|
const [provider] = await trx
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idp.idpId, policyData["auto-login-idp"]),
|
||||||
|
eq(idpOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(
|
||||||
|
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the admin role
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new Error("Admin role not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing policy by niceId and orgId
|
||||||
|
const [existingPolicy] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, policyNiceId),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let resourcePolicyId: number;
|
||||||
|
|
||||||
|
if (existingPolicy) {
|
||||||
|
// Update the existing policy
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set({
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] || policyData.rules.length > 0
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
existingPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
resourcePolicyId = existingPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Sync password
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync pincode
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPincode)
|
||||||
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync header auth
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyHeaderAuth)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync SSO roles
|
||||||
|
await syncRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync SSO users
|
||||||
|
await syncUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync whitelist users
|
||||||
|
await syncWhitelistPolicyUsers(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["whitelist-users"],
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync rules
|
||||||
|
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create a new policy
|
||||||
|
const [newPolicy] = await trx
|
||||||
|
.insert(resourcePolicies)
|
||||||
|
.values({
|
||||||
|
niceId: policyNiceId,
|
||||||
|
orgId,
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] ||
|
||||||
|
policyData.rules.length > 0,
|
||||||
|
scope: "global"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
resourcePolicyId = newPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Always add admin role
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
resourcePolicyId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add SSO roles
|
||||||
|
await addRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add SSO users
|
||||||
|
await addUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add password
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pincode
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header auth
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add whitelist users
|
||||||
|
if (policyData["whitelist-users"].length > 0) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values(
|
||||||
|
policyData["whitelist-users"].map((email) => ({
|
||||||
|
email,
|
||||||
|
resourcePolicyId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rules
|
||||||
|
if (policyData.rules.length > 0) {
|
||||||
|
await trx.insert(resourcePolicyRules).values(
|
||||||
|
policyData.rules.map((rule, index) => ({
|
||||||
|
resourcePolicyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: rule.priority ?? index + 1,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ resourcePolicyId, niceId: policyNiceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
||||||
|
if (input === "allow") return "ACCEPT";
|
||||||
|
if (input === "deny") return "DROP";
|
||||||
|
return "PASS";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleMatch(
|
||||||
|
input: string
|
||||||
|
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
|
||||||
|
return input.toUpperCase() as
|
||||||
|
| "CIDR"
|
||||||
|
| "IP"
|
||||||
|
| "PATH"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "COUNTRY_IS_NOT"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRolePolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(rolePolicies)
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin role is always included, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingRolePolicies.some(
|
||||||
|
(rp) => rp.roleId === role.roleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove roles no longer in the list (except admin)
|
||||||
|
for (const existingRolePolicy of existingRolePolicies) {
|
||||||
|
if (existingRolePolicy.roleId === adminRoleId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, existingRolePolicy.roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (role?.isAdmin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && !ssoRoles.includes(role.name)) {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, existingRolePolicy.roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin already added
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingUserPolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(userPolicies)
|
||||||
|
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingUserPolicies.some(
|
||||||
|
(up) => up.userId === user.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove users no longer in the list
|
||||||
|
for (const existingUserPolicy of existingUserPolicies) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.userId, existingUserPolicy.userId),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user &&
|
||||||
|
user.user.username &&
|
||||||
|
!ssoUsers.includes(user.user.username) &&
|
||||||
|
!ssoUsers.includes(user.user.email ?? "")
|
||||||
|
) {
|
||||||
|
await trx
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, existingUserPolicy.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWhitelistPolicyUsers(
|
||||||
|
policyId: number,
|
||||||
|
whitelistUsers: string[],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingWhitelist = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const email of whitelistUsers) {
|
||||||
|
const alreadyExists = existingWhitelist.some((w) => w.email === email);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingEntry of existingWhitelist) {
|
||||||
|
if (!whitelistUsers.includes(existingEntry.email)) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
|
||||||
|
eq(resourcePolicyWhiteList.email, existingEntry.email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPolicyRules(
|
||||||
|
policyId: number,
|
||||||
|
rules: ResourcePolicyData["rules"],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRules = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
|
||||||
|
.orderBy(resourcePolicyRules.priority);
|
||||||
|
|
||||||
|
for (const [index, rule] of rules.entries()) {
|
||||||
|
const intendedPriority = rule.priority ?? index + 1;
|
||||||
|
const existingRule = existingRules[index];
|
||||||
|
|
||||||
|
if (existingRule) {
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicyRules)
|
||||||
|
.set({
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
})
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
|
||||||
|
} else {
|
||||||
|
await trx.insert(resourcePolicyRules).values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove extra rules
|
||||||
|
if (existingRules.length > rules.length) {
|
||||||
|
const rulesToDelete = existingRules.slice(rules.length);
|
||||||
|
for (const rule of rulesToDelete) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+173
-10
@@ -1,8 +1,23 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { portRangeStringSchema } from "@server/lib/ip";
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
||||||
|
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
||||||
|
|
||||||
|
const hasMaxmindCountryDb =
|
||||||
|
typeof maxmindDbPath === "string" &&
|
||||||
|
maxmindDbPath.length > 0 &&
|
||||||
|
existsSync(maxmindDbPath);
|
||||||
|
|
||||||
|
const hasMaxmindAsnDb =
|
||||||
|
typeof maxmindAsnPath === "string" &&
|
||||||
|
maxmindAsnPath.length > 0 &&
|
||||||
|
existsSync(maxmindAsnPath);
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -82,8 +97,9 @@ export const RuleSchema = z
|
|||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional()
|
priority: z.int().optional(),
|
||||||
|
enabled: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
@@ -116,6 +132,9 @@ export const RuleSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country") {
|
||||||
|
if (!hasMaxmindCountryDb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Check if it's a valid 2-letter country code or "ALL"
|
// Check if it's a valid 2-letter country code or "ALL"
|
||||||
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||||
}
|
}
|
||||||
@@ -124,12 +143,15 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "asn") {
|
if (rule.match === "asn") {
|
||||||
|
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Check if it's either AS<number> format or "ALL"
|
// Check if it's either AS<number> format or "ALL"
|
||||||
const asNumberPattern = /^AS\d+$/i;
|
const asNumberPattern = /^AS\d+$/i;
|
||||||
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||||
@@ -139,7 +161,7 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -188,6 +210,7 @@ export const PublicResourceSchema = z
|
|||||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||||
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
|
||||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||||
|
policy: z.string().optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
"full-domain": z.string().optional(),
|
"full-domain": z.string().optional(),
|
||||||
@@ -200,7 +223,9 @@ export const PublicResourceSchema = z
|
|||||||
headers: z.array(HeaderSchema).optional(),
|
headers: z.array(HeaderSchema).optional(),
|
||||||
rules: z.array(RuleSchema).optional(),
|
rules: z.array(RuleSchema).optional(),
|
||||||
maintenance: MaintenanceSchema.optional(),
|
maintenance: MaintenanceSchema.optional(),
|
||||||
"auth-daemon": AuthDaemonSchema.optional()
|
"auth-daemon": AuthDaemonSchema.optional(),
|
||||||
|
"proxy-protocol": z.boolean().optional(),
|
||||||
|
"proxy-protocol-version": z.int().min(1).optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(resource) => {
|
(resource) => {
|
||||||
@@ -264,8 +289,37 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is http, it must have a full-domain
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
if ((resource.mode ?? resource.protocol) === "http") {
|
if (effectiveProtocol !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = resource["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
resource.targets.filter((target) => target != null).length <= 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["targets"],
|
||||||
|
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||||
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
|
if (
|
||||||
|
effectiveProtocol !== undefined &&
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -275,7 +329,7 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', a 'full-domain' must be provided"
|
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -377,6 +431,23 @@ export const PublicResourceSchema = z
|
|||||||
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
const effectiveMode = resource.mode ?? resource.protocol;
|
||||||
|
if (effectiveMode !== "tcp") {
|
||||||
|
return (
|
||||||
|
resource["proxy-protocol"] === undefined &&
|
||||||
|
resource["proxy-protocol-version"] === undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["proxy-protocol"],
|
||||||
|
message:
|
||||||
|
"'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'"
|
||||||
|
}
|
||||||
|
)
|
||||||
.transform((resource) => {
|
.transform((resource) => {
|
||||||
// Normalize: prefer mode, fall back to protocol for backwards compatibility
|
// Normalize: prefer mode, fall back to protocol for backwards compatibility
|
||||||
if (resource.mode === undefined && resource.protocol !== undefined) {
|
if (resource.mode === undefined && resource.protocol !== undefined) {
|
||||||
@@ -399,7 +470,7 @@ export const PrivateResourceSchema = z
|
|||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
"destination-port": z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).optional(),
|
||||||
// enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"disable-icmp": z.boolean().optional().default(false),
|
"disable-icmp": z.boolean().optional().default(false),
|
||||||
@@ -485,7 +556,90 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = data["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueSites = new Set<string>();
|
||||||
|
if (data.site) {
|
||||||
|
uniqueSites.add(data.site);
|
||||||
|
}
|
||||||
|
for (const site of data.sites) {
|
||||||
|
uniqueSites.add(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSites.size <= 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["sites"],
|
||||||
|
message:
|
||||||
|
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transform((data) => {
|
||||||
|
if (
|
||||||
|
data.mode === "ssh" &&
|
||||||
|
data.destination !== undefined &&
|
||||||
|
data["destination-port"] === undefined
|
||||||
|
) {
|
||||||
|
data["destination-port"] = 22;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||||
|
|
||||||
|
export const ResourcePolicySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
sso: z.boolean().optional().default(true),
|
||||||
|
"auto-login-idp": z.int().positive().optional().nullable(),
|
||||||
|
"sso-roles": z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.refine((roles) => !roles.includes("Admin"), {
|
||||||
|
error: "Admin role cannot be included in sso-roles"
|
||||||
|
}),
|
||||||
|
"sso-users": z.array(z.string()).optional().default([]),
|
||||||
|
password: z.string().min(4).max(100).optional().nullable(),
|
||||||
|
pincode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{6}$/)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"basic-auth": z
|
||||||
|
.object({
|
||||||
|
user: z.string().min(4).max(100),
|
||||||
|
password: z.string().min(4).max(100),
|
||||||
|
"extended-compatibility": z.boolean().default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"email-whitelist-enabled": z.boolean().optional().default(false),
|
||||||
|
"whitelist-users": z
|
||||||
|
.array(
|
||||||
|
z.email().or(
|
||||||
|
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||||
|
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.max(50)
|
||||||
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
"apply-rules": z.boolean().optional().default(false),
|
||||||
|
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
||||||
|
});
|
||||||
|
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|
||||||
// Schema for the entire configuration object
|
// Schema for the entire configuration object
|
||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
@@ -506,6 +660,10 @@ export const ConfigSchema = z
|
|||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
"public-policies": z
|
||||||
|
.record(z.string(), ResourcePolicySchema)
|
||||||
|
.optional()
|
||||||
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
@@ -536,6 +694,10 @@ export const ConfigSchema = z
|
|||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
|
"public-policies": Record<
|
||||||
|
string,
|
||||||
|
z.infer<typeof ResourcePolicySchema>
|
||||||
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -675,3 +837,4 @@ export type Site = z.infer<typeof SiteSchema>;
|
|||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|||||||
@@ -154,8 +154,19 @@ class AdaptiveCache {
|
|||||||
keys(): string[] {
|
keys(): string[] {
|
||||||
return localCache.keys();
|
return localCache.keys();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get keys with a specific prefix
|
||||||
|
* @param prefix - Key prefix to match
|
||||||
|
* @returns Array of matching keys
|
||||||
|
*/
|
||||||
|
async keysWithPrefix(prefix: string): Promise<string[]> {
|
||||||
|
const allKeys = localCache.keys();
|
||||||
|
return allKeys.filter((key) => key.startsWith(prefix));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const cache = new AdaptiveCache();
|
export const cache = new AdaptiveCache();
|
||||||
|
export const regionalCache = cache; // Alias for compatability with the private version
|
||||||
export default cache;
|
export default cache;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
|
primaryDb,
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -23,422 +24,427 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { tierMatrix } from "./billing/tierMatrix";
|
import { tierMatrix } from "./billing/tierMatrix";
|
||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
type ClientRow = typeof clients.$inferSelect;
|
||||||
|
|
||||||
|
function runQueuedClientAssociationRebuilds(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction | typeof db = db
|
queuedClients: ClientRow[]
|
||||||
): Promise<void> {
|
) {
|
||||||
const execute = async (transaction: Transaction | typeof db) => {
|
if (queuedClients.length === 0) {
|
||||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
return;
|
||||||
const adminRoleCache = new Map<
|
}
|
||||||
string,
|
|
||||||
typeof roles.$inferSelect | null
|
|
||||||
>();
|
|
||||||
const exitNodesCache = new Map<
|
|
||||||
string,
|
|
||||||
Awaited<ReturnType<typeof listExitNodes>>
|
|
||||||
>();
|
|
||||||
const isOrgLicensedCache = new Map<string, boolean>();
|
|
||||||
const existingClientCache = new Map<
|
|
||||||
string,
|
|
||||||
typeof clients.$inferSelect | null
|
|
||||||
>();
|
|
||||||
const roleClientAccessCache = new Map<string, boolean>();
|
|
||||||
const userClientAccessCache = new Map<string, boolean>();
|
|
||||||
|
|
||||||
const getOrgOlmKey = (orgId: string, olmId: string) =>
|
const uniqueClientsById = new Map<number, ClientRow>();
|
||||||
`${orgId}:${olmId}`;
|
for (const client of queuedClients) {
|
||||||
const getRoleClientKey = (roleId: number, clientId: number) =>
|
uniqueClientsById.set(client.clientId, client);
|
||||||
`${roleId}:${clientId}`;
|
}
|
||||||
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
|
||||||
`${cachedUserId}:${clientId}`;
|
|
||||||
|
|
||||||
const getOrg = async (orgId: string) => {
|
for (const client of uniqueClientsById.values()) {
|
||||||
if (orgCache.has(orgId)) {
|
rebuildClientAssociationsFromClient(client).catch((error) => {
|
||||||
return orgCache.get(orgId) ?? null;
|
logger.error(
|
||||||
}
|
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
|
||||||
|
error
|
||||||
const [org] = await transaction
|
)}`
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
orgCache.set(orgId, org ?? null);
|
|
||||||
|
|
||||||
return org ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAdminRole = async (orgId: string) => {
|
|
||||||
if (adminRoleCache.has(orgId)) {
|
|
||||||
return adminRoleCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await transaction
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
adminRoleCache.set(orgId, adminRole ?? null);
|
|
||||||
|
|
||||||
return adminRole ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExitNodes = async (orgId: string) => {
|
|
||||||
if (exitNodesCache.has(orgId)) {
|
|
||||||
return exitNodesCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodes = await listExitNodes(orgId);
|
|
||||||
exitNodesCache.set(orgId, exitNodes);
|
|
||||||
|
|
||||||
return exitNodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIsOrgLicensed = async (orgId: string) => {
|
|
||||||
if (isOrgLicensedCache.has(orgId)) {
|
|
||||||
return isOrgLicensedCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
);
|
);
|
||||||
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
|
||||||
|
|
||||||
return isOrgLicensed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingClient = async (orgId: string, olmId: string) => {
|
|
||||||
const key = getOrgOlmKey(orgId, olmId);
|
|
||||||
if (existingClientCache.has(key)) {
|
|
||||||
return existingClientCache.get(key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingClient] = await transaction
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clients.userId, userId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
eq(clients.olmId, olmId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
existingClientCache.set(key, existingClient ?? null);
|
|
||||||
|
|
||||||
return existingClient ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasRoleClientAccess = async (
|
|
||||||
roleId: number,
|
|
||||||
clientId: number
|
|
||||||
) => {
|
|
||||||
const key = getRoleClientKey(roleId, clientId);
|
|
||||||
if (roleClientAccessCache.has(key)) {
|
|
||||||
return roleClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingRoleClient] = await transaction
|
|
||||||
.select()
|
|
||||||
.from(roleClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleClients.roleId, roleId),
|
|
||||||
eq(roleClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingRoleClient);
|
|
||||||
roleClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUserClientAccess = async (
|
|
||||||
cachedUserId: string,
|
|
||||||
clientId: number
|
|
||||||
) => {
|
|
||||||
const key = getUserClientKey(cachedUserId, clientId);
|
|
||||||
if (userClientAccessCache.has(key)) {
|
|
||||||
return userClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingUserClient] = await transaction
|
|
||||||
.select()
|
|
||||||
.from(userClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userClients.userId, cachedUserId),
|
|
||||||
eq(userClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingUserClient);
|
|
||||||
userClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all OLMs for this user
|
|
||||||
const userOlms = await transaction
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.userId, userId));
|
|
||||||
|
|
||||||
if (userOlms.length === 0) {
|
|
||||||
// No OLMs for this user, but we should still clean up any orphaned clients
|
|
||||||
await cleanupOrphanedClients(userId, transaction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all user orgs with all roles (for org list and role-based logic)
|
|
||||||
const userOrgRoleRows = await transaction
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.innerJoin(
|
|
||||||
userOrgRoles,
|
|
||||||
and(
|
|
||||||
eq(userOrgs.userId, userOrgRoles.userId),
|
|
||||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
|
||||||
.where(eq(userOrgs.userId, userId));
|
|
||||||
|
|
||||||
const userOrgIds = [
|
|
||||||
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
|
||||||
];
|
|
||||||
const orgIdToRoleRows = new Map<
|
|
||||||
string,
|
|
||||||
(typeof userOrgRoleRows)[0][]
|
|
||||||
>();
|
|
||||||
for (const r of userOrgRoleRows) {
|
|
||||||
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
|
||||||
list.push(r);
|
|
||||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
|
||||||
}
|
|
||||||
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
|
||||||
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
|
||||||
orgRequiresDeviceApprovalRole.set(
|
|
||||||
orgId,
|
|
||||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each OLM, ensure there's a client in each org the user is in
|
|
||||||
for (const olm of userOlms) {
|
|
||||||
for (const orgId of orgIdToRoleRows.keys()) {
|
|
||||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
|
||||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
|
||||||
|
|
||||||
const org = await getOrg(orgId);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org.subnet) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get admin role for this org (needed for access grants)
|
|
||||||
const adminRole = await getAdminRole(orgId);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a client already exists for this OLM+user+org combination
|
|
||||||
const existingClient = await getExistingClient(
|
|
||||||
orgId,
|
|
||||||
olm.olmId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingClient) {
|
|
||||||
// Ensure admin role has access to the client
|
|
||||||
const hasRoleAccess = await hasRoleClientAccess(
|
|
||||||
adminRole.roleId,
|
|
||||||
existingClient.clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasRoleAccess) {
|
|
||||||
await transaction.insert(roleClients).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
clientId: existingClient.clientId
|
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
|
||||||
getRoleClientKey(
|
|
||||||
adminRole.roleId,
|
|
||||||
existingClient.clientId
|
|
||||||
),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
logger.debug(
|
|
||||||
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure user has access to the client
|
|
||||||
const hasUserAccess = await hasUserClientAccess(
|
|
||||||
userId,
|
|
||||||
existingClient.clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasUserAccess) {
|
|
||||||
await transaction.insert(userClients).values({
|
|
||||||
userId,
|
|
||||||
clientId: existingClient.clientId
|
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, existingClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
logger.debug(
|
|
||||||
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get exit nodes for this org
|
|
||||||
const exitNodesList = await getExitNodes(orgId);
|
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[
|
|
||||||
Math.floor(Math.random() * exitNodesList.length)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get next available subnet
|
|
||||||
const newSubnet = await getNextAvailableClientSubnet(
|
|
||||||
orgId,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
if (!newSubnet) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subnet = newSubnet.split("/")[0];
|
|
||||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
|
||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
|
||||||
|
|
||||||
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
|
||||||
const requireApproval =
|
|
||||||
build !== "oss" &&
|
|
||||||
isOrgLicensed &&
|
|
||||||
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
|
||||||
|
|
||||||
const newClientData: InferInsertModel<typeof clients> = {
|
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId,
|
|
||||||
approvalState: requireApproval ? "pending" : null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the client
|
|
||||||
const [newClient] = await transaction
|
|
||||||
.insert(clients)
|
|
||||||
.values(newClientData)
|
|
||||||
.returning();
|
|
||||||
existingClientCache.set(
|
|
||||||
getOrgOlmKey(orgId, olm.olmId),
|
|
||||||
newClient
|
|
||||||
);
|
|
||||||
|
|
||||||
// create approval request
|
|
||||||
if (requireApproval) {
|
|
||||||
await transaction
|
|
||||||
.insert(approvals)
|
|
||||||
.values({
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
clientId: newClient.clientId,
|
|
||||||
userId,
|
|
||||||
type: "user_device"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
await rebuildClientAssociationsFromClient(
|
|
||||||
newClient,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant admin role access to the client
|
|
||||||
await transaction.insert(roleClients).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
|
||||||
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant user access to the client
|
|
||||||
await transaction.insert(userClients).values({
|
|
||||||
userId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up clients in orgs the user is no longer in
|
|
||||||
await cleanupOrphanedClients(userId, transaction, userOrgIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (trx) {
|
|
||||||
// Use provided transaction
|
|
||||||
await execute(trx);
|
|
||||||
} else {
|
|
||||||
// Create new transaction
|
|
||||||
await db.transaction(async (transaction) => {
|
|
||||||
await execute(transaction);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calculateUserClientsForOrgs(
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const trx = primaryDb;
|
||||||
|
|
||||||
|
const queuedAssociationRebuilds: ClientRow[] = [];
|
||||||
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
|
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
|
||||||
|
const exitNodesCache = new Map<
|
||||||
|
string,
|
||||||
|
Awaited<ReturnType<typeof listExitNodes>>
|
||||||
|
>();
|
||||||
|
const isOrgLicensedCache = new Map<string, boolean>();
|
||||||
|
const existingClientCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof clients.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const roleClientAccessCache = new Map<string, boolean>();
|
||||||
|
const userClientAccessCache = new Map<string, boolean>();
|
||||||
|
|
||||||
|
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
|
||||||
|
const getRoleClientKey = (roleId: number, clientId: number) =>
|
||||||
|
`${roleId}:${clientId}`;
|
||||||
|
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
||||||
|
`${cachedUserId}:${clientId}`;
|
||||||
|
|
||||||
|
const getOrg = async (orgId: string) => {
|
||||||
|
if (orgCache.has(orgId)) {
|
||||||
|
return orgCache.get(orgId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [org] = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
orgCache.set(orgId, org ?? null);
|
||||||
|
|
||||||
|
return org ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminRole = async (orgId: string) => {
|
||||||
|
if (adminRoleCache.has(orgId)) {
|
||||||
|
return adminRoleCache.get(orgId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
adminRoleCache.set(orgId, adminRole ?? null);
|
||||||
|
|
||||||
|
return adminRole ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExitNodes = async (orgId: string) => {
|
||||||
|
if (exitNodesCache.has(orgId)) {
|
||||||
|
return exitNodesCache.get(orgId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitNodes = await listExitNodes(orgId);
|
||||||
|
exitNodesCache.set(orgId, exitNodes);
|
||||||
|
|
||||||
|
return exitNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIsOrgLicensed = async (orgId: string) => {
|
||||||
|
if (isOrgLicensedCache.has(orgId)) {
|
||||||
|
return isOrgLicensedCache.get(orgId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.deviceApprovals
|
||||||
|
);
|
||||||
|
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
||||||
|
|
||||||
|
return isOrgLicensed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExistingClient = async (orgId: string, olmId: string) => {
|
||||||
|
const key = getOrgOlmKey(orgId, olmId);
|
||||||
|
if (existingClientCache.has(key)) {
|
||||||
|
return existingClientCache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await trx
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
eq(clients.olmId, olmId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
existingClientCache.set(key, existingClient ?? null);
|
||||||
|
|
||||||
|
return existingClient ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
|
||||||
|
const key = getRoleClientKey(roleId, clientId);
|
||||||
|
if (roleClientAccessCache.has(key)) {
|
||||||
|
return roleClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRoleClient] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleClients.roleId, roleId),
|
||||||
|
eq(roleClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingRoleClient);
|
||||||
|
roleClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserClientAccess = async (
|
||||||
|
cachedUserId: string,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getUserClientKey(cachedUserId, clientId);
|
||||||
|
if (userClientAccessCache.has(key)) {
|
||||||
|
return userClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserClient] = await trx
|
||||||
|
.select()
|
||||||
|
.from(userClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userClients.userId, cachedUserId),
|
||||||
|
eq(userClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingUserClient);
|
||||||
|
userClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all OLMs for this user
|
||||||
|
const userOlms = await trx
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.userId, userId));
|
||||||
|
|
||||||
|
if (userOlms.length === 0) {
|
||||||
|
// No OLMs for this user, but we should still clean up any orphaned clients
|
||||||
|
await cleanupOrphanedClients(
|
||||||
|
userId,
|
||||||
|
trx,
|
||||||
|
[],
|
||||||
|
queuedAssociationRebuilds
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user orgs with all roles (for org list and role-based logic)
|
||||||
|
const userOrgRoleRows = await trx
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(
|
||||||
|
userOrgRoles,
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrgRoles.userId),
|
||||||
|
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
const userOrgIds = [
|
||||||
|
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
||||||
|
];
|
||||||
|
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
|
||||||
|
for (const r of userOrgRoleRows) {
|
||||||
|
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||||
|
}
|
||||||
|
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
||||||
|
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
||||||
|
orgRequiresDeviceApprovalRole.set(
|
||||||
|
orgId,
|
||||||
|
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each OLM, ensure there's a client in each org the user is in
|
||||||
|
for (const olm of userOlms) {
|
||||||
|
for (const orgId of orgIdToRoleRows.keys()) {
|
||||||
|
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||||
|
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||||
|
|
||||||
|
const org = await getOrg(orgId);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.subnet) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role for this org (needed for access grants)
|
||||||
|
const adminRole = await getAdminRole(orgId);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a client already exists for this OLM+user+org combination
|
||||||
|
const existingClient = await getExistingClient(orgId, olm.olmId);
|
||||||
|
|
||||||
|
if (existingClient) {
|
||||||
|
// Ensure admin role has access to the client
|
||||||
|
const hasRoleAccess = await hasRoleClientAccess(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRoleAccess) {
|
||||||
|
await trx.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user has access to the client
|
||||||
|
const hasUserAccess = await hasUserClientAccess(
|
||||||
|
userId,
|
||||||
|
existingClient.clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasUserAccess) {
|
||||||
|
await trx.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, existingClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get exit nodes for this org
|
||||||
|
const exitNodesList = await getExitNodes(orgId);
|
||||||
|
|
||||||
|
if (exitNodesList.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomExitNode =
|
||||||
|
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
||||||
|
|
||||||
|
// Get next available subnet
|
||||||
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
|
await getNextAvailableClientSubnet(orgId, trx);
|
||||||
|
|
||||||
|
const subnet = newSubnet.split("/")[0];
|
||||||
|
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||||
|
|
||||||
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
userId,
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
exitNodeId: randomExitNode.exitNodeId,
|
||||||
|
name: olm.name || "User Client",
|
||||||
|
subnet: updatedSubnet,
|
||||||
|
olmId: olm.olmId,
|
||||||
|
type: "olm",
|
||||||
|
niceId,
|
||||||
|
approvalState: requireApproval ? "pending" : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the client
|
||||||
|
const [newClient] = await trx
|
||||||
|
.insert(clients)
|
||||||
|
.values(newClientData)
|
||||||
|
.returning();
|
||||||
|
await releaseSubnetLock();
|
||||||
|
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
|
||||||
|
|
||||||
|
// create approval request
|
||||||
|
if (requireApproval) {
|
||||||
|
await trx
|
||||||
|
.insert(approvals)
|
||||||
|
.values({
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
clientId: newClient.clientId,
|
||||||
|
userId,
|
||||||
|
type: "user_device"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedAssociationRebuilds.push(newClient);
|
||||||
|
|
||||||
|
// Grant admin role access to the client
|
||||||
|
await trx.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant user access to the client
|
||||||
|
await trx.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up clients in orgs the user is no longer in
|
||||||
|
await cleanupOrphanedClients(
|
||||||
|
userId,
|
||||||
|
trx,
|
||||||
|
userOrgIds,
|
||||||
|
queuedAssociationRebuilds
|
||||||
|
);
|
||||||
|
|
||||||
|
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction | typeof db,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = []
|
userOrgIds: string[] = [],
|
||||||
|
queuedAssociationRebuilds: ClientRow[] = []
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
||||||
@@ -468,9 +474,9 @@ async function cleanupOrphanedClients(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Rebuild associations for each deleted client to clean up related data
|
// Queue deleted clients for post-trx association cleanup.
|
||||||
for (const deletedClient of deletedClients) {
|
for (const deletedClient of deletedClients) {
|
||||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
queuedAssociationRebuilds.push(deletedClient);
|
||||||
|
|
||||||
if (deletedClient.olmId) {
|
if (deletedClient.olmId) {
|
||||||
await sendTerminateClient(
|
await sendTerminateClient(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.18.4";
|
export const APP_VERSION = "1.21.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
||||||
|
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
|
||||||
|
* retry: the database guarantees exactly one winner per deadlock pair, so the
|
||||||
|
* loser just needs to try again.
|
||||||
|
*/
|
||||||
|
export function isTransientError(error: any): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const message = (error.message || "").toLowerCase();
|
||||||
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
|
// Connection timeout / terminated
|
||||||
|
if (
|
||||||
|
message.includes("connection timeout") ||
|
||||||
|
message.includes("connection terminated") ||
|
||||||
|
message.includes("timeout exceeded when trying to connect") ||
|
||||||
|
causeMessage.includes("connection terminated unexpectedly") ||
|
||||||
|
causeMessage.includes("connection timeout")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
||||||
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
|
if (
|
||||||
|
code === "ECONNRESET" ||
|
||||||
|
code === "ECONNREFUSED" ||
|
||||||
|
code === "EPIPE" ||
|
||||||
|
code === "ETIMEDOUT"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
|
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||||
|
*/
|
||||||
|
export async function withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string,
|
||||||
|
maxRetries: number = MAX_RETRIES,
|
||||||
|
baseDelayMs: number = BASE_DELAY_MS
|
||||||
|
): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (isTransientError(error) && attempt < maxRetries) {
|
||||||
|
attempt++;
|
||||||
|
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
||||||
|
const jitter = Math.random() * baseDelay;
|
||||||
|
const delay = baseDelay + jitter;
|
||||||
|
logger.warn(
|
||||||
|
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
||||||
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
import { usageService } from "./billing/usageService";
|
import { usageService } from "./billing/usageService";
|
||||||
import { FeatureId } from "./billing";
|
import { LimitId } from "./billing";
|
||||||
|
|
||||||
export type DeleteOrgByIdResult = {
|
export type DeleteOrgByIdResult = {
|
||||||
deletedNewtIds: string[];
|
deletedNewtIds: string[];
|
||||||
@@ -140,7 +140,9 @@ export async function deleteOrgById(
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
logger.info(
|
||||||
|
`Found ${orgCount.count} orgs using domain ${domainId}`
|
||||||
|
);
|
||||||
if (orgCount.count === 1) {
|
if (orgCount.count === 1) {
|
||||||
domainIdsToDelete.push(domainId);
|
domainIdsToDelete.push(domainId);
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,7 @@ export async function deleteOrgById(
|
|||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
}
|
}
|
||||||
|
|
||||||
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
@@ -199,22 +201,22 @@ export async function deleteOrgById(
|
|||||||
if (org.billingOrgId) {
|
if (org.billingOrgId) {
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
FeatureId.DOMAINS,
|
LimitId.DOMAINS,
|
||||||
domainCount ?? 0
|
domainCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
FeatureId.SITES,
|
LimitId.SITES,
|
||||||
siteCount ?? 0
|
siteCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
FeatureId.USERS,
|
LimitId.USERS,
|
||||||
userCount ?? 0
|
userCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
FeatureId.REMOTE_EXIT_NODES,
|
LimitId.REMOTE_EXIT_NODES,
|
||||||
remoteExitNodeCount ?? 0
|
remoteExitNodeCount ?? 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
resourcePolicies,
|
||||||
|
resources,
|
||||||
|
sites,
|
||||||
|
targetHealthCheck,
|
||||||
|
targets,
|
||||||
|
type Resource,
|
||||||
|
type Target,
|
||||||
|
type TargetHealthCheck,
|
||||||
|
type Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { removeTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
|
export type DeleteResourceResult = {
|
||||||
|
deletedResource: Resource;
|
||||||
|
targetsToBeRemoved: Target[];
|
||||||
|
healthChecksToBeRemoved: TargetHealthCheck[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function performDeleteResources(
|
||||||
|
resourceIds: number[],
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<DeleteResourceResult[]> {
|
||||||
|
if (resourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetsToBeRemoved = await trx
|
||||||
|
.select()
|
||||||
|
.from(targets)
|
||||||
|
.where(inArray(targets.resourceId, resourceIds));
|
||||||
|
|
||||||
|
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
|
||||||
|
const healthChecksToBeRemoved =
|
||||||
|
targetIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select()
|
||||||
|
.from(targetHealthCheck)
|
||||||
|
.where(inArray(targetHealthCheck.targetId, targetIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const deletedResources = await trx
|
||||||
|
.delete(resources)
|
||||||
|
.where(inArray(resources.resourceId, resourceIds))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const policyIds = deletedResources
|
||||||
|
.map((resource) => resource.defaultResourcePolicyId)
|
||||||
|
.filter((id): id is number => id != null);
|
||||||
|
|
||||||
|
if (policyIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicies)
|
||||||
|
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedResources.length > 0) {
|
||||||
|
logger.debug(`Deleted ${deletedResources.length} resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetsByResourceId = new Map<number, Target[]>();
|
||||||
|
for (const target of targetsToBeRemoved) {
|
||||||
|
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
||||||
|
existing.push(target);
|
||||||
|
targetsByResourceId.set(target.resourceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIdToResourceId = new Map(
|
||||||
|
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
||||||
|
for (const healthCheck of healthChecksToBeRemoved) {
|
||||||
|
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
|
||||||
|
if (resourceId == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = healthChecksByResourceId.get(resourceId) ?? [];
|
||||||
|
existing.push(healthCheck);
|
||||||
|
healthChecksByResourceId.set(resourceId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedResources.map((deletedResource) => ({
|
||||||
|
deletedResource,
|
||||||
|
targetsToBeRemoved:
|
||||||
|
targetsByResourceId.get(deletedResource.resourceId) ?? [],
|
||||||
|
healthChecksToBeRemoved:
|
||||||
|
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performDeleteResource(
|
||||||
|
resourceId: number,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<DeleteResourceResult | null> {
|
||||||
|
const [result] = await performDeleteResources([resourceId], trx);
|
||||||
|
return result ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runResourceDeleteSideEffects(
|
||||||
|
result: DeleteResourceResult
|
||||||
|
): Promise<void> {
|
||||||
|
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
|
||||||
|
result;
|
||||||
|
|
||||||
|
for (const target of targetsToBeRemoved) {
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, target.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.debug(
|
||||||
|
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (site.pubKey && site.type === "newt") {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, site.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (newt) {
|
||||||
|
await removeTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[],
|
||||||
|
healthChecksToBeRemoved,
|
||||||
|
deletedResource.mode === "udp" ? "udp" : "tcp",
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
resources,
|
||||||
|
siteNetworks,
|
||||||
|
siteResources,
|
||||||
|
targets,
|
||||||
|
type SiteResource,
|
||||||
|
type Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import {
|
||||||
|
performDeleteResources,
|
||||||
|
runResourceDeleteSideEffects,
|
||||||
|
type DeleteResourceResult
|
||||||
|
} from "@server/lib/deleteResource";
|
||||||
|
import {
|
||||||
|
performDeleteSiteResources,
|
||||||
|
runSiteResourceDeleteSideEffects
|
||||||
|
} from "@server/lib/deleteSiteResource";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
|
||||||
|
|
||||||
|
export type DeleteSiteAssociatedResourcesSideEffects = {
|
||||||
|
resources: DeleteResourceResult[];
|
||||||
|
siteResources: SiteResource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const rows = await trx
|
||||||
|
.selectDistinct({ resourceId: targets.resourceId })
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.siteId, siteId));
|
||||||
|
|
||||||
|
return rows.map((row) => row.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSiteResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const rows = await trx
|
||||||
|
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.siteResourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAssociatedResourceCountForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number> {
|
||||||
|
const [publicCountResult, privateCountResult] = await Promise.all([
|
||||||
|
trx
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(distinct ${targets.resourceId})`
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(eq(targets.siteId, siteId)),
|
||||||
|
trx
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
|
||||||
|
})
|
||||||
|
.from(siteNetworks)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteNetworks.siteId, siteId),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number(publicCountResult[0]?.count ?? 0) +
|
||||||
|
Number(privateCountResult[0]?.count ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exceedsSiteAssociatedResourceDeleteLimit(
|
||||||
|
resourceCount: number
|
||||||
|
): boolean {
|
||||||
|
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||||
|
if (resourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, resourceIds),
|
||||||
|
eq(resources.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingSiteResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||||
|
if (siteResourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
|
eq(siteResources.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.siteResourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingAssociatedResourceCountForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number> {
|
||||||
|
const [resourceIds, siteResourceIds] = await Promise.all([
|
||||||
|
getPendingResourceIdsForSite(siteId, trx),
|
||||||
|
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return resourceIds.length + siteResourceIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAssociatedResourcesForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
||||||
|
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||||
|
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||||
|
|
||||||
|
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||||
|
performDeleteResources(resourceIds, trx),
|
||||||
|
performDeleteSiteResources(siteResourceIds, trx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePendingAssociatedResourcesForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
||||||
|
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
|
||||||
|
const siteResourceIds = await getPendingSiteResourceIdsForSite(
|
||||||
|
siteId,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||||
|
performDeleteResources(resourceIds, trx),
|
||||||
|
performDeleteSiteResources(siteResourceIds, trx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
||||||
|
sideEffects: DeleteSiteAssociatedResourcesSideEffects
|
||||||
|
): Promise<void> {
|
||||||
|
for (const result of sideEffects.resources) {
|
||||||
|
await runResourceDeleteSideEffects(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const removed of sideEffects.siteResources) {
|
||||||
|
runSiteResourceDeleteSideEffects(removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { inArray } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
siteResources,
|
||||||
|
type SiteResource,
|
||||||
|
type Transaction
|
||||||
|
} from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
export async function performDeleteSiteResources(
|
||||||
|
siteResourceIds: number[],
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<SiteResource[]> {
|
||||||
|
if (siteResourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedSiteResources = await trx
|
||||||
|
.delete(siteResources)
|
||||||
|
.where(inArray(siteResources.siteResourceId, siteResourceIds))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (removedSiteResources.length > 0) {
|
||||||
|
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return removedSiteResources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function performDeleteSiteResource(
|
||||||
|
siteResourceId: number,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<SiteResource | null> {
|
||||||
|
const [removedSiteResource] = await performDeleteSiteResources(
|
||||||
|
[siteResourceId],
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
return removedSiteResource ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runSiteResourceDeleteSideEffects(
|
||||||
|
removedSiteResource: SiteResource
|
||||||
|
): void {
|
||||||
|
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
|
||||||
|
(err) => {
|
||||||
|
logger.error(
|
||||||
|
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess(
|
|||||||
export async function listExitNodes(
|
export async function listExitNodes(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
filterOnline = false,
|
filterOnline = false,
|
||||||
noCloud = false
|
noCloud = false,
|
||||||
|
// Accepted for parity with the enterprise implementation (used there for
|
||||||
|
// site-label filtering of remote exit nodes). The OSS build has no remote
|
||||||
|
// exit nodes, so it is unused here.
|
||||||
|
siteId?: number
|
||||||
) {
|
) {
|
||||||
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
||||||
const allExitNodes = await db
|
const allExitNodes = await db
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
import { db, exitNodes } from "@server/db";
|
import { db, exitNodes, Transaction } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
export async function getNextAvailableSubnet(): Promise<string> {
|
/**
|
||||||
// Get all existing subnets from routes table
|
* Reserves the next available exit node subnet.
|
||||||
const existingAddresses = await db
|
*
|
||||||
.select({
|
* Exit node subnets must never overlap with one another - regardless of
|
||||||
address: exitNodes.address
|
* which org(s) they belong to - since HA exit nodes can end up routing for
|
||||||
})
|
* the same org. This acquires a lock that the caller MUST release (via the
|
||||||
.from(exitNodes);
|
* returned `release`) only after the chosen address has been durably
|
||||||
|
* persisted (e.g. after the enclosing transaction commits), otherwise
|
||||||
const addresses = existingAddresses.map((a) => a.address);
|
* concurrent callers can race and pick the same subnet.
|
||||||
let subnet = findNextAvailableCidr(
|
*/
|
||||||
addresses,
|
export async function getNextAvailableSubnet(
|
||||||
config.getRawConfig().gerbil.block_size,
|
trx: Transaction | typeof db = db
|
||||||
config.getRawConfig().gerbil.subnet_group
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
);
|
const lockKey = "exit-node-subnet-allocation";
|
||||||
if (!subnet) {
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
throw new Error("No available subnets remaining in space");
|
if (!acquired) {
|
||||||
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
|
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||||
|
|
||||||
// replace the last octet with 1
|
try {
|
||||||
subnet =
|
// Get all existing subnets from routes table
|
||||||
subnet.split(".").slice(0, 3).join(".") +
|
const existingAddresses = await trx
|
||||||
".1" +
|
.select({
|
||||||
"/" +
|
address: exitNodes.address
|
||||||
subnet.split("/")[1];
|
})
|
||||||
return subnet;
|
.from(exitNodes);
|
||||||
|
|
||||||
|
const addresses = existingAddresses.map((a) => a.address);
|
||||||
|
let subnet = findNextAvailableCidr(
|
||||||
|
addresses,
|
||||||
|
config.getRawConfig().gerbil.block_size,
|
||||||
|
config.getRawConfig().gerbil.subnet_group
|
||||||
|
);
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace the last octet with 1
|
||||||
|
subnet =
|
||||||
|
subnet.split(".").slice(0, 3).join(".") +
|
||||||
|
".1" +
|
||||||
|
"/" +
|
||||||
|
subnet.split("/")[1];
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-117
@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
|
|||||||
export async function getNextAvailableClientSubnet(
|
export async function getNextAvailableClientSubnet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
transaction: Transaction | typeof db = db
|
transaction: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
return await lockManager.withLock(
|
const lockKey = `client-subnet-allocation:${orgId}`;
|
||||||
`client-subnet-allocation:${orgId}`,
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
async () => {
|
if (!acquired) {
|
||||||
const [org] = await transaction
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
try {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
const [org] = await transaction
|
||||||
}
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org) {
|
||||||
throw new Error(
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddressesSites = await transaction
|
|
||||||
.select({
|
|
||||||
address: sites.address
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
|
||||||
|
|
||||||
const existingAddressesClients = await transaction
|
|
||||||
.select({
|
|
||||||
address: clients.subnet
|
|
||||||
})
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddressesSites.map(
|
|
||||||
(site) => `${site.address?.split("/")[0]}/32`
|
|
||||||
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
|
||||||
...existingAddressesClients.map(
|
|
||||||
(client) => `${client.address.split("/")}/32`
|
|
||||||
)
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
return subnet;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
if (!org.subnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddressesSites = await transaction
|
||||||
|
.select({
|
||||||
|
address: sites.address
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
|
||||||
|
|
||||||
|
const existingAddressesClients = await transaction
|
||||||
|
.select({
|
||||||
|
address: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddressesSites.map(
|
||||||
|
(site) => `${site.address?.split("/")[0]}/32`
|
||||||
|
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
|
||||||
|
...existingAddressesClients.map(
|
||||||
|
(client) => `${client.address.split("/")[0]}/32`
|
||||||
|
)
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableAliasAddress(
|
export async function getNextAvailableAliasAddress(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<string> {
|
): Promise<{ value: string; release: () => Promise<void> }> {
|
||||||
return await lockManager.withLock(
|
const lockKey = `alias-address-allocation:${orgId}`;
|
||||||
`alias-address-allocation:${orgId}`,
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
async () => {
|
if (!acquired) {
|
||||||
const [org] = await trx
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
.select()
|
}
|
||||||
.from(orgs)
|
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
|
|
||||||
if (!org) {
|
try {
|
||||||
throw new Error(`Organization with ID ${orgId} not found`);
|
const [org] = await trx
|
||||||
}
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
if (!org.subnet) {
|
if (!org) {
|
||||||
throw new Error(
|
throw new Error(`Organization with ID ${orgId} not found`);
|
||||||
`Organization with ID ${orgId} has no subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!org.utilitySubnet) {
|
|
||||||
throw new Error(
|
|
||||||
`Organization with ID ${orgId} has no utility subnet defined`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingAddresses = await trx
|
|
||||||
.select({
|
|
||||||
aliasAddress: siteResources.aliasAddress
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
isNotNull(siteResources.aliasAddress),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const addresses = [
|
|
||||||
...existingAddresses.map(
|
|
||||||
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
|
||||||
),
|
|
||||||
// reserve a /29 for the dns server and other stuff
|
|
||||||
`${org.utilitySubnet.split("/")[0]}/29`
|
|
||||||
].filter((address) => address !== null) as string[];
|
|
||||||
|
|
||||||
let subnet = findNextAvailableCidr(
|
|
||||||
addresses,
|
|
||||||
32,
|
|
||||||
org.utilitySubnet
|
|
||||||
);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the cidr
|
|
||||||
subnet = subnet.split("/")[0];
|
|
||||||
|
|
||||||
return subnet;
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
if (!org.subnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.utilitySubnet) {
|
||||||
|
throw new Error(
|
||||||
|
`Organization with ID ${orgId} has no utility subnet defined`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingAddresses = await trx
|
||||||
|
.select({
|
||||||
|
aliasAddress: siteResources.aliasAddress
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNotNull(siteResources.aliasAddress),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const addresses = [
|
||||||
|
...existingAddresses.map(
|
||||||
|
(site) => `${site.aliasAddress?.split("/")[0]}/32`
|
||||||
|
),
|
||||||
|
// reserve a /29 for the dns server and other stuff
|
||||||
|
`${org.utilitySubnet.split("/")[0]}/29`
|
||||||
|
].filter((address) => address !== null) as string[];
|
||||||
|
|
||||||
|
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
|
||||||
|
if (!subnet) {
|
||||||
|
throw new Error("No available subnets remaining in space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove the cidr
|
||||||
|
subnet = subnet.split("/")[0];
|
||||||
|
|
||||||
|
return { value: subnet, release };
|
||||||
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getNextAvailableOrgSubnet(): Promise<string> {
|
export async function getNextAvailableOrgSubnet(): Promise<{
|
||||||
return await lockManager.withLock("org-subnet-allocation", async () => {
|
value: string;
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const lockKey = "org-subnet-allocation";
|
||||||
|
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
||||||
|
if (!acquired) {
|
||||||
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
|
}
|
||||||
|
const release = () => lockManager.releaseLock(lockKey, acquired);
|
||||||
|
|
||||||
|
try {
|
||||||
const existingAddresses = await db
|
const existingAddresses = await db
|
||||||
.select({
|
.select({
|
||||||
subnet: orgs.subnet
|
subnet: orgs.subnet
|
||||||
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
|
|||||||
throw new Error("No available subnets remaining in space");
|
throw new Error("No available subnets remaining in space");
|
||||||
}
|
}
|
||||||
|
|
||||||
return subnet;
|
return { value: subnet, release };
|
||||||
});
|
} catch (e) {
|
||||||
|
await release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRemoteSubnets(
|
export function generateRemoteSubnets(
|
||||||
@@ -475,6 +496,7 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
|
if (!sr.enabled) return false;
|
||||||
if (!sr.destination) return false;
|
if (!sr.destination) return false;
|
||||||
|
|
||||||
if (sr.mode === "cidr") {
|
if (sr.mode === "cidr") {
|
||||||
@@ -483,7 +505,7 @@ export function generateRemoteSubnets(
|
|||||||
const parseResult = cidrSchema.safeParse(sr.destination);
|
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||||
return parseResult.success;
|
return parseResult.success;
|
||||||
}
|
}
|
||||||
if (sr.mode === "host") {
|
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||||
// check if its a valid IP using zod
|
// check if its a valid IP using zod
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
const parseResult = ipSchema.safeParse(sr.destination);
|
const parseResult = ipSchema.safeParse(sr.destination);
|
||||||
@@ -493,7 +515,7 @@ export function generateRemoteSubnets(
|
|||||||
})
|
})
|
||||||
.map((sr) => {
|
.map((sr) => {
|
||||||
if (sr.mode === "cidr") return sr.destination;
|
if (sr.mode === "cidr") return sr.destination;
|
||||||
if (sr.mode === "host") {
|
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||||
return `${sr.destination}/32`;
|
return `${sr.destination}/32`;
|
||||||
}
|
}
|
||||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||||
@@ -509,8 +531,9 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
return allSiteResources
|
return allSiteResources
|
||||||
.filter(
|
.filter(
|
||||||
(sr) =>
|
(sr) =>
|
||||||
|
sr.enabled &&
|
||||||
sr.aliasAddress &&
|
sr.aliasAddress &&
|
||||||
((sr.alias && sr.mode == "host") ||
|
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
||||||
(sr.fullDomain && sr.mode == "http"))
|
(sr.fullDomain && sr.mode == "http"))
|
||||||
)
|
)
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
@@ -556,6 +579,10 @@ export function generateSubnetProxyTargets(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!siteResource.destination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -563,7 +590,7 @@ export function generateSubnetProxyTargets(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host") {
|
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -637,6 +664,13 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[]
|
}[]
|
||||||
): Promise<SubnetProxyTargetV2[] | undefined> {
|
): Promise<SubnetProxyTargetV2[] | undefined> {
|
||||||
|
if (!siteResource.enabled) {
|
||||||
|
logger.debug(
|
||||||
|
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (clients.length === 0) {
|
if (clients.length === 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||||
@@ -644,7 +678,12 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targets: SubnetProxyTargetV2[] = [];
|
if (!siteResource.destination) {
|
||||||
|
// ssh can have no destination
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets: SubnetProxyTargetV2[] = [];
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -652,7 +691,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host") {
|
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -875,7 +914,13 @@ export const portRangeStringSchema = z
|
|||||||
message:
|
message:
|
||||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
'Port range string. Use "*" for all ports, a comma-separated list of ports, or ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.',
|
||||||
|
example: "80,443,8000-9000"
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a port range string into an array of port range objects
|
* Parses a port range string into an array of port range objects
|
||||||
|
|||||||
+144
-24
@@ -1,28 +1,87 @@
|
|||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
||||||
|
|
||||||
|
type LocalLockRecord = {
|
||||||
|
owner: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localLocks = new Map<string, LocalLockRecord>();
|
||||||
|
|
||||||
export class LockManager {
|
export class LockManager {
|
||||||
/**
|
private clearExpiredLocalLock(lockKey: string): void {
|
||||||
* Acquire a distributed lock using Redis SET with NX and PX options
|
const current = localLocks.get(lockKey);
|
||||||
* @param lockKey - Unique identifier for the lock
|
if (current && current.expiresAt <= Date.now()) {
|
||||||
* @param ttlMs - Time to live in milliseconds
|
localLocks.delete(lockKey);
|
||||||
* @returns Promise<boolean> - true if lock acquired, false otherwise
|
}
|
||||||
*/
|
|
||||||
async acquireLock(
|
|
||||||
lockKey: string,
|
|
||||||
ttlMs: number = 30000
|
|
||||||
): Promise<boolean> {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release a lock using Lua script to ensure atomicity
|
* Acquire a local in-process lock using an optimistic Map-based check.
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
|
* @param ttlMs - Time to live in milliseconds
|
||||||
|
* @returns Promise<string | null> - a token identifying this specific acquisition
|
||||||
|
* (truthy) on success, or null if the lock could not be acquired.
|
||||||
*/
|
*/
|
||||||
async releaseLock(lockKey: string): Promise<void> {}
|
async acquireLock(
|
||||||
|
lockKey: string,
|
||||||
|
ttlMs: number = 30000,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
retryDelayMs: number = 100
|
||||||
|
): Promise<string | null> {
|
||||||
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
|
this.clearExpiredLocalLock(lockKey);
|
||||||
|
|
||||||
|
const existing = localLocks.get(lockKey);
|
||||||
|
if (!existing) {
|
||||||
|
const token = `${instanceId}:${randomUUID()}`;
|
||||||
|
localLocks.set(lockKey, {
|
||||||
|
owner: token,
|
||||||
|
expiresAt: Date.now() + ttlMs
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lock is currently held -- possibly by a different, unrelated
|
||||||
|
// caller in this same process. We intentionally do NOT treat
|
||||||
|
// same-process holders as automatically reentrant here: two
|
||||||
|
// independent logical operations (e.g. two different API requests)
|
||||||
|
// running concurrently in the same process must not both believe
|
||||||
|
// they hold the lock, or their writes under it can interleave
|
||||||
|
// unguarded. Just retry with backoff like any other contended lock.
|
||||||
|
if (attempt < maxRetries - 1) {
|
||||||
|
const delay = retryDelayMs * Math.pow(2, attempt);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a lock previously acquired via acquireLock/acquireLockWithRetry.
|
||||||
|
* @param lockKey - Unique identifier for the lock
|
||||||
|
* @param token - the exact token returned by the acquisition being released.
|
||||||
|
* Required so a caller whose TTL already expired can't delete a
|
||||||
|
* different, currently-active holder's lock.
|
||||||
|
*/
|
||||||
|
async releaseLock(lockKey: string, token: string): Promise<void> {
|
||||||
|
this.clearExpiredLocalLock(lockKey);
|
||||||
|
const existing = localLocks.get(lockKey);
|
||||||
|
|
||||||
|
if (existing && existing.owner === token) {
|
||||||
|
localLocks.delete(lockKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force release a lock regardless of owner (use with caution)
|
* Force release a lock regardless of owner (use with caution)
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
*/
|
*/
|
||||||
async forceReleaseLock(lockKey: string): Promise<void> {}
|
async forceReleaseLock(lockKey: string): Promise<void> {
|
||||||
|
localLocks.delete(lockKey);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a lock exists and get its info
|
* Check if a lock exists and get its info
|
||||||
@@ -35,16 +94,44 @@ export class LockManager {
|
|||||||
ttl: number;
|
ttl: number;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
}> {
|
}> {
|
||||||
return { exists: true, ownedByMe: true, ttl: 0 };
|
this.clearExpiredLocalLock(lockKey);
|
||||||
|
const existing = localLocks.get(lockKey);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return { exists: false, ownedByMe: false, ttl: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = Math.max(0, existing.expiresAt - Date.now());
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
|
||||||
|
ttl,
|
||||||
|
owner: existing.owner.split(":")[0]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the TTL of an existing lock owned by this worker
|
* Extend the TTL of an existing lock, provided the token matches the
|
||||||
|
* acquisition currently holding it.
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param ttlMs - New TTL in milliseconds
|
* @param ttlMs - New TTL in milliseconds
|
||||||
|
* @param token - the token returned by the acquisition being extended
|
||||||
* @returns Promise<boolean> - true if extended successfully
|
* @returns Promise<boolean> - true if extended successfully
|
||||||
*/
|
*/
|
||||||
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
|
async extendLock(
|
||||||
|
lockKey: string,
|
||||||
|
ttlMs: number,
|
||||||
|
token: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.clearExpiredLocalLock(lockKey);
|
||||||
|
const existing = localLocks.get(lockKey);
|
||||||
|
|
||||||
|
if (!existing || existing.owner !== token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.expiresAt = Date.now() + ttlMs;
|
||||||
|
localLocks.set(lockKey, existing);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,15 +141,34 @@ export class LockManager {
|
|||||||
* @param ttlMs - Time to live in milliseconds
|
* @param ttlMs - Time to live in milliseconds
|
||||||
* @param maxRetries - Maximum number of retry attempts
|
* @param maxRetries - Maximum number of retry attempts
|
||||||
* @param baseDelayMs - Base delay between retries in milliseconds
|
* @param baseDelayMs - Base delay between retries in milliseconds
|
||||||
* @returns Promise<boolean> - true if lock acquired
|
* @returns Promise<string | null> - token if acquired, null otherwise
|
||||||
*/
|
*/
|
||||||
async acquireLockWithRetry(
|
async acquireLockWithRetry(
|
||||||
lockKey: string,
|
lockKey: string,
|
||||||
ttlMs: number = 30000,
|
ttlMs: number = 30000,
|
||||||
maxRetries: number = 5,
|
maxRetries: number = 5,
|
||||||
baseDelayMs: number = 100
|
baseDelayMs: number = 100
|
||||||
): Promise<boolean> {
|
): Promise<string | null> {
|
||||||
return true;
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
const acquired = await this.acquireLock(
|
||||||
|
lockKey,
|
||||||
|
ttlMs,
|
||||||
|
1,
|
||||||
|
baseDelayMs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (acquired) {
|
||||||
|
return acquired;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay =
|
||||||
|
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,16 +183,16 @@ export class LockManager {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
ttlMs: number = 30000
|
ttlMs: number = 30000
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const acquired = await this.acquireLock(lockKey, ttlMs);
|
const token = await this.acquireLock(lockKey, ttlMs);
|
||||||
|
|
||||||
if (!acquired) {
|
if (!token) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(lockKey);
|
await this.releaseLock(lockKey, token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +205,21 @@ export class LockManager {
|
|||||||
activeLocksCount: number;
|
activeLocksCount: number;
|
||||||
locksOwnedByMe: number;
|
locksOwnedByMe: number;
|
||||||
}> {
|
}> {
|
||||||
return { activeLocksCount: 0, locksOwnedByMe: 0 };
|
const now = Date.now();
|
||||||
|
for (const [key, value] of localLocks.entries()) {
|
||||||
|
if (value.expiresAt <= now) {
|
||||||
|
localLocks.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let locksOwnedByMe = 0;
|
||||||
|
for (const value of localLocks.values()) {
|
||||||
|
if (value.owner.startsWith(`${instanceId}:`)) {
|
||||||
|
locksOwnedByMe++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeLocksCount: localLocks.size, locksOwnedByMe };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export function createApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
|
||||||
|
return z.object({
|
||||||
|
data: dataSchema.nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user