Compare commits
1726 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a5b68374f | |||
| 813f27f28f | |||
| 2b372a4e71 | |||
| 0098c788a2 | |||
| cb4e87fc77 | |||
| c8891b20ba | |||
| ef48f8758a | |||
| 27816c0d34 | |||
| a492b6122c | |||
| 0985e019a8 | |||
| 55c955fa0e | |||
| c37d02a963 | |||
| 19124ca613 | |||
| 9954e5b2a6 | |||
| c5dc579708 | |||
| 70585816e5 | |||
| 8311b45baa | |||
| 946ab75ab9 | |||
| 9aef3f9f83 | |||
| 10cb32c9f3 | |||
| eaaa6b68ac | |||
| 9762c3f157 | |||
| 70cf676651 | |||
| 993e5188cb | |||
| 1e9977475c | |||
| 136b26e462 | |||
| c855012a94 | |||
| ef60e98950 | |||
| 467d809010 | |||
| 63a41c7109 | |||
| 7f140cbbc7 | |||
| b3cd1028bc | |||
| c79fdc41c6 | |||
| 76d8d30aef | |||
| 7b1f18378e | |||
| 15032e34d3 | |||
| 78ede75468 | |||
| 78849f3971 | |||
| 5e6c3e262e | |||
| ba53a0bdff | |||
| fdb3d25a99 | |||
| c69aee227e | |||
| 125d1ea2e4 | |||
| eef26ee2c5 | |||
| 6c683f9575 | |||
| 05184ed7aa | |||
| f477c1c35b | |||
| e223bf3683 | |||
| ace4f85cb7 | |||
| aa48d6a111 | |||
| 5d5e8770b9 | |||
| e9cc9ee5de | |||
| eb7a76bd29 | |||
| 45653b2968 | |||
| a06c5f65f3 | |||
| e7741d1a3e | |||
| 0ac97ecd5e | |||
| 387049beac | |||
| c9240ecb84 | |||
| b87e71c557 | |||
| 866293aa5a | |||
| e142dd32b4 | |||
| 949786dab5 | |||
| 2dd142b0e9 | |||
| dfd16a6752 | |||
| f4454d4d48 | |||
| e7efc917f0 | |||
| 5ffe1ba07d | |||
| b56e2972c4 | |||
| ca1a084397 | |||
| a7a1f81e9d | |||
| 9c09f17dc5 | |||
| 21e2c022c7 | |||
| 222cbc886d | |||
| db2e76bd31 | |||
| bf32cc150d | |||
| 967de0b79f | |||
| 22231e6c45 | |||
| 20ed9966b9 | |||
| dddf060e1a | |||
| a569054e94 | |||
| 5885e8eb39 | |||
| 22964cff0f | |||
| e952c2d34a | |||
| 0a043af482 | |||
| 8324445895 | |||
| 796d14a9e4 | |||
| 707cc4b275 | |||
| 93400ace27 | |||
| 6fb8dae966 | |||
| a27a169160 | |||
| f0a1de3474 | |||
| 79c6fcac95 | |||
| 50697e32c2 | |||
| 6fe74a9f8d | |||
| a246de2b1f | |||
| 5ac8e4e098 | |||
| aa95e5bb86 | |||
| 7d13ed79b2 | |||
| c1f65c802c | |||
| bcc429221e | |||
| bd73609b9e | |||
| 2dbb21a7f2 | |||
| fe68533ff2 | |||
| 01a40daf38 | |||
| 097744275f | |||
| e481a4d847 | |||
| 95c6bb4de6 | |||
| 18e194e152 | |||
| b2f391307b | |||
| a4da3c7ba2 | |||
| af3abef3bf | |||
| f7633a43ce | |||
| ffd345f044 | |||
| ae36d3228f | |||
| 1c78a6b483 | |||
| b6c6590aad | |||
| 5a792e9913 | |||
| a2f822889d | |||
| 83ba463a34 | |||
| a909c5cbe0 | |||
| d615f34f94 | |||
| 37378895cf | |||
| 19ef055296 | |||
| 599fa5eb30 | |||
| 4d82b37cab | |||
| 77d01d50db | |||
| 013c1ab92c | |||
| d4fc60f2f4 | |||
| cd25cde47f | |||
| af709331fb | |||
| e20a21bacd | |||
| 74b3b283f7 | |||
| 9fe4f78269 | |||
| 03d95874e6 | |||
| bd3d6994c1 | |||
| 5fd78817a8 | |||
| 72bc125f84 | |||
| 5d51af4330 | |||
| b18ea66def | |||
| 93998f9fd5 | |||
| c554e69514 | |||
| a6e10e55cc | |||
| 41f541a531 | |||
| 9cb1043545 | |||
| 96e33d33b0 | |||
| ccc7003ac1 | |||
| 93cbd47b5d | |||
| 8b808e44b6 | |||
| 0644e26297 | |||
| 682653b977 | |||
| 0053cfc8fc | |||
| 5cb62a30cc | |||
| e596a63058 | |||
| 3ec32afb37 | |||
| 0189a86757 | |||
| ee32307654 | |||
| 2f08e6b838 | |||
| c8a3fc350d | |||
| dc63ef1284 | |||
| 92332fb02f | |||
| acc6a26654 | |||
| 2bd4d2faaf | |||
| 1e77ead488 | |||
| c008ef7c1b | |||
| 02dfeed3ce | |||
| 34cc2e0ed1 | |||
| f5d0694574 | |||
| f91da2ec46 | |||
| 89471a0174 | |||
| 0cb04d0290 | |||
| e118e5b047 | |||
| 7e4e8ea266 | |||
| 2f386f8e47 | |||
| f4ea572f6b | |||
| 825df7da63 | |||
| cd34f0a7b0 | |||
| b1b22c439a | |||
| eac747849b | |||
| 1aedf9da0a | |||
| 840684aeba | |||
| f57012eb90 | |||
| 34387d9859 | |||
| 80f5914fdd | |||
| eaa70da4dd | |||
| 466f137590 | |||
| 028df8bf27 | |||
| 28ef5238c9 | |||
| 7d3d5b2b22 | |||
| 81eba50c9a | |||
| 3436105bec | |||
| d948d2ec33 | |||
| 4b3375ab8e | |||
| 6b8a3c8d77 | |||
| ba9794c067 | |||
| 6ce165bfd5 | |||
| eb4b2daaab | |||
| 8cbc8dec89 | |||
| e89e60d50b | |||
| c45308f234 | |||
| 40205c40c5 | |||
| f3fe2dd33b | |||
| 8edcc45033 | |||
| 91471a4aca | |||
| ae2c37a2f6 | |||
| c8208f0a88 | |||
| e11dfbd29c | |||
| b375d20598 | |||
| c4b82c69f8 | |||
| c9a00420a0 | |||
| 36ef9cd442 | |||
| 5e08779ab0 | |||
| 16a0e1ce7b | |||
| 8b03484ade | |||
| 9da9974adf | |||
| 6f80cf3db2 | |||
| 76d8f44779 | |||
| 700c92efcb | |||
| d17e0c9f50 | |||
| f00b9794f5 | |||
| daff59c93f | |||
| aa8954366c | |||
| 87464d53bd | |||
| e04f17c9aa | |||
| b25e3499d8 | |||
| 2e6f74a6f8 | |||
| 8eee0ca5a5 | |||
| c2ebc0a0ff | |||
| 03c905a7af | |||
| 035644eaf7 | |||
| 8ce45a1acd | |||
| 16e7233a3e | |||
| a331dd3fb4 | |||
| e3e2938b28 | |||
| 73e96b1b28 | |||
| b8194295ec | |||
| 382a46dfff | |||
| 1f74e1b320 | |||
| fee780cb81 | |||
| 5056cba85d | |||
| dab38ff82c | |||
| d83fa63af5 | |||
| d5837ab718 | |||
| f85cfc4c68 | |||
| 0b2aceafe0 | |||
| 059db34a53 | |||
| bc1ea86b4e | |||
| 9f2ced1933 | |||
| 013cff9b6e | |||
| aa19437031 | |||
| e848ef848b | |||
| bb6605337f | |||
| 8df8383468 | |||
| a7e9de3ac4 | |||
| 8df41f514e | |||
| c2bf50b121 | |||
| 4e7dcbd7b5 | |||
| b7ccb92236 | |||
| 23a151dd45 | |||
| 122079ddb2 | |||
| 1d0b0ae6ec | |||
| a55dd769cf | |||
| f1a0bc97e3 | |||
| a57dfd1d12 | |||
| c0a8304b91 | |||
| ab7b968e28 | |||
| f10b40c3b0 | |||
| 7878ac9c76 | |||
| 0752951842 | |||
| 06bb6636a1 | |||
| 2fdd332a31 | |||
| 98b1e9546a | |||
| 184aa65c6d | |||
| 70b3a432a4 | |||
| fb4fc75bd8 | |||
| 0479ed9e7f | |||
| 1dc3409135 | |||
| 1bb89fce26 | |||
| 8f3fbb94d2 | |||
| e8c35bec1c | |||
| 728e7252eb | |||
| 1218507f7d | |||
| a2dff0a35d | |||
| f411180908 | |||
| 231a19b679 | |||
| 58a87a986a | |||
| 61a78ef352 | |||
| e28e5ebb4e | |||
| 19cef8c453 | |||
| 1290d6cd5c | |||
| ad301074db | |||
| 30a756d254 | |||
| 363c13c387 | |||
| 08e4afaef0 | |||
| 69aa6e2d1d | |||
| 547865e0da | |||
| 3a9e79e6d5 | |||
| 0fc1aa9191 | |||
| f34a3559be | |||
| ddf417f4ca | |||
| f2abbf01e5 | |||
| d08be59055 | |||
| 44bb87e4ac | |||
| 1d2f1405aa | |||
| ff64a79014 | |||
| f6cdadbc2d | |||
| 546769ca66 | |||
| d07996d435 | |||
| 467808f174 | |||
| 64d3c6b2d9 | |||
| 75193bb0a2 | |||
| 82ba2bd809 | |||
| 8559942c5c | |||
| a7fefc84a8 | |||
| c8e83fedeb | |||
| 4bf148a4bf | |||
| 44664faf3c | |||
| 322c136d1f | |||
| 3b8dd45a73 | |||
| c1bd36231d | |||
| 2cee723f0e | |||
| edfeec900d | |||
| 958bde2090 | |||
| 29b272f5d5 | |||
| 9162ac6d91 | |||
| fe30bb280e | |||
| a1e9396999 | |||
| 2a1c290dff | |||
| d155d7e31b | |||
| 3dc258da16 | |||
| 0db1397f2f | |||
| 0254fb1695 | |||
| 954b492aa9 | |||
| 8aadc10530 | |||
| 6ea719c50f | |||
| b50886179a | |||
| e06f2f47b1 | |||
| ed8c8bedcd | |||
| 1711e39219 | |||
| a73879ec7a | |||
| 45c613dec4 | |||
| 5150a2c386 | |||
| ca0dd09964 | |||
| 5e0e4f1452 | |||
| 3ed72dd96b | |||
| b8d7d5c910 | |||
| 673b8b7af5 | |||
| a651e50759 | |||
| 6484e8e302 | |||
| b01d266629 | |||
| 4465b05404 | |||
| d1182c3a59 | |||
| cb6c47678b | |||
| 8106620a19 | |||
| be3e066843 | |||
| e345c6ee6e | |||
| 073b89b355 | |||
| 5cad07f8ad | |||
| f9d872558e | |||
| c5015d02ae | |||
| 48013228c1 | |||
| dbafffe73d | |||
| 61cbcb2a06 | |||
| 89c1ad5d98 | |||
| b343ca6290 | |||
| b913466671 | |||
| 9054f4f9c3 | |||
| 3915024d9a | |||
| 7d1085b43f | |||
| 7c2477cccc | |||
| 5aecb5fb90 | |||
| f86d040ee4 | |||
| ed32717b3f | |||
| aab8462134 | |||
| 04943fb4a6 | |||
| e0c96e7224 | |||
| caacd1e677 | |||
| c995c5a674 | |||
| 1e9544af07 | |||
| c20dfdabfb | |||
| 11a6f1f47f | |||
| fcf92d4e2c | |||
| 77cef554be | |||
| 9dc9b6a2c3 | |||
| 9808a48da0 | |||
| 8a6960d9c3 | |||
| d966ef66e1 | |||
| ed97cf5d97 | |||
| a3b088f8d2 | |||
| 2828dee94c | |||
| bdc45887f9 | |||
| ee6fb34906 | |||
| bff2ba7cc2 | |||
| 8e821b397f | |||
| 6f71af278e | |||
| 757bb39622 | |||
| 00ef6d617f | |||
| d1b2105c80 | |||
| 50ee28b1f7 | |||
| ba529ad14e | |||
| 6ab0555148 | |||
| c6f269b3fa | |||
| 8e160902af | |||
| 7bcb852dba | |||
| ed604c8810 | |||
| bea20674a8 | |||
| 177926932b | |||
| 04dfbd0a14 | |||
| 06f840a680 | |||
| 5ddcfeb506 | |||
| a143b7de7c | |||
| 63372b174f | |||
| ad7d68d2b4 | |||
| e05af54f76 | |||
| 914e95e47f | |||
| 13eadeaa8f | |||
| 19a686b3e4 | |||
| d046084e84 | |||
| e13a076939 | |||
| b4ca6432db | |||
| 5b9efc3c5f | |||
| 6d7a19b0a0 | |||
| 6b3a6fa380 | |||
| e2a65b4b74 | |||
| 1f01108b62 | |||
| c80c7df1d0 | |||
| 99a064b77a | |||
| 9b84623d0c | |||
| 6bb6cf8a48 | |||
| 348fcbcabf | |||
| 1f4cde5f7f | |||
| 3e3b02021c | |||
| 17eb93d045 | |||
| 660420ddef | |||
| 395cab795c | |||
| 0fecbe704b | |||
| ce59a8a52b | |||
| 2091b5f359 | |||
| 62c63ddcaa | |||
| dfd604c781 | |||
| 3525b367b3 | |||
| 0b5b6ed5a3 | |||
| 6fe9494df4 | |||
| b2eab95a3b | |||
| 38d30b0214 | |||
| c96c5e8ae8 | |||
| 6f71e9f0f2 | |||
| d17ec6dc1f | |||
| 212b7a104f | |||
| d21dfb750e | |||
| c36a019f5d | |||
| cf2dfdea5b | |||
| 985e1bb9ab | |||
| fff38aac85 | |||
| 7db58f920c | |||
| e9b16b8801 | |||
| 5a2a97b23a | |||
| 5b894e8682 | |||
| 84925f724d | |||
| 7b78b91449 | |||
| f9bff5954f | |||
| 2c6e9507b5 | |||
| 6471571bc6 | |||
| fe40ea58c1 | |||
| 0d4edcd1c7 | |||
| 7d8797840a | |||
| 19f8c1772f | |||
| 37d331e813 | |||
| c660df55cd | |||
| 60982bf19f | |||
| 7c8b865379 | |||
| 3cca0c09c0 | |||
| 85335bfecc | |||
| 7c2b4f422a | |||
| ad2a0ae127 | |||
| 871f14ef3a | |||
| 6c2c620c99 | |||
| f643abf19a | |||
| a1729033cf | |||
| 7311766512 | |||
| 17105f3a51 | |||
| edcfbd26e4 | |||
| 0c4d9ea164 | |||
| a5a5224f5c | |||
| 8773f7c0a7 | |||
| f385bc2d22 | |||
| a8c9d2e7e6 | |||
| db3f90318b | |||
| 2d4d0df5ca | |||
| 569ebc671d | |||
| 8c8e4e6233 | |||
| c7901ef74b | |||
| be3bd72c1b | |||
| 73d1f9288d | |||
| fb7e9f6898 | |||
| 38e4b3077f | |||
| 312cdc563b | |||
| 48ff6dd705 | |||
| 695e831090 | |||
| 046b431bb8 | |||
| ce2704fc1a | |||
| 7e89b36188 | |||
| 222dd6bba3 | |||
| ca9ab65228 | |||
| ee4e8f7029 | |||
| f86a1eb32b | |||
| ffd648ed74 | |||
| b2b72169fd | |||
| 76746fb6e1 | |||
| 6258787c73 | |||
| 720080e487 | |||
| 46ad1317e4 | |||
| cd28720e46 | |||
| 38af02ad3c | |||
| 5eed547f91 | |||
| d363ee02ed | |||
| 594ee31f43 | |||
| 56e25d01ae | |||
| e0fa5607e5 | |||
| 572c9bf319 | |||
| 52cac4aa21 | |||
| e358d12765 | |||
| 02697e27a4 | |||
| ce58e71c44 | |||
| d9766b0f99 | |||
| eeaa1d56ad | |||
| e7f5bc585c | |||
| 4f26fb7750 | |||
| cdbc190bfc | |||
| 1b1f9ab4cf | |||
| 2efe6cfdb3 | |||
| 517c607ecf | |||
| 802e8f7a22 | |||
| c7cfe2efcb | |||
| ae1f36f39a | |||
| a479ef28ac | |||
| ce2cf50b5a | |||
| f48d01acde | |||
| 991fed93ee | |||
| 26ab63d0e4 | |||
| e15703164d | |||
| 8f33e25782 | |||
| 722595c131 | |||
| 4843268537 | |||
| c9be84a8a8 | |||
| 03288d2a60 | |||
| f60ae13e4e | |||
| e72697f8b8 | |||
| 0c3dc1ad14 | |||
| 840fe86f78 | |||
| e079927a5b | |||
| 63379964fa | |||
| 0cfaf6ed7f | |||
| 043ee9e9d2 | |||
| 1d5dfd6db2 | |||
| b63e3e5888 | |||
| 4f82470506 | |||
| 40e21b6f28 | |||
| 67fab1928d | |||
| eb98374566 | |||
| 1169b68619 | |||
| 6c83e78256 | |||
| d3bfd67738 | |||
| 0908f0f057 | |||
| 2785449c7a | |||
| d2419ba572 | |||
| d44292cf33 | |||
| 435cae06a2 | |||
| 18ed38889f | |||
| aed86ce4ba | |||
| 2c2be50b19 | |||
| e2db4c6246 | |||
| c4839fee08 | |||
| 965b7026f0 | |||
| e14e15fcbb | |||
| 4ca5acf158 | |||
| ea41fcc566 | |||
| 5736c1d8ce | |||
| d142366dd9 | |||
| bab09dff95 | |||
| 23d3345ab9 | |||
| 09a64815d4 | |||
| 6d5f969798 | |||
| 10349932f4 | |||
| 9c430b37aa | |||
| ad3fe2fa76 | |||
| 863eb8efe9 | |||
| 86bba494fe | |||
| 1a43f1ef4b | |||
| 75ab074805 | |||
| dc4e0253de | |||
| 47a99e35ee | |||
| cccf236042 | |||
| 63fd63c65c | |||
| beee1d692d | |||
| fde786ca84 | |||
| 3086fdd064 | |||
| 6c30f6db31 | |||
| 84b082e194 | |||
| f021b73458 | |||
| 74f4751bcc | |||
| e5bce4e180 | |||
| 9b0e7b381c | |||
| 90afe5a7ac | |||
| b24de85157 | |||
| eda43dffe1 | |||
| 82c9a1eb70 | |||
| a3d4553d14 | |||
| 1cc5f59f66 | |||
| 4e2d88efdd | |||
| 4975cabb2c | |||
| 225591094f | |||
| 82f88f2cd3 | |||
| 99e6bd31b6 | |||
| 5c50590d7b | |||
| 072c89e704 | |||
| dbdff6812d | |||
| 42b9d5158d | |||
| 2ba225299e | |||
| cc841d5640 | |||
| fa0818d3fa | |||
| dec358c4cd | |||
| 5455d1c118 | |||
| ae39084a75 | |||
| e98f873f81 | |||
| e9a2a7e752 | |||
| 06015d5191 | |||
| af688d2a23 | |||
| 7d0b3ec6b5 | |||
| cf5fb8dc33 | |||
| 27d20eb1bc | |||
| 2e2684c695 | |||
| 7e2fd8f49d | |||
| 9a0a255445 | |||
| 91b7ceb2cf | |||
| d5a37436c0 | |||
| be609b5000 | |||
| 0503c6e66e | |||
| d4b830b9bb | |||
| 14d6ff25a7 | |||
| 1f62f305ce | |||
| 9405b0b70a | |||
| a26ee4ac1a | |||
| cebcf3e337 | |||
| 4cfcc64481 | |||
| 1a2069a6d9 | |||
| 2a5c9465e9 | |||
| f36b66e397 | |||
| 8c6d44677d | |||
| 1bfff630bf | |||
| ebcef28b05 | |||
| e87e12898c | |||
| d60ab281cf | |||
| 483d54a9f0 | |||
| 0ab6ff9148 | |||
| c73a39f797 | |||
| c87b6872e5 | |||
| f315c8bc43 | |||
| 20fa1519fd | |||
| 54430afc40 | |||
| 7990d08fee | |||
| e9042d9e2e | |||
| 24a15841e4 | |||
| bb8f6e09fd | |||
| 04bc8ab694 | |||
| 6ac8335cf2 | |||
| 4c6144f8fb | |||
| 255003794e | |||
| 119d5c79a0 | |||
| 8e2d7c25df | |||
| 753dee3023 | |||
| cac0272952 | |||
| ee5b74f9fc | |||
| 1362b72cd3 | |||
| 35b1566962 | |||
| a4bcce5a0c | |||
| c03f1946e8 | |||
| c11e107758 | |||
| 3b4e49f63a | |||
| ea7253f7e8 | |||
| 8a529f7946 | |||
| e76612e018 | |||
| e1f99985d8 | |||
| e0c2735635 | |||
| 8e6b4e243d | |||
| 2623fa8f02 | |||
| 7ff92d32cd | |||
| c7f691b20a | |||
| db042e520e | |||
| 4cab693cfc | |||
| c9515ae77c | |||
| d14de86f65 | |||
| f6ee9db730 | |||
| 94353aea44 | |||
| b01fcc70fe | |||
| ed95f10fcc | |||
| 35fed74e49 | |||
| 64bae5b142 | |||
| 6cf1b9b010 | |||
| dae169540b | |||
| 19f9dda490 | |||
| a060c8029f | |||
| aca9d1e070 | |||
| cdf79edb00 | |||
| df53dfc936 | |||
| 8e2e09ab81 | |||
| 1eac7cbccd | |||
| ddaaed65e4 | |||
| 8e633c21c7 | |||
| e7c4ef44d8 | |||
| 3d71470bd2 | |||
| dd627a222e | |||
| 62cc20fa1c | |||
| 0450fc9f57 | |||
| c58aaf5ba6 | |||
| 655522d4e2 | |||
| 225475dcae | |||
| ccb977fdfb | |||
| 280cbb6e22 | |||
| c20babcb53 | |||
| 768eebe2cd | |||
| 44e3eedffa | |||
| bb189874cb | |||
| 34dadd0e16 | |||
| 87b5cd9988 | |||
| 6a537a23e8 | |||
| e63a6e9b77 | |||
| 7ce589c4f2 | |||
| 75a909784a | |||
| 244f497a9c | |||
| e58f0c9f07 | |||
| 5f18c06e03 | |||
| f36cf06e26 | |||
| 27d52646a0 | |||
| 4dd8080c55 | |||
| 0b35d4f2e3 | |||
| 54a9fb9e54 | |||
| 60a9e68f02 | |||
| ad374298e3 | |||
| c5dc4e6127 | |||
| 291ad831c5 | |||
| 0a018f0ca8 | |||
| 6673eeb1bb | |||
| 4641f0b9ef | |||
| a4487964e5 | |||
| fe42fdd1ec | |||
| 375211f184 | |||
| 66c377a5c9 | |||
| 50c2aa0111 | |||
| fdeb891137 | |||
| 6a6e3a43b1 | |||
| b0a34fa21b | |||
| 5c4de03588 | |||
| 72bf6f3c41 | |||
| ad9289e0c1 | |||
| b0cb0e5a99 | |||
| 8347203bbe | |||
| 4aa1186aed | |||
| eed87af61d | |||
| daeea8e7ea | |||
| 0d63a15715 | |||
| fa2e229ada | |||
| 81c1a1da9c | |||
| 5d9700d84c | |||
| f8a8cdaa5f | |||
| e23e446476 | |||
| fa097df50b | |||
| 75f34ff127 | |||
| c9586b4d93 | |||
| 52937a6d90 | |||
| 186c131cce | |||
| 8de3f9a440 | |||
| ea49e179f9 | |||
| 485f4f1c8e | |||
| 5fb35d12d7 | |||
| ec8a9fe3d2 | |||
| 411a34e15e | |||
| 3df71fd2bc | |||
| 5e1f6085e3 | |||
| 53fc7ab6e3 | |||
| 7779ed24fe | |||
| 6e4193dae3 | |||
| f138609f48 | |||
| 98154b5de3 | |||
| 6322fd9eef | |||
| 1c0949e957 | |||
| 52f26396ac | |||
| c3847e6001 | |||
| 5cf13a963d | |||
| b017877826 | |||
| 959f68b520 | |||
| 14cab3fdb8 | |||
| b8d468f6de | |||
| fc66394243 | |||
| 8fca243c9a | |||
| 388f710379 | |||
| ba3ab4362b | |||
| e18c9afc2d | |||
| a9b4a86c4a | |||
| 200ea502dd | |||
| de36db97eb | |||
| 30283b044f | |||
| 055bed8a07 | |||
| 12b5c2ab34 | |||
| dd78674888 | |||
| 0d0df63847 | |||
| 3ab00d9da8 | |||
| 3e6e72c5c7 | |||
| 5d8a55f08c | |||
| 81c569aae4 | |||
| 88fd3fc4da | |||
| 2282d3ae39 | |||
| c4dcec463a | |||
| 5b7f893ad7 | |||
| 2ede0d498a | |||
| f518e8a0ff | |||
| 767284408a | |||
| eef51f3b84 | |||
| 69b7114a49 | |||
| 0ea38ea568 | |||
| c600da71e3 | |||
| c64dd14b1a | |||
| 8ea6d9fa67 | |||
| 978ac8f53c | |||
| 49a326cde7 | |||
| 63e208f4ec | |||
| f50d1549b0 | |||
| 55e24df671 | |||
| b37e1d0cc0 | |||
| afa26c0dd4 | |||
| c71f46ede5 | |||
| 20e547a0f6 | |||
| 2edebaddc2 | |||
| 119e1d4867 | |||
| 63e30d3378 | |||
| d6fe04ec4e | |||
| b8a364af6a | |||
| 5ef808d4a2 | |||
| 848d4d91e6 | |||
| a502780c9b | |||
| 418e099804 | |||
| 06258aa386 | |||
| d7608b1cc8 | |||
| cb86ad4104 | |||
| 8cd51df1e1 | |||
| 8ef7220766 | |||
| b5333a3686 | |||
| e6e92dbc0f | |||
| 01fdd41a10 | |||
| 6af06a38ae | |||
| 5d9c66d22d | |||
| 81f5a4b127 | |||
| da3e68a20b | |||
| 8712c1719e | |||
| 593c5db0e8 | |||
| b28391feae | |||
| 5f8df6d4cd | |||
| c36efe7f14 | |||
| cf97b6df9c | |||
| 720d3a8135 | |||
| 9c42458fa5 | |||
| 6d9b129ac9 | |||
| e17ec798d4 | |||
| 58ac499f30 | |||
| f07f0092ad | |||
| bcd3475d17 | |||
| 7c04526088 | |||
| 2d7ab68576 | |||
| 218a4893b6 | |||
| 266bf261aa | |||
| 63694032e8 | |||
| b77aaedb58 | |||
| a316d0301f | |||
| dcd499720e | |||
| e18fe21eca | |||
| 2970b51fb8 | |||
| b9236ff52e | |||
| 38eb0ec7ed | |||
| ecba4a0b80 | |||
| e6da18c952 | |||
| 12941ac5ae | |||
| 11085bda63 | |||
| c03211cc53 | |||
| 2867459600 | |||
| 32b24db9bf | |||
| 660bf9ff87 | |||
| 78c4ddebba | |||
| f2dfadb37b | |||
| 3f2bdf081f | |||
| d6ba34aeea | |||
| b622aca221 | |||
| 6442eb12fb | |||
| 01c15afa74 | |||
| 4e88f1f38a | |||
| 13ab505f4d | |||
| 7d112aab27 | |||
| b786497299 | |||
| eedf57af89 | |||
| 7a01a4e090 | |||
| 874794c996 | |||
| 5e37c4e85f | |||
| 4e7eac368f | |||
| e8398cb221 | |||
| 9460e28c7b | |||
| 756f3f32ca | |||
| 362981ad19 | |||
| fa4f7e4ac2 | |||
| c6bca4e2ab | |||
| e28b361e05 | |||
| a18691011b | |||
| c4a6403cba | |||
| 1851bf941a | |||
| b7ab3c2e92 | |||
| ce1ad032ba | |||
| 8446c68e1b | |||
| 40ed388b0f | |||
| ce1693aa2f | |||
| 11d16a1552 | |||
| 0ac54a2c88 | |||
| b7d8b32123 | |||
| 5987f6b2cd | |||
| 7ad76f5683 | |||
| 09a9457021 | |||
| d8b45396e3 | |||
| ca4643ec36 | |||
| e2f78ba476 | |||
| 5d92190d50 | |||
| 2b0d6de986 | |||
| 057f82a561 | |||
| 719d2a5ffe | |||
| d4bff9d5cb | |||
| 19fcc1f93b | |||
| d45ea127c2 | |||
| f591cf8601 | |||
| 6661a76aa8 | |||
| a2ed22bfcc | |||
| e370f8891a | |||
| 8a83e32c42 | |||
| 831eb6325c | |||
| 4d6240c987 | |||
| 79cf7c84dc | |||
| b71f582329 | |||
| 8315d4b6ae | |||
| b8c3cc751a | |||
| d00262dc31 | |||
| 952d0c74d0 | |||
| ffbea7af59 | |||
| 3debc6c8d3 | |||
| 5092eb58fb | |||
| f0b9240575 | |||
| 9cf59c409e | |||
| 971c375398 | |||
| ac4439c5ae | |||
| bfd5aa30a7 | |||
| 9737170665 | |||
| 922a040466 | |||
| 9eacefb155 | |||
| 33f0782f3a | |||
| e6a5cef945 | |||
| 4c8edb80b3 | |||
| d4668fae99 | |||
| ddfe55e3ae | |||
| 761a5f1d4c | |||
| 1fbcad8787 | |||
| aba586e605 | |||
| 27b21b5ad4 | |||
| b6e54dab17 | |||
| 1f8e89772d | |||
| 843b13ed57 | |||
| be89e5ca55 | |||
| 5f3657fd56 | |||
| 494162400e | |||
| ab65bb6a8a | |||
| 333625f199 | |||
| dbfd715381 | |||
| f1d989964e | |||
| b701629498 | |||
| 8250946325 | |||
| 71f63d8e6f | |||
| dd5e834db0 | |||
| 970ecb52f0 | |||
| 62ea1b40e1 | |||
| 3b0fd5c592 | |||
| b7616026dd | |||
| 16ad60b89a | |||
| db7971d2f7 | |||
| f3f8bd3125 | |||
| 516fd0ee8f | |||
| 8d6700d493 | |||
| 9d4ace9b3e | |||
| 2800655e33 | |||
| 91eecee11d | |||
| 899e5aa395 | |||
| d5820c4902 | |||
| a91c002274 | |||
| 4d142b93dd | |||
| 04dcf57ff3 | |||
| 975550c755 | |||
| a964a80d85 | |||
| 22c3b8f116 | |||
| c4b1831cfe | |||
| cdb6813384 | |||
| b14b68d83c | |||
| 3c2f930e6b | |||
| ca9c7ce555 | |||
| c2e95a0607 | |||
| 2767ee9e80 | |||
| d998a8087f | |||
| fdce016921 | |||
| c73d70933b | |||
| e9d0ad6e37 | |||
| a35586f762 | |||
| f527c30923 | |||
| 94e70219cf | |||
| 6496763aae | |||
| a409ec269b | |||
| bc7bc8da66 | |||
| 52484c774e | |||
| 4e1e0cade1 | |||
| fda5904dac | |||
| 69ecc22318 | |||
| bff9d33ee6 | |||
| edf506953b | |||
| 5e11746549 | |||
| 1ae315e303 | |||
| 758b03ab25 | |||
| e756fad573 | |||
| 3547450b03 | |||
| 733f6692c6 | |||
| 2d83160b16 | |||
| 256fa880dd | |||
| b08c5f5c67 | |||
| d0862a2d26 | |||
| e97340ed52 | |||
| e27c81eea6 | |||
| 7f7f3d43b2 | |||
| 4b1b772098 | |||
| f66b88490f | |||
| 18f9157169 | |||
| 6eb82a807b | |||
| bf57a97833 | |||
| e9e2093220 | |||
| c3540da2e3 | |||
| d228cf56dd | |||
| 8f4cecd963 | |||
| 66adff44bb | |||
| be41c094dc | |||
| 273848ca18 | |||
| 1e9dbead3b | |||
| aeaa8ba133 | |||
| 24654af635 | |||
| e88a21d6db | |||
| bcd01badaf | |||
| 8e063506e0 | |||
| 84f5d6137a | |||
| 0a8565f5e8 | |||
| bd8da25a46 | |||
| a841f588dd | |||
| 75a4362ce3 | |||
| e763e001e5 | |||
| 69475a0ae7 | |||
| 53e14c2ad7 | |||
| 1edc33148a | |||
| a4cbfc74e4 | |||
| c0d25aeb02 | |||
| 40f49bf6da | |||
| 0bfce87dc6 | |||
| 2a0655e9de | |||
| a86cfa5934 | |||
| 54b77523c5 | |||
| ba06c8928d | |||
| c8a4ac1ed4 | |||
| 143acbae48 | |||
| 937f6fdae8 | |||
| ba7239ac08 | |||
| 2e748274c0 | |||
| eab2750953 | |||
| 17b6cb0c73 | |||
| 98a4c453c1 | |||
| 6475dceab9 | |||
| 040a945774 | |||
| 47743a5fa8 | |||
| d47d6de985 | |||
| 37818b8594 | |||
| 3b184acddd | |||
| 9c80404d17 | |||
| aaa7082f9d | |||
| a45b45b2ce | |||
| e4bfbd267e | |||
| 65b4dcc672 | |||
| 36fc30b524 | |||
| e724ed9137 | |||
| 7ca992af05 | |||
| 37f1c714ac | |||
| 397a43fb60 | |||
| 45e0a648c6 | |||
| 7336aa81d9 | |||
| d727c10d98 | |||
| 321d77a317 | |||
| 19b8a6b737 | |||
| f2e69dfb96 | |||
| 8207e49317 | |||
| b75600b9ea | |||
| 7b01f1bef6 | |||
| e7bd2c0001 | |||
| a26076e9db | |||
| 9711a0fb8e | |||
| accc670411 | |||
| 071c41a54f | |||
| 35ba6c19c3 | |||
| 14c8348166 | |||
| 7d6ee72025 | |||
| ea0e770b57 | |||
| 193b7ff21e | |||
| d814ad9f3e | |||
| da8b620c75 | |||
| 911b5e6814 | |||
| f991fd9c71 | |||
| 652e4c922d | |||
| 4364e3fbc1 | |||
| a783fdecbc | |||
| 16f67455a2 | |||
| 0850a28d20 | |||
| 5ca598139e | |||
| df1bf09163 | |||
| 50bc8d3e9c | |||
| 86d089024e | |||
| d5c1cf594d | |||
| a0b5731e69 | |||
| ceb359d614 | |||
| a49a9f8e3b | |||
| 766606b08d | |||
| fed56c1959 | |||
| ae6ed8ad97 | |||
| c1ca0b8e2c | |||
| 569dc735ce | |||
| dd11c2c871 | |||
| 8def4a2b68 | |||
| 13a5f24b07 | |||
| 0989d6353e | |||
| 4139a7b73f | |||
| be60d66ce3 | |||
| 0a33043874 | |||
| 96d1d983e5 | |||
| 7ffb260d7c | |||
| ce74489df5 | |||
| 342b188fae | |||
| fa6fee7b55 | |||
| c53d5a4d7d | |||
| 521e905724 | |||
| 4623090050 | |||
| dd9e5cc541 | |||
| 626be6a347 | |||
| 56327ed503 | |||
| 6d1665004b | |||
| 59b8119fbd | |||
| 9ff863db5e | |||
| e2ac6e6d4d | |||
| df4101875a | |||
| 3f5c788d48 | |||
| 45cd4df6e5 | |||
| 94ac3ec76e | |||
| af7263a0b1 | |||
| 035396f95c | |||
| f318f6304b | |||
| 9d0ff472e5 | |||
| d27482e812 | |||
| d5b6de70da | |||
| 69c2212ea0 | |||
| 10be9bcd56 | |||
| f531def0d2 | |||
| ed40eae655 | |||
| ba5ae6ed04 | |||
| d6ade102dc | |||
| 0a6301697e | |||
| 13b4fc6725 | |||
| c94d246c24 | |||
| 5b779ba9fe | |||
| 3ba2cb19a9 | |||
| a095dddd01 | |||
| 1b5cfaa49b | |||
| 66f3fabbae | |||
| 0be8fb7931 | |||
| 431e6ffaae | |||
| 7d8185e0ee | |||
| dff45748bd | |||
| da514ef314 | |||
| 7f73cde794 | |||
| b0af0d9cd5 | |||
| e6464929ff | |||
| 122053939d | |||
| 8429197b07 | |||
| 44f2081882 | |||
| 300b4a3706 | |||
| 81ef2db7f8 | |||
| c41e8be3e8 | |||
| 41bab0ce0b | |||
| 5f26b9eeea | |||
| 1cca69ad23 | |||
| 410ed3949b | |||
| efc6ef3075 | |||
| 63f7dd1d20 | |||
| 57b8c69983 | |||
| aad060810a | |||
| 9222b00a6f | |||
| ff61b22e7e | |||
| 577cb91343 | |||
| 1889386f64 | |||
| 5d7f082ebf | |||
| db6327c4ff | |||
| fd7f6b2b99 | |||
| 49435398a8 | |||
| e101ac341b | |||
| 6cfc7b7c69 | |||
| 313acabc86 | |||
| 34cced872f | |||
| ac09e3aaf9 | |||
| 9f2fd34e99 | |||
| 67b63d3084 | |||
| 4a31a7b84b | |||
| 538b601b1e | |||
| 588f064c25 | |||
| d521e79662 | |||
| ccddb9244d | |||
| 0547396213 | |||
| 6c85171091 | |||
| a8f6b6c1da | |||
| f899326189 | |||
| 0f4d1d2a74 | |||
| 941d5c08e3 | |||
| db9f74158b | |||
| b4c01349d1 | |||
| 165bbd3584 | |||
| ffb253e0e9 | |||
| e5e9fe456f | |||
| c63589b204 | |||
| 11408c2656 | |||
| 7d4aed8819 | |||
| 609ffccd67 | |||
| 508369a59d | |||
| 748af1d8cb | |||
| 26a91cd5e1 | |||
| 48dd4d5913 | |||
| d309ec249e | |||
| 72d46b7352 | |||
| 4613aae47d | |||
| 1bc4480d84 | |||
| b5d76f73e8 | |||
| a5c7913e77 | |||
| 34b914f509 | |||
| 5a3d75ca12 | |||
| 158d7b23d8 | |||
| 67949b4968 | |||
| 1fc40b3017 | |||
| bb1a375484 | |||
| bf5dd3b0a1 | |||
| e4d4c62833 | |||
| 20ae903d7f | |||
| f5f757e4bd | |||
| 13c011895d | |||
| bd8d0e3392 | |||
| 5ad564d21b | |||
| 8f8775cb93 | |||
| 37695827aa | |||
| 7a72d209ea | |||
| cda6b67bef | |||
| 066305b095 | |||
| f2ba4b270f | |||
| 89695df012 | |||
| b0566d3c6f | |||
| 5dda8c384f | |||
| b04385a340 | |||
| d374ea6ea6 | |||
| 01a2820390 | |||
| c89c1a03da | |||
| 873408270e | |||
| 8fec8f35bc | |||
| 141c846fe2 | |||
| cb569ff14d | |||
| 1497469016 | |||
| e356a6d33b | |||
| 38ac4c5980 | |||
| ed3ee64e4b | |||
| 12aea2901d | |||
| 5ff56467ea | |||
| 3a8718a4b0 | |||
| 8c15855fc3 | |||
| 37c4a7b690 | |||
| b735e7c34d | |||
| 5f85c3b3b8 | |||
| 5d9cb9fa21 | |||
| 643d56958d | |||
| f378d6f040 | |||
| bb57794388 | |||
| a9ca49b8a2 | |||
| c1b473294e | |||
| e3e4bdfe09 | |||
| bfbeace2e2 | |||
| efcf46ce8a | |||
| 2085715965 | |||
| d227db7b7b | |||
| 2af67ad355 | |||
| f100854423 | |||
| 92331d7a33 | |||
| 9a5bcb9099 | |||
| 8eb6bb2a95 | |||
| 2aa65ccab3 | |||
| be1577a3e7 | |||
| c8e1b3bf29 | |||
| e17b986628 | |||
| 5f19918ca0 | |||
| 2959ad0e70 | |||
| a76eec7bb7 | |||
| 068b2a0dcd | |||
| 316b7e5653 | |||
| 00fc1da33c | |||
| 9ef93df54f | |||
| fd9fdf6399 | |||
| 8fa1701e06 | |||
| 4abe83f8a9 | |||
| 0a7564acb6 | |||
| db0f7cfbae | |||
| 1724885371 | |||
| a97e9ea8b1 | |||
| 9d30e97526 | |||
| b91330a27a | |||
| 744bc9ebe9 | |||
| 89ed9e6d7f | |||
| b007e7f54a | |||
| 6651a6df42 | |||
| 3f29b165aa | |||
| b13b91face | |||
| 63c14fe2d5 | |||
| 14e74ed02d | |||
| 7e30750618 | |||
| 4d1dd16be5 | |||
| fa49cf5eba | |||
| 26b39fc1c6 | |||
| 0d36e368ea | |||
| 859f265c68 | |||
| 3219f520ba | |||
| 97e27b6caf | |||
| 09da83a72b | |||
| d13b210e2f | |||
| 09fb672718 | |||
| 9797ad0e17 | |||
| 8b3d61ac36 | |||
| 7161c9547a | |||
| 60d4362a87 | |||
| 1836e0c8fc | |||
| d3344aeb34 | |||
| cfeb093fa6 | |||
| a469b3ffcc | |||
| 14b3a3fdd8 | |||
| 94367ce387 | |||
| 5be518aa50 | |||
| d059a8da9e | |||
| 1dcacbef7a | |||
| a25edeccf7 | |||
| 315f73c77d | |||
| 666288fccc | |||
| 0ccf61c2a9 | |||
| c16b1b27a3 | |||
| ed9ba60be6 | |||
| 24d047e3d8 | |||
| 9671079ffb | |||
| 688892523c | |||
| b02c341f62 | |||
| 3e9bcada1e | |||
| 93d4bd6438 | |||
| 5146498b33 | |||
| 72da4f39a8 | |||
| a2b2fb804b | |||
| 3eac80e666 | |||
| 718d2122a4 | |||
| 310c6c90a3 | |||
| 9d80f62d58 | |||
| 77032fc989 | |||
| 64e6086f0c | |||
| 3aa58fdc8f | |||
| 93bc6ba615 | |||
| 36690d63cb | |||
| 9896e9799a | |||
| 27afc82b79 | |||
| 1c8f01ce7b | |||
| 4038ccff0d | |||
| 5b41bc2f59 | |||
| 014ba760b5 | |||
| 96a91ccf09 | |||
| 347fbd2a48 | |||
| 29723052ab | |||
| 86415d675b | |||
| 8fc4a0dc48 | |||
| e14670cdda | |||
| 4d73488f0c | |||
| 46e62b24cf | |||
| 17c3041fe9 | |||
| d5ae381528 | |||
| e2e09527ec | |||
| 3ce1afbcc9 | |||
| 1f077d7ec2 | |||
| adf3d0347b | |||
| 7ed8b16a53 | |||
| 9f7c162107 | |||
| fb15f8cde6 | |||
| 45ecfcc6bb | |||
| c6f947e470 | |||
| adf5caf18a | |||
| 0b8068e13d | |||
| f143d2e214 | |||
| 2e802301ae | |||
| 7305c721a6 | |||
| b299f3d6aa | |||
| e09cd6c16c | |||
| b7df8b7319 | |||
| c92b5942fc | |||
| fe729ec762 | |||
| 915673798e | |||
| 9527fe4f26 | |||
| e8a8b3f664 | |||
| d6a829abc2 | |||
| 1a36cd0317 | |||
| 75005ccf81 | |||
| fd6c600531 | |||
| 6996c2501e | |||
| efbd9bdb56 | |||
| 0d34213647 | |||
| 870b85d71b | |||
| 86ba6b6f86 | |||
| 02be3cd0c4 | |||
| 1b756ef9a0 | |||
| ceda06f9ae | |||
| 068eba015b | |||
| 7ae6b2df05 | |||
| 6765d5ad26 | |||
| 35cfd6bec9 | |||
| 90f66baf85 | |||
| 5edfed78f2 | |||
| fd6a3e5a17 | |||
| 14a4b1b4b4 | |||
| 5743c0bb72 | |||
| acca1b6a91 | |||
| 355265cd1e | |||
| 6ec8d143fa | |||
| 8ae327e8f5 | |||
| c03a61f613 | |||
| 89928c753c | |||
| a56fcc0fba | |||
| 43c60bcdbc | |||
| a3fa12f0e4 | |||
| d696556097 | |||
| 6a45151741 | |||
| 34e2fbefb9 | |||
| f7cede4713 | |||
| 610b20c1ff | |||
| fb19e10cdc | |||
| 2f1756ccf2 | |||
| ce632a25cf | |||
| ec10c37468 | |||
| 5ee3e140ed | |||
| 888f5f8bb6 | |||
| 9114dd5992 | |||
| a126494c12 | |||
| 79ba804c88 | |||
| e2cbe11a5f | |||
| 05748bf8ff | |||
| f8c98bf6bf | |||
| f4496bb23a | |||
| c93766bb48 | |||
| a1ea3f74b3 | |||
| 06aaa7c680 | |||
| 65e8bfc93e | |||
| ff5e12655f | |||
| 1065004fa3 | |||
| 6d90d734f4 | |||
| 6c8757f230 | |||
| 40e37b1798 | |||
| 8e1fd4474f | |||
| bd87585396 | |||
| e9e935d6c4 | |||
| 2f2c2b4222 | |||
| 9749a272ec | |||
| b76a50238e | |||
| a4f3963a5a | |||
| d52bd65d21 | |||
| fb51f42f35 | |||
| c910a715bd | |||
| 9040f9b82a | |||
| fc0ec0d754 | |||
| b3569174b6 | |||
| 0cae624995 | |||
| cbf184342b | |||
| ce123a7f1a | |||
| 0c5daa7173 | |||
| bc20a34a49 | |||
| d5b6a426a9 | |||
| 4c78e93143 | |||
| 5f184e9e5e | |||
| 2201b0395d | |||
| 51818044b1 | |||
| 30943010e6 | |||
| dd5ca10226 | |||
| a56b058858 | |||
| eade72e2c6 | |||
| e9bc9747b8 | |||
| eb0cdda0f9 | |||
| 552adf3200 | |||
| eba25fcc4d | |||
| 673cd0fcd1 | |||
| b941b5571f | |||
| ca026b41c0 | |||
| 29a683a815 | |||
| 69dbd20ea5 | |||
| 427ee026ac | |||
| 0a537c6830 | |||
| 89682a2ee4 | |||
| 78b00a18cc | |||
| 192702daf9 | |||
| fcee735578 | |||
| 2ba49e84bb | |||
| 262376aa75 | |||
| 4c8d2266ec | |||
| bb98bf03aa | |||
| 19c3efc9e9 | |||
| 7164721ee0 | |||
| 74b16809ec | |||
| 220723d25f | |||
| fdb03c9626 | |||
| a81bbb9192 | |||
| 7a4aff8e4b | |||
| 2810632f4a | |||
| 2d0dd067b8 | |||
| 3ab25f5ff1 | |||
| 39bebea5f7 | |||
| 57681dcd3d | |||
| 168ce549f7 | |||
| 9ec94441f3 | |||
| 53e7b99605 | |||
| abfe476cb9 | |||
| bbca200ceb | |||
| cb21cab117 | |||
| 1f80845a7a | |||
| 20088ef82b | |||
| 1e0b1a3607 | |||
| 24e8455c73 | |||
| e42a732e93 | |||
| 0f2b94307f | |||
| d333cb5199 | |||
| a6db4f20ad | |||
| 9ed9472c01 | |||
| f7fcde8312 | |||
| 6660c850f3 | |||
| 8a08bdf9f0 | |||
| 87807e22e0 | |||
| 0eb39abdb4 | |||
| a499ebc158 | |||
| 9467e6c032 | |||
| 9d849a0ced | |||
| 2ca400ab16 | |||
| 4183067c77 | |||
| 5eb4691973 | |||
| d14dfbf360 | |||
| 493a5ad02a | |||
| 481beff028 | |||
| f1f7e438b4 | |||
| 00f84c9d8e | |||
| f75b9c6c86 | |||
| 31bc6d5773 | |||
| 51dc1450d3 | |||
| fcbea08c87 | |||
| 8d60a87aa1 | |||
| 956aa64519 | |||
| fd1cb6ca23 | |||
| 37082ae436 | |||
| bb47ca3d2e | |||
| 0dd3c84b24 | |||
| 848fca7e1b | |||
| 2500f99722 | |||
| c7737c444f | |||
| 4d1a7ed69b | |||
| 626d5df67e | |||
| e4c369deec | |||
| 307209e73f | |||
| dc84935ee6 | |||
| 998c1f52ca | |||
| 2766758c66 | |||
| 258d1d82f3 | |||
| 46aaadb76a | |||
| ea7a618810 | |||
| c0e503b31f | |||
| 55f5a41752 | |||
| b0be82be86 | |||
| 96a9bdb700 | |||
| 74e6d39c24 | |||
| 61dfa00222 | |||
| 476281db2b | |||
| f32e31c73d | |||
| ea72279080 | |||
| 16ba56af84 | |||
| f13ddde988 | |||
| 67dc10dfe9 | |||
| 5fd216adc2 | |||
| 6f0268f6c0 | |||
| 2996dfb33a | |||
| c92f2cd4ba | |||
| 8164d5c1ad | |||
| d9d8d85f6e | |||
| d49720703f | |||
| 2362a9b4dd | |||
| a8265a5286 | |||
| 9ea7431b73 | |||
| 37e6f320fe | |||
| c0c0d48edf | |||
| 284cccbe17 | |||
| 81a9a94264 | |||
| dccf101554 | |||
| a01c06bbc7 | |||
| db43cf1b30 | |||
| 2f561b5604 | |||
| 5a30f036ff | |||
| 768b9ffd09 | |||
| 8732e50047 | |||
| d6e0024c96 | |||
| 9759e86921 | |||
| 982c692c40 | |||
| 0c3ce7836c | |||
| 7ef86c5707 | |||
| f62b88b930 | |||
| 03a326c841 | |||
| 4df4cafd70 | |||
| 4b9539cc6d | |||
| 87135c90bd | |||
| 853d416b2f | |||
| bfd14b87bd | |||
| 88aba4e169 | |||
| 99e2fcb2e8 | |||
| 1f138ab68c | |||
| 99ded7454e | |||
| f82cacac6d | |||
| a548f61ea6 | |||
| bfae715076 | |||
| 358e25b7c2 | |||
| 2c3fa54933 | |||
| 00cdd5833e | |||
| 52b1164e58 | |||
| 657bc9cdf0 | |||
| ec6bcd41b0 | |||
| 1721cce040 | |||
| e41a5ad6b0 | |||
| ee1eca9e66 | |||
| d049369172 | |||
| 6280a68d51 | |||
| 32054dc4f6 | |||
| 831c631048 | |||
| e23711bcce | |||
| 440bff57d0 | |||
| 7345cc81c1 | |||
| 164ab26069 | |||
| 4b6ace80d3 | |||
| 653127a0f7 | |||
| bf3a1e20fc | |||
| d7a44e7589 | |||
| 6c0d583557 | |||
| 13f0fb25da | |||
| 818aca9ec8 | |||
| 1c7fb476b0 | |||
| 93843ed733 | |||
| 0973313703 | |||
| bfbfbe8b11 | |||
| 8c62d9fe78 | |||
| d5558f55ed | |||
| a96ad6bd07 | |||
| 00d9482a99 | |||
| 0f90e2a30f | |||
| 3eed636404 | |||
| a67f88381f | |||
| 808fd856d1 | |||
| 5b9b532458 | |||
| 9fba9bd6b7 | |||
| c5ece144d0 | |||
| b64e2e11db | |||
| 0ccd5714f9 | |||
| e2dfc3eb20 | |||
| 40eeb9b7cb | |||
| 8fa62a0908 | |||
| 446eba8bc9 | |||
| 18579c0647 | |||
| 2bb94e24eb | |||
| 0d37e08638 | |||
| ca89c5feca | |||
| 729c2adb3f | |||
| a21f49cb02 | |||
| ef697c4864 | |||
| 2652dea09a | |||
| efa9312fca | |||
| 074ee70025 | |||
| 77117e48e3 | |||
| da112d3417 | |||
| ddaaf34dbd | |||
| 373e35324e | |||
| 09b2f27749 | |||
| 7e9f18bf24 | |||
| ab3be26790 | |||
| 5c67a1cb12 | |||
| e28ab19ed4 | |||
| 59f8334cfd | |||
| 718bec4bbc | |||
| 2d731cb24b | |||
| 1905936950 | |||
| c362bc673c | |||
| 4da0a752ef | |||
| 221ee6a1c2 | |||
| 2e60ecec87 | |||
| 3d4df906cf | |||
| 71386d3b05 | |||
| e051142334 | |||
| 89a7e2e4dc | |||
| 27440700a5 | |||
| b5019cef12 | |||
| 7e48cbe1aa | |||
| 4b2c570e73 | |||
| 75b9703793 | |||
| e983e1166a | |||
| 322f3bfb1d | |||
| 009b86c33b | |||
| a5775a0f4f |
@@ -28,7 +28,9 @@ LICENSE
|
||||
CONTRIBUTING.md
|
||||
dist
|
||||
.git
|
||||
migrations/
|
||||
server/migrations/
|
||||
config/
|
||||
build.ts
|
||||
tsconfig.json
|
||||
tsconfig.json
|
||||
Dockerfile*
|
||||
drizzle.config.ts
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
@@ -44,19 +44,9 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
groups:
|
||||
dev-patch-updates:
|
||||
dependency-type: "development"
|
||||
patch-updates:
|
||||
update-types:
|
||||
- "patch"
|
||||
dev-minor-updates:
|
||||
dependency-type: "development"
|
||||
minor-updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
prod-patch-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "patch"
|
||||
prod-minor-updates:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: CI/CD Pipeline
|
||||
name: Public CICD Pipeline
|
||||
|
||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
id: check-rc
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
if [[ "$TAG" == *".rc."* ]]; then
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
echo "IS_RC=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_RC=false" >> $GITHUB_ENV
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
id: check-rc
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
if [[ "$TAG" == *".rc."* ]]; then
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
echo "IS_RC=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_RC=false" >> $GITHUB_ENV
|
||||
@@ -201,10 +201,10 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
id: check-rc
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
if [[ "$TAG" == *".rc."* ]]; then
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
echo "IS_RC=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "IS_RC=false" >> $GITHUB_ENV
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Extract tag name
|
||||
id: get-tag
|
||||
@@ -264,9 +264,9 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version: 1.24
|
||||
go-version: 1.25
|
||||
|
||||
- name: Update version in package.json
|
||||
run: |
|
||||
@@ -289,25 +289,17 @@ jobs:
|
||||
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Update install/main.go
|
||||
run: |
|
||||
PANGOLIN_VERSION=${{ env.TAG }}
|
||||
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
|
||||
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
|
||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
|
||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
|
||||
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
|
||||
cat install/main.go
|
||||
shell: bash
|
||||
|
||||
- name: Build installer
|
||||
working-directory: install
|
||||
run: |
|
||||
make go-build-release
|
||||
make go-build-release \
|
||||
PANGOLIN_VERSION=${{ env.TAG }} \
|
||||
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \
|
||||
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
|
||||
shell: bash
|
||||
|
||||
- name: Upload artifacts from /install/bin
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: install-bin
|
||||
path: install/bin/
|
||||
@@ -322,26 +314,100 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Login to GHCR
|
||||
env:
|
||||
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
|
||||
run: |
|
||||
mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")"
|
||||
skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}"
|
||||
shell: bash
|
||||
|
||||
- name: Copy tag from Docker Hub to GHCR
|
||||
# Mirror the already-built image (all architectures) to GHCR so we can sign it
|
||||
- name: Copy tags from Docker Hub to GHCR
|
||||
# Mirror the already-built images (all architectures) to GHCR so we can sign them
|
||||
# Wait a bit for both architectures to be available in Docker Hub manifest
|
||||
env:
|
||||
REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG=${{ env.TAG }}
|
||||
echo "Waiting for multi-arch manifest to be ready..."
|
||||
MAJOR_TAG=$(echo $TAG | cut -d. -f1)
|
||||
MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
|
||||
|
||||
echo "Waiting for multi-arch manifests to be ready..."
|
||||
sleep 30
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:$TAG \
|
||||
docker://$GHCR_IMAGE:$TAG
|
||||
|
||||
# Determine if this is an RC release
|
||||
IS_RC="false"
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
IS_RC="true"
|
||||
fi
|
||||
|
||||
if [ "$IS_RC" = "true" ]; then
|
||||
echo "RC release detected - copying version-specific tags only"
|
||||
|
||||
# SQLite OSS
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:$TAG \
|
||||
docker://$GHCR_IMAGE:$TAG
|
||||
|
||||
# PostgreSQL OSS
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:postgresql-$TAG \
|
||||
docker://$GHCR_IMAGE:postgresql-$TAG
|
||||
|
||||
# SQLite Enterprise
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:ee-$TAG \
|
||||
docker://$GHCR_IMAGE:ee-$TAG
|
||||
|
||||
# PostgreSQL Enterprise
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG \
|
||||
docker://$GHCR_IMAGE:ee-postgresql-$TAG
|
||||
else
|
||||
echo "Regular release detected - copying all tags (latest, major, minor, full version)"
|
||||
|
||||
# SQLite OSS - all tags
|
||||
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:$TAG_SUFFIX
|
||||
done
|
||||
|
||||
# PostgreSQL OSS - all tags
|
||||
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX
|
||||
done
|
||||
|
||||
# SQLite Enterprise - all tags
|
||||
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:ee-$TAG_SUFFIX
|
||||
done
|
||||
|
||||
# PostgreSQL Enterprise - all tags
|
||||
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
|
||||
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}"
|
||||
skopeo copy --all --retry-times 3 \
|
||||
docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG_SUFFIX \
|
||||
docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX
|
||||
done
|
||||
fi
|
||||
|
||||
echo "All images copied successfully to GHCR!"
|
||||
shell: bash
|
||||
|
||||
- name: Login to GitHub Container Registry (for cosign)
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -349,7 +415,7 @@ jobs:
|
||||
|
||||
- name: Install cosign
|
||||
# cosign is used to sign and verify container images (key and keyless)
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Dual-sign and verify (GHCR & Docker Hub)
|
||||
# Sign each image by digest using keyless (OIDC) and key-based signing,
|
||||
@@ -366,28 +432,127 @@ jobs:
|
||||
issuer="https://token.actions.githubusercontent.com"
|
||||
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
|
||||
|
||||
for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||
echo "Processing ${IMAGE}:${TAG}"
|
||||
# Track failures
|
||||
FAILED_TAGS=()
|
||||
SUCCESSFUL_TAGS=()
|
||||
|
||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')"
|
||||
REF="${IMAGE}@${DIGEST}"
|
||||
echo "Resolved digest: ${REF}"
|
||||
# Determine if this is an RC release
|
||||
IS_RC="false"
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
IS_RC="true"
|
||||
fi
|
||||
|
||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||
cosign sign --recursive "${REF}"
|
||||
# Define image variants to sign
|
||||
if [ "$IS_RC" = "true" ]; then
|
||||
echo "RC release - signing version-specific tags only"
|
||||
IMAGE_TAGS=(
|
||||
"${TAG}"
|
||||
"postgresql-${TAG}"
|
||||
"ee-${TAG}"
|
||||
"ee-postgresql-${TAG}"
|
||||
)
|
||||
else
|
||||
echo "Regular release - signing all tags"
|
||||
MAJOR_TAG=$(echo $TAG | cut -d. -f1)
|
||||
MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
|
||||
IMAGE_TAGS=(
|
||||
"latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"
|
||||
"postgresql-latest" "postgresql-$MAJOR_TAG" "postgresql-$MINOR_TAG" "postgresql-$TAG"
|
||||
"ee-latest" "ee-$MAJOR_TAG" "ee-$MINOR_TAG" "ee-$TAG"
|
||||
"ee-postgresql-latest" "ee-postgresql-$MAJOR_TAG" "ee-postgresql-$MINOR_TAG" "ee-postgresql-$TAG"
|
||||
)
|
||||
fi
|
||||
|
||||
echo "==> cosign sign (key) --recursive ${REF}"
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||
# Sign each image variant for both registries
|
||||
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do
|
||||
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
|
||||
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
TAG_FAILED=false
|
||||
|
||||
echo "==> cosign verify (public key) ${REF}"
|
||||
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
|
||||
# Wrap the entire tag processing in error handling
|
||||
(
|
||||
set -e
|
||||
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
|
||||
REF="${BASE_IMAGE}@${DIGEST}"
|
||||
echo "Resolved digest: ${REF}"
|
||||
|
||||
echo "==> cosign verify (keyless policy) ${REF}"
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer "${issuer}" \
|
||||
--certificate-identity-regexp "${id_regex}" \
|
||||
"${REF}" -o text
|
||||
echo "==> cosign sign (keyless) --recursive ${REF}"
|
||||
cosign sign --recursive "${REF}"
|
||||
|
||||
echo "==> cosign sign (key) --recursive ${REF}"
|
||||
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
|
||||
|
||||
# Retry wrapper for verification to handle registry propagation delays
|
||||
retry_verify() {
|
||||
local cmd="$1"
|
||||
local attempts=6
|
||||
local delay=5
|
||||
local i=1
|
||||
until eval "$cmd"; do
|
||||
if [ $i -ge $attempts ]; then
|
||||
echo "Verification failed after $attempts attempts"
|
||||
return 1
|
||||
fi
|
||||
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
|
||||
sleep $delay
|
||||
i=$((i+1))
|
||||
delay=$((delay*2))
|
||||
# Cap the delay to avoid very long waits
|
||||
if [ $delay -gt 60 ]; then delay=60; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "==> cosign verify (public key) ${REF}"
|
||||
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
|
||||
VERIFIED_INDEX=true
|
||||
else
|
||||
VERIFIED_INDEX=false
|
||||
fi
|
||||
|
||||
echo "==> cosign verify (keyless policy) ${REF}"
|
||||
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
|
||||
VERIFIED_INDEX_KEYLESS=true
|
||||
else
|
||||
VERIFIED_INDEX_KEYLESS=false
|
||||
fi
|
||||
|
||||
# Check if verification succeeded
|
||||
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
|
||||
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
echo "This may be due to registry propagation delays. Continuing anyway."
|
||||
fi
|
||||
) || TAG_FAILED=true
|
||||
|
||||
if [ "$TAG_FAILED" = "true" ]; then
|
||||
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||
else
|
||||
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
|
||||
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}")
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
# Report summary
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Sign and Verify Summary"
|
||||
echo "=========================================="
|
||||
echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
|
||||
echo "Failed: ${#FAILED_TAGS[@]}"
|
||||
echo ""
|
||||
|
||||
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
|
||||
echo "Failed tags:"
|
||||
for tag in "${FAILED_TAGS[@]}"; do
|
||||
echo " - $tag"
|
||||
done
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
|
||||
else
|
||||
echo "✓ All images signed and verified successfully!"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
post-run:
|
||||
@@ -405,7 +570,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
skopeo --version
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
|
||||
- name: Input check
|
||||
run: |
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \
|
||||
| jq -r '.Tags[]' | sort -u > src-tags.txt
|
||||
| jq -r '.Tags[]' | grep -v -e '-arm64' -e '-amd64' | sort -u > src-tags.txt
|
||||
echo "Found source tags: $(wc -l < src-tags.txt)"
|
||||
head -n 20 src-tags.txt || true
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v5
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
name: SAAS Pipeline
|
||||
|
||||
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
|
||||
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write # for GHCR push
|
||||
id-token: write # for Cosign Keyless (OIDC) Signing
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pre-run:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Verify AWS identity
|
||||
run: aws sts get-caller-identity
|
||||
|
||||
- name: Start EC2 instances
|
||||
run: |
|
||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||
echo "EC2 instances started"
|
||||
|
||||
|
||||
release-arm:
|
||||
name: Build and Release (ARM64)
|
||||
runs-on: [self-hosted, linux, arm64, us-east-1]
|
||||
needs: [pre-run]
|
||||
if: >-
|
||||
${{
|
||||
needs.pre-run.result == 'success'
|
||||
}}
|
||||
# Job-level timeout to avoid runaway or stuck runs
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Target images
|
||||
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download MaxMind GeoLite2 databases
|
||||
env:
|
||||
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
|
||||
run: |
|
||||
echo "Downloading MaxMind GeoLite2 databases..."
|
||||
|
||||
# Download GeoLite2-Country
|
||||
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||
-o GeoLite2-Country.tar.gz
|
||||
|
||||
# Download GeoLite2-ASN
|
||||
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
|
||||
-o GeoLite2-ASN.tar.gz
|
||||
|
||||
# Extract the .mmdb files
|
||||
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
|
||||
|
||||
# Verify files exist
|
||||
if [ ! -f "GeoLite2-Country.mmdb" ]; then
|
||||
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
|
||||
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up tar files
|
||||
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
|
||||
|
||||
echo "MaxMind databases downloaded successfully"
|
||||
ls -lh GeoLite2-*.mmdb
|
||||
|
||||
- name: Monitor storage space
|
||||
run: |
|
||||
THRESHOLD=75
|
||||
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
|
||||
echo "Used space: $USED_SPACE%"
|
||||
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
|
||||
echo "Used space is below the threshold of 75% free. Running Docker system prune."
|
||||
echo y | docker system prune -a
|
||||
else
|
||||
echo "Storage space is above the threshold. No action needed."
|
||||
fi
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@v2
|
||||
|
||||
- name: Extract tag name
|
||||
id: get-tag
|
||||
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Update version in package.json
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
|
||||
cat server/lib/consts.ts
|
||||
shell: bash
|
||||
|
||||
- name: Build and push Docker images (Docker Hub - ARM64)
|
||||
run: |
|
||||
TAG=${{ env.TAG }}
|
||||
make build-saas tag=$TAG
|
||||
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
|
||||
shell: bash
|
||||
|
||||
post-run:
|
||||
needs: [pre-run, release-arm]
|
||||
if: >-
|
||||
${{
|
||||
always() &&
|
||||
needs.pre-run.result == 'success' &&
|
||||
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Verify AWS identity
|
||||
run: aws sts get-caller-identity
|
||||
|
||||
- name: Stop EC2 instances
|
||||
run: |
|
||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||
echo "EC2 instances stopped"
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-stale: 14
|
||||
days-before-close: 14
|
||||
|
||||
@@ -14,12 +14,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
run: npm run set:oss
|
||||
|
||||
- name: Generate database migrations
|
||||
run: npm run db:sqlite:generate
|
||||
run: npm run db:generate
|
||||
|
||||
- name: Apply database migrations
|
||||
run: npm run db:sqlite:push
|
||||
run: npm run db:push
|
||||
|
||||
- name: Test with tsc
|
||||
run: npx tsc --noEmit
|
||||
@@ -62,10 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build Docker image sqlite
|
||||
run: make dev-build-sqlite
|
||||
@@ -74,10 +71,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Copy config file
|
||||
run: cp config/config.example.yml config/config.yml
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build Docker image pg
|
||||
run: make dev-build-pg
|
||||
|
||||
@@ -50,4 +50,7 @@ dynamic/
|
||||
*.mmdb
|
||||
scratch/
|
||||
tsconfig.json
|
||||
hydrateSaas.ts
|
||||
hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
server/setup/migrations.ts
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
@@ -19,4 +19,4 @@
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { db } from "@server/db/pg/driver";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { __DIRNAME } from "@server/lib/consts";
|
||||
|
||||
const version = "1.14.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "loginPageBranding" (
|
||||
"loginPageBrandingId" serial PRIMARY KEY NOT NULL,
|
||||
"logoUrl" text NOT NULL,
|
||||
"logoWidth" integer NOT NULL,
|
||||
"logoHeight" integer NOT NULL,
|
||||
"primaryColor" text,
|
||||
"resourceTitle" text NOT NULL,
|
||||
"resourceSubtitle" text,
|
||||
"orgTitle" text,
|
||||
"orgSubtitle" text
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "loginPageBrandingOrg" (
|
||||
"loginPageBrandingId" integer NOT NULL,
|
||||
"orgId" varchar NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "resourceHeaderAuthExtendedCompatibility" (
|
||||
"headerAuthExtendedCompatibilityId" serial PRIMARY KEY NOT NULL,
|
||||
"resourceId" integer NOT NULL,
|
||||
"extendedCompatibilityIsActivated" boolean DEFAULT false NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeEnabled" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeType" text DEFAULT 'forced';`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "maintenanceTitle" text;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "maintenanceMessage" text;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ADD COLUMN "maintenanceEstimatedTime" text;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "tcpPortRangeString" varchar;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "udpPortRangeString" varchar;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "siteResources" ADD COLUMN "disableIcmp" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_loginPageBrandingId_loginPageBranding_loginPageBrandingId_fk" FOREIGN KEY ("loginPageBrandingId") REFERENCES "public"."loginPageBranding"("loginPageBrandingId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resourceHeaderAuthExtendedCompatibility" ADD CONSTRAINT "resourceHeaderAuthExtendedCompatibility_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
await db.execute(sql`ROLLBACK`);
|
||||
console.log("Unable to migrate database");
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
@@ -1,72 +1,93 @@
|
||||
FROM node:24-alpine AS builder
|
||||
# FROM node:24-slim AS base
|
||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apk add --no-cache curl tzdata python3 make g++
|
||||
|
||||
# COPY package.json package-lock.json ./
|
||||
COPY package*.json ./
|
||||
|
||||
FROM base AS builder-dev
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
|
||||
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
|
||||
ARG BUILD=oss
|
||||
ARG DATABASE=sqlite
|
||||
|
||||
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||
npm run set:$DATABASE && \
|
||||
npm run set:$BUILD && \
|
||||
npm run db:generate && \
|
||||
npm run build && \
|
||||
npm run build:cli && \
|
||||
test -f dist/server.mjs
|
||||
|
||||
# Copy the appropriate TypeScript configuration based on build type
|
||||
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
|
||||
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
|
||||
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
|
||||
fi
|
||||
# Create placeholder files for MaxMind databases to avoid COPY errors
|
||||
# Real files should be present for saas builds, placeholders for oss builds
|
||||
RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb
|
||||
|
||||
# if the build is oss then remove the server/private directory
|
||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
|
||||
FROM base AS builder
|
||||
|
||||
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
RUN mkdir -p dist
|
||||
RUN npm run next:build
|
||||
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
|
||||
RUN if [ "$DATABASE" = "pg" ]; then \
|
||||
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
|
||||
else \
|
||||
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
|
||||
fi
|
||||
|
||||
# test to make sure the build output is there and error if not
|
||||
RUN test -f dist/server.mjs
|
||||
|
||||
RUN npm run build:cli
|
||||
|
||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
||||
RUN npm prune --omit=dev && npm cache clean --force
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
# FROM node:24-slim AS runner
|
||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Only curl and tzdata needed at runtime - no build tools!
|
||||
RUN apk add --no-cache curl tzdata
|
||||
RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
||||
# This includes the compiled native modules like better-sqlite3
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
COPY --from=builder-dev /app/.next/standalone ./
|
||||
COPY --from=builder-dev /app/.next/static ./.next/static
|
||||
COPY --from=builder-dev /app/dist ./dist
|
||||
COPY --from=builder-dev /app/server/migrations ./dist/init
|
||||
|
||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||
|
||||
COPY server/db/names.json ./dist/names.json
|
||||
COPY server/db/ios_models.json ./dist/ios_models.json
|
||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||
COPY public ./public
|
||||
|
||||
# Copy MaxMind databases for SaaS builds
|
||||
ARG BUILD=oss
|
||||
|
||||
RUN mkdir -p ./maxmind
|
||||
|
||||
# Copy MaxMind databases (placeholders exist for oss builds, real files for saas)
|
||||
COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb
|
||||
COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb
|
||||
|
||||
# Remove MaxMind databases for non-saas builds (keep only for saas)
|
||||
RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi
|
||||
|
||||
# OCI Image Labels - Build Args for dynamic values
|
||||
ARG VERSION="dev"
|
||||
ARG REVISION=""
|
||||
ARG CREATED=""
|
||||
ARG LICENSE="AGPL-3.0"
|
||||
|
||||
# Derive title and description based on BUILD type
|
||||
ARG IMAGE_TITLE="Pangolin"
|
||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
# OCI Image Labels
|
||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
||||
org.opencontainers.image.url="https://github.com/fosrl/pangolin" \
|
||||
org.opencontainers.image.documentation="https://docs.pangolin.net" \
|
||||
org.opencontainers.image.vendor="Fossorial" \
|
||||
org.opencontainers.image.licenses="${LICENSE}" \
|
||||
org.opencontainers.image.title="${IMAGE_TITLE}" \
|
||||
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.revision="${REVISION}" \
|
||||
org.opencontainers.image.created="${CREATED}"
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
|
||||
@@ -3,6 +3,25 @@
|
||||
major_tag := $(shell echo $(tag) | cut -d. -f1)
|
||||
minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
|
||||
|
||||
# OCI label variables
|
||||
CREATED := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
# Common OCI build args for OSS builds
|
||||
OCI_ARGS_OSS = --build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$(REVISION) \
|
||||
--build-arg CREATED=$(CREATED) \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
# Common OCI build args for Enterprise builds
|
||||
OCI_ARGS_EE = --build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$(REVISION) \
|
||||
--build-arg CREATED=$(CREATED) \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||
|
||||
.PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
|
||||
|
||||
build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
|
||||
@@ -15,6 +34,7 @@ build-sqlite:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
$(OCI_ARGS_OSS) \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:latest \
|
||||
--tag fosrl/pangolin:$(major_tag) \
|
||||
@@ -30,6 +50,7 @@ build-postgresql:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
$(OCI_ARGS_OSS) \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:postgresql-latest \
|
||||
--tag fosrl/pangolin:postgresql-$(major_tag) \
|
||||
@@ -45,6 +66,7 @@ build-ee-sqlite:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
$(OCI_ARGS_EE) \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-latest \
|
||||
--tag fosrl/pangolin:ee-$(major_tag) \
|
||||
@@ -60,6 +82,7 @@ build-ee-postgresql:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
$(OCI_ARGS_EE) \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-latest \
|
||||
--tag fosrl/pangolin:ee-postgresql-$(major_tag) \
|
||||
@@ -67,6 +90,18 @@ build-ee-postgresql:
|
||||
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
||||
--push .
|
||||
|
||||
build-saas:
|
||||
@if [ -z "$(tag)" ]; then \
|
||||
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||
exit 1; \
|
||||
fi
|
||||
docker buildx build \
|
||||
--build-arg BUILD=saas \
|
||||
--build-arg DATABASE=pg \
|
||||
--platform linux/arm64 \
|
||||
--tag $(AWS_IMAGE):$(tag) \
|
||||
--push .
|
||||
|
||||
build-release-arm:
|
||||
@if [ -z "$(tag)" ]; then \
|
||||
echo "Error: tag is required. Usage: make build-release-arm tag=<tag>"; \
|
||||
@@ -74,9 +109,16 @@ build-release-arm:
|
||||
fi
|
||||
@MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \
|
||||
MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \
|
||||
CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:latest-arm64 \
|
||||
--tag fosrl/pangolin:$$MAJOR_TAG-arm64 \
|
||||
@@ -86,6 +128,11 @@ build-release-arm:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:postgresql-latest-arm64 \
|
||||
--tag fosrl/pangolin:postgresql-$$MAJOR_TAG-arm64 \
|
||||
@@ -95,6 +142,12 @@ build-release-arm:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:ee-latest-arm64 \
|
||||
--tag fosrl/pangolin:ee-$$MAJOR_TAG-arm64 \
|
||||
@@ -104,6 +157,12 @@ build-release-arm:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-latest-arm64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-arm64 \
|
||||
@@ -118,9 +177,16 @@ build-release-amd:
|
||||
fi
|
||||
@MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \
|
||||
MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \
|
||||
CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:latest-amd64 \
|
||||
--tag fosrl/pangolin:$$MAJOR_TAG-amd64 \
|
||||
@@ -130,6 +196,11 @@ build-release-amd:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:postgresql-latest-amd64 \
|
||||
--tag fosrl/pangolin:postgresql-$$MAJOR_TAG-amd64 \
|
||||
@@ -139,6 +210,12 @@ build-release-amd:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-latest-amd64 \
|
||||
--tag fosrl/pangolin:ee-$$MAJOR_TAG-amd64 \
|
||||
@@ -148,6 +225,12 @@ build-release-amd:
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-latest-amd64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-amd64 \
|
||||
@@ -201,27 +284,51 @@ build-rc:
|
||||
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:$(tag) \
|
||||
--push .
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:postgresql-$(tag) \
|
||||
--push .
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-$(tag) \
|
||||
--push .
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64,linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-$(tag) \
|
||||
--push .
|
||||
@@ -231,27 +338,51 @@ build-rc-arm:
|
||||
echo "Error: tag is required. Usage: make build-rc-arm tag=<tag>"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:$(tag)-arm64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:postgresql-$(tag)-arm64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:ee-$(tag)-arm64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \
|
||||
--push .
|
||||
@@ -261,27 +392,51 @@ build-rc-amd:
|
||||
echo "Error: tag is required. Usage: make build-rc-amd tag=<tag>"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:$(tag)-amd64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=oss \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:postgresql-$(tag)-amd64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-$(tag)-amd64 \
|
||||
--push . && \
|
||||
docker buildx build \
|
||||
--build-arg BUILD=enterprise \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=$(tag) \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg LICENSE="Fossorial Commercial" \
|
||||
--build-arg IMAGE_TITLE="Pangolin EE" \
|
||||
--build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
--tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \
|
||||
--push .
|
||||
@@ -314,16 +469,52 @@ create-manifests-rc:
|
||||
echo "All RC multi-arch manifests created successfully!"
|
||||
|
||||
build-arm:
|
||||
docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest .
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg VERSION=dev \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/arm64 \
|
||||
-t fosrl/pangolin:latest .
|
||||
|
||||
build-x86:
|
||||
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker buildx build \
|
||||
--build-arg VERSION=dev \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
--platform linux/amd64 \
|
||||
-t fosrl/pangolin:latest .
|
||||
|
||||
dev-build-sqlite:
|
||||
docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker build \
|
||||
--build-arg DATABASE=sqlite \
|
||||
--build-arg VERSION=dev \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
-t fosrl/pangolin:latest .
|
||||
|
||||
dev-build-pg:
|
||||
docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
|
||||
@CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \
|
||||
REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \
|
||||
docker build \
|
||||
--build-arg DATABASE=pg \
|
||||
--build-arg VERSION=dev \
|
||||
--build-arg REVISION=$$REVISION \
|
||||
--build-arg CREATED=$$CREATED \
|
||||
--build-arg IMAGE_TITLE="Pangolin" \
|
||||
--build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \
|
||||
-t fosrl/pangolin:postgresql-latest .
|
||||
|
||||
test:
|
||||
docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest
|
||||
|
||||
@@ -37,35 +37,51 @@
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
</strong>
|
||||
</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, all with zero-trust security and granular access control.
|
||||
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.
|
||||
|
||||
## Installation
|
||||
|
||||
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
- Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
|
||||
- Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
|
||||
<img src="public/screenshots/hero.png" />
|
||||
<img src="public/screenshots/hero.png" alt="Pangolin" width="100%" />
|
||||
|
||||
## Deployment Options
|
||||
|
||||
| <img width=500 /> | Description |
|
||||
|-----------------|--------------|
|
||||
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/nodes) and connect to our control plane. |
|
||||
- **Pangolin Cloud** — Fully managed service - no infrastructure required.
|
||||
- **Self-Host: Community Edition** — Free, open source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** — Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
|
||||
## Key Features
|
||||
|
||||
| <img width=500 /> | <img width=500 /> |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||
| **Client-based private resource access**<br /><br />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. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
|
||||
| **Zero-trust granular access**<br /><br />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 and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
|
||||
### Connect remote networks with sites and NAT traversal
|
||||
|
||||
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.
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||
|
||||
### 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.
|
||||
|
||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||
|
||||
### Client-based private resource access
|
||||
|
||||
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.
|
||||
|
||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||
|
||||
### 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.
|
||||
|
||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
@@ -74,20 +90,21 @@ Download the Pangolin client for your platform:
|
||||
- [Mac](https://pangolin.net/downloads/mac)
|
||||
- [Windows](https://pangolin.net/downloads/windows)
|
||||
- [Linux](https://pangolin.net/downloads/linux)
|
||||
- [iOS](https://pangolin.net/downloads/ios)
|
||||
- [Android](https://pangolin.net/downloads/android)
|
||||
|
||||
## Get Started
|
||||
|
||||
### Sign up now
|
||||
|
||||
Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
|
||||
|
||||
### Check out the docs
|
||||
|
||||
We encourage everyone to read the full documentation first, which is
|
||||
available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of
|
||||
the docs to illustrate some basic ideas.
|
||||
|
||||
### Sign up and try now
|
||||
|
||||
For Pangolin's managed service, you will first need to create an account at
|
||||
[app.pangolin.net](https://app.pangolin.net). We have a generous free tier to get started.
|
||||
|
||||
## 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).
|
||||
@@ -95,7 +112,3 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License
|
||||
## Contributions
|
||||
|
||||
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
||||
|
||||
---
|
||||
|
||||
WireGuard® is a registered trademark of Jason A. Donenfeld.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us:
|
||||
|
||||
1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk.
|
||||
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include:
|
||||
2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) with the following information:
|
||||
|
||||
- Description and location of the vulnerability.
|
||||
- Potential impact of the vulnerability.
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import requests
|
||||
import yaml
|
||||
import json
|
||||
import base64
|
||||
|
||||
# The file path for the YAML file to be read
|
||||
# You can change this to the path of your YAML file
|
||||
YAML_FILE_PATH = 'blueprint.yaml'
|
||||
|
||||
# The API endpoint and headers from the curl request
|
||||
API_URL = 'http://api.pangolin.net/v1/org/test/blueprint'
|
||||
HEADERS = {
|
||||
'accept': '*/*',
|
||||
'Authorization': 'Bearer <your_token_here>',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def convert_and_send(file_path, url, headers):
|
||||
"""
|
||||
Reads a YAML file, converts its content to a JSON payload,
|
||||
and sends it via a PUT request to a specified URL.
|
||||
"""
|
||||
try:
|
||||
# Read the YAML file content
|
||||
with open(file_path, 'r') as file:
|
||||
yaml_content = file.read()
|
||||
|
||||
# Parse the YAML string to a Python dictionary
|
||||
# This will be used to ensure the YAML is valid before sending
|
||||
parsed_yaml = yaml.safe_load(yaml_content)
|
||||
|
||||
# convert the parsed YAML to a JSON string
|
||||
json_payload = json.dumps(parsed_yaml)
|
||||
print("Converted JSON payload:")
|
||||
print(json_payload)
|
||||
|
||||
# Encode the JSON string to Base64
|
||||
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
|
||||
|
||||
# Create the final payload with the base64 encoded data
|
||||
final_payload = {
|
||||
"blueprint": encoded_json
|
||||
}
|
||||
|
||||
print("Sending the following Base64 encoded JSON payload:")
|
||||
print(final_payload)
|
||||
print("-" * 20)
|
||||
|
||||
# Make the PUT request with the base64 encoded payload
|
||||
response = requests.put(url, headers=headers, json=final_payload)
|
||||
|
||||
# Print the API response for debugging
|
||||
print(f"API Response Status Code: {response.status_code}")
|
||||
print("API Response Content:")
|
||||
print(response.text)
|
||||
|
||||
# Raise an exception for bad status codes (4xx or 5xx)
|
||||
response.raise_for_status()
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: The file '{file_path}' was not found.")
|
||||
except yaml.YAMLError as e:
|
||||
print(f"Error parsing YAML file: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred during the API request: {e}")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
# Run the function
|
||||
if __name__ == "__main__":
|
||||
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
client-resources:
|
||||
client-resource-nice-id-uno:
|
||||
name: this is my resource
|
||||
protocol: tcp
|
||||
proxy-port: 3001
|
||||
hostname: localhost
|
||||
internal-port: 3000
|
||||
site: lively-yosemite-toad
|
||||
client-resource-nice-id-duce:
|
||||
name: this is my resource
|
||||
protocol: udp
|
||||
proxy-port: 3000
|
||||
hostname: localhost
|
||||
internal-port: 3000
|
||||
site: lively-yosemite-toad
|
||||
|
||||
proxy-resources:
|
||||
resource-nice-id-uno:
|
||||
name: this is my resource
|
||||
protocol: http
|
||||
full-domain: duce.test.example.com
|
||||
host-header: example.com
|
||||
tls-server-name: example.com
|
||||
# auth:
|
||||
# pincode: 123456
|
||||
# password: sadfasdfadsf
|
||||
# sso-enabled: true
|
||||
# sso-roles:
|
||||
# - Member
|
||||
# sso-users:
|
||||
# - owen@pangolin.net
|
||||
# whitelist-users:
|
||||
# - owen@pangolin.net
|
||||
# auto-login-idp: 1
|
||||
headers:
|
||||
- name: X-Example-Header
|
||||
value: example-value
|
||||
- name: X-Another-Header
|
||||
value: another-value
|
||||
rules:
|
||||
- action: allow
|
||||
match: ip
|
||||
value: 1.1.1.1
|
||||
- action: deny
|
||||
match: cidr
|
||||
value: 2.2.2.2/32
|
||||
- action: pass
|
||||
match: path
|
||||
value: /admin
|
||||
targets:
|
||||
- site: lively-yosemite-toad
|
||||
path: /path
|
||||
pathMatchType: prefix
|
||||
hostname: localhost
|
||||
method: http
|
||||
port: 8000
|
||||
- site: slim-alpine-chipmunk
|
||||
hostname: localhost
|
||||
path: /yoman
|
||||
pathMatchType: exact
|
||||
method: http
|
||||
port: 8001
|
||||
resource-nice-id-duce:
|
||||
name: this is other resource
|
||||
protocol: tcp
|
||||
proxy-port: 3000
|
||||
targets:
|
||||
- site: lively-yosemite-toad
|
||||
hostname: localhost
|
||||
port: 3000
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, licenseKey } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
type ClearLicenseKeysArgs = { };
|
||||
|
||||
export const clearLicenseKeys: CommandModule<
|
||||
{},
|
||||
ClearLicenseKeysArgs
|
||||
> = {
|
||||
command: "clear-license-keys",
|
||||
describe:
|
||||
"Clear all license keys from the database",
|
||||
// no args
|
||||
builder: (yargs) => {
|
||||
return yargs;
|
||||
},
|
||||
handler: async (argv: {}) => {
|
||||
try {
|
||||
|
||||
console.log(`Clearing all license keys from the database`);
|
||||
|
||||
// Delete all license keys
|
||||
const deletedCount = await db
|
||||
.delete(licenseKey)
|
||||
.where(eq(licenseKey.licenseKeyId, licenseKey.licenseKeyId)) .returning();; // delete all
|
||||
|
||||
console.log(`Deleted ${deletedCount.length} license key(s) from the database`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, clients, olms, currentFingerprint, userClients, approvals } from "@server/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
|
||||
type DeleteClientArgs = {
|
||||
orgId: string;
|
||||
niceId: string;
|
||||
};
|
||||
|
||||
export const deleteClient: CommandModule<{}, DeleteClientArgs> = {
|
||||
command: "delete-client",
|
||||
describe:
|
||||
"Delete a client and all associated data (OLMs, current fingerprint, userClients, approvals). Snapshots are preserved.",
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option("orgId", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "The organization ID"
|
||||
})
|
||||
.option("niceId", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "The client niceId (identifier)"
|
||||
});
|
||||
},
|
||||
handler: async (argv: { orgId: string; niceId: string }) => {
|
||||
try {
|
||||
const { orgId, niceId } = argv;
|
||||
|
||||
console.log(
|
||||
`Deleting client with orgId: ${orgId}, niceId: ${niceId}...`
|
||||
);
|
||||
|
||||
// Find the client
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.orgId, orgId), eq(clients.niceId, niceId)))
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
console.error(
|
||||
`Error: Client with orgId "${orgId}" and niceId "${niceId}" not found.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clientId = client.clientId;
|
||||
console.log(`Found client with clientId: ${clientId}`);
|
||||
|
||||
// Find all OLMs associated with this client
|
||||
const associatedOlms = await db
|
||||
.select()
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, clientId));
|
||||
|
||||
console.log(`Found ${associatedOlms.length} OLM(s) associated with this client`);
|
||||
|
||||
// Delete in a transaction to ensure atomicity
|
||||
await db.transaction(async (trx) => {
|
||||
// Delete currentFingerprint entries for the associated OLMs
|
||||
// Note: We delete these explicitly before deleting OLMs to ensure
|
||||
// we have control, even though cascade would handle it
|
||||
let fingerprintCount = 0;
|
||||
if (associatedOlms.length > 0) {
|
||||
const olmIds = associatedOlms.map((olm) => olm.olmId);
|
||||
const deletedFingerprints = await trx
|
||||
.delete(currentFingerprint)
|
||||
.where(inArray(currentFingerprint.olmId, olmIds))
|
||||
.returning();
|
||||
fingerprintCount = deletedFingerprints.length;
|
||||
}
|
||||
console.log(`Deleted ${fingerprintCount} current fingerprint(s)`);
|
||||
|
||||
// Delete OLMs
|
||||
// Note: OLMs have onDelete: "set null" for clientId, so we need to delete them explicitly
|
||||
const deletedOlms = await trx
|
||||
.delete(olms)
|
||||
.where(eq(olms.clientId, clientId))
|
||||
.returning();
|
||||
console.log(`Deleted ${deletedOlms.length} OLM(s)`);
|
||||
|
||||
// Delete approvals
|
||||
// Note: Approvals have onDelete: "cascade" but we delete explicitly for clarity
|
||||
const deletedApprovals = await trx
|
||||
.delete(approvals)
|
||||
.where(eq(approvals.clientId, clientId))
|
||||
.returning();
|
||||
console.log(`Deleted ${deletedApprovals.length} approval(s)`);
|
||||
|
||||
// Delete userClients
|
||||
// Note: userClients have onDelete: "cascade" but we delete explicitly for clarity
|
||||
const deletedUserClients = await trx
|
||||
.delete(userClients)
|
||||
.where(eq(userClients.clientId, clientId))
|
||||
.returning();
|
||||
console.log(`Deleted ${deletedUserClients.length} userClient association(s)`);
|
||||
|
||||
// Finally, delete the client itself
|
||||
const deletedClients = await trx
|
||||
.delete(clients)
|
||||
.where(eq(clients.clientId, clientId))
|
||||
.returning();
|
||||
console.log(`Deleted client: ${deletedClients[0]?.name || niceId}`);
|
||||
});
|
||||
|
||||
console.log("\nClient deletion completed successfully!");
|
||||
console.log("\nSummary:");
|
||||
console.log(` - Client: ${niceId} (clientId: ${clientId})`);
|
||||
console.log(` - Olm(s): ${associatedOlms.length}`);
|
||||
console.log(` - Current fingerprints: deleted`);
|
||||
console.log(` - Approvals: deleted`);
|
||||
console.log(` - UserClients: deleted`);
|
||||
console.log(` - Snapshots: preserved (not deleted)`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error deleting client:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||
import { generateCA } from "@server/lib/sshCA";
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
type GenerateOrgCaKeysArgs = {
|
||||
orgId: string;
|
||||
secret?: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = {
|
||||
command: "generate-org-ca-keys",
|
||||
describe:
|
||||
"Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)",
|
||||
builder: (yargs) => {
|
||||
return yargs
|
||||
.option("orgId", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "The organization ID"
|
||||
})
|
||||
.option("secret", {
|
||||
type: "string",
|
||||
describe:
|
||||
"Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)."
|
||||
})
|
||||
.option("force", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe:
|
||||
"Overwrite existing CA keys for the org if they already exist"
|
||||
});
|
||||
},
|
||||
handler: async (argv: {
|
||||
orgId: string;
|
||||
secret?: string;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const { orgId, force } = argv;
|
||||
let secret = argv.secret;
|
||||
|
||||
if (!secret) {
|
||||
const configPath = fs.existsSync(configFilePath1)
|
||||
? configFilePath1
|
||||
: fs.existsSync(configFilePath2)
|
||||
? configFilePath2
|
||||
: null;
|
||||
|
||||
if (!configPath) {
|
||||
console.error(
|
||||
"Error: No server secret provided and config file not found. " +
|
||||
"Expected config.yml or config.yaml in the config directory, or pass --secret."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(configPath, "utf8");
|
||||
const config = yaml.load(configContent) as {
|
||||
server?: { secret?: string };
|
||||
};
|
||||
|
||||
if (!config?.server?.secret) {
|
||||
console.error(
|
||||
"Error: No server.secret in config file. Pass --secret or set server.secret in config."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
secret = config.server.secret;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
orgId: orgs.orgId,
|
||||
sshCaPrivateKey: orgs.sshCaPrivateKey,
|
||||
sshCaPublicKey: orgs.sshCaPublicKey
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
console.error(`Error: Organization with orgId "${orgId}" not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) {
|
||||
if (!force) {
|
||||
console.error(
|
||||
"Error: This organization already has CA keys. Use --force to overwrite."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
|
||||
const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
|
||||
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({
|
||||
sshCaPrivateKey: encryptedPrivateKey,
|
||||
sshCaPublicKey: ca.publicKeyOpenSSH
|
||||
})
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
console.log("SSH CA keys generated and stored for org:", orgId);
|
||||
console.log("\nPublic key (OpenSSH format):");
|
||||
console.log(ca.publicKeyOpenSSH);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error generating org CA keys:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
||||
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
||||
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||
import { deleteClient } from "./commands/deleteClient";
|
||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("pangctl")
|
||||
@@ -13,5 +16,8 @@ yargs(hideBin(process.argv))
|
||||
.command(resetUserSecurityKeys)
|
||||
.command(clearExitNodes)
|
||||
.command(rotateServerSecret)
|
||||
.command(clearLicenseKeys)
|
||||
.command(deleteClient)
|
||||
.command(generateOrgCaKeys)
|
||||
.demandCommand()
|
||||
.help().argv;
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
# To see all available options, please visit the docs:
|
||||
# https://docs.pangolin.net/self-host/advanced/config-file
|
||||
|
||||
app:
|
||||
dashboard_url: http://localhost:3002
|
||||
log_level: debug
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: example.com
|
||||
|
||||
server:
|
||||
secret: my_secret_key
|
||||
# https://docs.pangolin.net/
|
||||
|
||||
gerbil:
|
||||
base_endpoint: example.com
|
||||
start_port: 51820
|
||||
base_endpoint: "{{.DashboardDomain}}"
|
||||
|
||||
orgs:
|
||||
block_size: 24
|
||||
subnet_group: 100.90.137.0/20
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
log_level: "info"
|
||||
telemetry:
|
||||
anonymous_usage: true
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
base_domain: "{{.BaseDomain}}"
|
||||
|
||||
server:
|
||||
secret: "{{.Secret}}"
|
||||
cors:
|
||||
origins: ["https://{{.DashboardDomain}}"]
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||
credentials: false
|
||||
|
||||
flags:
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: true
|
||||
allow_raw_resources: true
|
||||
enable_integration_api: true
|
||||
require_email_verification: false
|
||||
disable_signup_without_invite: true
|
||||
disable_user_create_org: false
|
||||
allow_raw_resources: true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*-journal
|
||||
@@ -1,5 +1,9 @@
|
||||
http:
|
||||
middlewares:
|
||||
badger:
|
||||
plugin:
|
||||
badger:
|
||||
disableForwardAuth: true
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
@@ -13,14 +17,16 @@ http:
|
||||
- web
|
||||
middlewares:
|
||||
- redirect-to-https
|
||||
- badger
|
||||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
service: next-service
|
||||
priority: 10
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- badger
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -28,9 +34,10 @@ http:
|
||||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
priority: 100
|
||||
entryPoints:
|
||||
- websecure
|
||||
middlewares:
|
||||
- badger
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
@@ -44,3 +51,12 @@ http:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:3000" # API/WebSocket server
|
||||
|
||||
tcp:
|
||||
serversTransports:
|
||||
pp-transport-v1:
|
||||
proxyProtocol:
|
||||
version: 1
|
||||
pp-transport-v2:
|
||||
proxyProtocol:
|
||||
version: 2
|
||||
|
||||
@@ -3,32 +3,52 @@ api:
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
directory: "/var/dynamic"
|
||||
watch: true
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.3.0"
|
||||
version: "{{.BadgerVersion}}"
|
||||
|
||||
log:
|
||||
level: "DEBUG"
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
maxSize: 100
|
||||
maxBackups: 3
|
||||
maxAge: 3
|
||||
compress: true
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "{{.LetsEncryptEmail}}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":9443"
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
|
||||
@@ -4,6 +4,12 @@ services:
|
||||
image: fosrl/pangolin:latest
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
reservations:
|
||||
memory: 256m
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
healthcheck:
|
||||
|
||||
@@ -7,8 +7,8 @@ services:
|
||||
POSTGRES_DB: postgres # Default database name
|
||||
POSTGRES_USER: postgres # Default user
|
||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||
volumes:
|
||||
- ./config/postgres:/var/lib/postgresql/data
|
||||
# volumes:
|
||||
# - ./config/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432" # Map host port 5432 to container port 5432
|
||||
restart: no
|
||||
|
||||
@@ -6,6 +6,12 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
// import { glob } from "glob";
|
||||
|
||||
// Read default build type from server/build.ts
|
||||
let build = "oss";
|
||||
const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8");
|
||||
const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/);
|
||||
if (m) build = m[1];
|
||||
|
||||
const banner = `
|
||||
// patch __dirname
|
||||
// import { fileURLToPath } from "url";
|
||||
@@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv))
|
||||
describe: "Build type (oss, saas, enterprise)",
|
||||
type: "string",
|
||||
choices: ["oss", "saas", "enterprise"],
|
||||
default: "oss"
|
||||
default: build
|
||||
})
|
||||
.help()
|
||||
.alias("help", "h").argv;
|
||||
@@ -275,7 +281,7 @@ esbuild
|
||||
})
|
||||
],
|
||||
sourcemap: "inline",
|
||||
target: "node22"
|
||||
target: "node24"
|
||||
})
|
||||
.then((result) => {
|
||||
// Check if there were any errors in the build result
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
all: update-versions go-build-release put-back
|
||||
dev-all: dev-update-versions dev-build dev-clean
|
||||
all: go-build-release
|
||||
|
||||
# Build with version injection via ldflags
|
||||
# Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x
|
||||
# Or fetched automatically if not provided (requires curl and jq)
|
||||
|
||||
PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name')
|
||||
GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
|
||||
BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
|
||||
|
||||
LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \
|
||||
-X main.gerbilVersion=$(GERBIL_VERSION) \
|
||||
-X main.badgerVersion=$(BADGER_VERSION)
|
||||
|
||||
go-build-release:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
|
||||
@echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)"
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64
|
||||
|
||||
clean:
|
||||
rm -f bin/installer_linux_amd64
|
||||
rm -f bin/installer_linux_arm64
|
||||
|
||||
update-versions:
|
||||
@echo "Fetching latest versions..."
|
||||
cp main.go main.go.bak && \
|
||||
$(MAKE) dev-update-versions
|
||||
|
||||
put-back:
|
||||
mv main.go.bak main.go
|
||||
|
||||
dev-update-versions:
|
||||
if [ -z "$(tag)" ]; then \
|
||||
PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name'); \
|
||||
else \
|
||||
PANGOLIN_VERSION=$(tag); \
|
||||
fi && \
|
||||
GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \
|
||||
BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \
|
||||
echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \
|
||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \
|
||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \
|
||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \
|
||||
echo "Updated main.go with latest versions"
|
||||
|
||||
dev-build: go-build-release
|
||||
|
||||
dev-clean:
|
||||
@echo "Restoring version values ..."
|
||||
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \
|
||||
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \
|
||||
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go
|
||||
@echo "Restored version strings in main.go"
|
||||
.PHONY: all go-build-release clean
|
||||
|
||||
@@ -99,11 +99,6 @@ func ReadAppConfig(configPath string) (*AppConfigValues, error) {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// findPattern finds the start of a pattern in a string
|
||||
func findPattern(s, pattern string) int {
|
||||
return bytes.Index([]byte(s), []byte(pattern))
|
||||
}
|
||||
|
||||
func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
// Read source file
|
||||
sourceData, err := os.ReadFile(sourceFile)
|
||||
@@ -118,19 +113,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
}
|
||||
|
||||
// Parse source Docker Compose YAML
|
||||
var sourceCompose map[string]interface{}
|
||||
var sourceCompose map[string]any
|
||||
if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil {
|
||||
return fmt.Errorf("error parsing source Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Parse destination Docker Compose YAML
|
||||
var destCompose map[string]interface{}
|
||||
var destCompose map[string]any
|
||||
if err := yaml.Unmarshal(destData, &destCompose); err != nil {
|
||||
return fmt.Errorf("error parsing destination Docker Compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section from source
|
||||
sourceServices, ok := sourceCompose["services"].(map[string]interface{})
|
||||
sourceServices, ok := sourceCompose["services"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found in source file or has invalid format")
|
||||
}
|
||||
@@ -142,10 +137,10 @@ func copyDockerService(sourceFile, destFile, serviceName string) error {
|
||||
}
|
||||
|
||||
// Get or create services section in destination
|
||||
destServices, ok := destCompose["services"].(map[string]interface{})
|
||||
destServices, ok := destCompose["services"].(map[string]any)
|
||||
if !ok {
|
||||
// If services section doesn't exist, create it
|
||||
destServices = make(map[string]interface{})
|
||||
destServices = make(map[string]any)
|
||||
destCompose["services"] = destServices
|
||||
}
|
||||
|
||||
@@ -187,17 +182,21 @@ func backupConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) {
|
||||
func MarshalYAMLWithIndent(data any, indent int) (resp []byte, err error) {
|
||||
buffer := new(bytes.Buffer)
|
||||
encoder := yaml.NewEncoder(buffer)
|
||||
encoder.SetIndent(indent)
|
||||
|
||||
err := encoder.Encode(data)
|
||||
if err != nil {
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer encoder.Close()
|
||||
defer func() {
|
||||
if cerr := encoder.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
@@ -209,7 +208,7 @@ func replaceInFile(filepath, oldStr, newStr string) error {
|
||||
}
|
||||
|
||||
// Replace the string
|
||||
newContent := strings.Replace(string(content), oldStr, newStr, -1)
|
||||
newContent := strings.ReplaceAll(string(content), oldStr, newStr)
|
||||
|
||||
// Write the modified content back to the file
|
||||
err = os.WriteFile(filepath, []byte(newContent), 0644)
|
||||
@@ -228,28 +227,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error {
|
||||
}
|
||||
|
||||
// Parse YAML into a generic map
|
||||
var compose map[string]interface{}
|
||||
var compose map[string]any
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return fmt.Errorf("error parsing compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section
|
||||
services, ok := compose["services"].(map[string]interface{})
|
||||
services, ok := compose["services"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found or invalid")
|
||||
}
|
||||
|
||||
// Get traefik service
|
||||
traefik, ok := services["traefik"].(map[string]interface{})
|
||||
traefik, ok := services["traefik"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("traefik service not found or invalid")
|
||||
}
|
||||
|
||||
// Check volumes
|
||||
logVolume := "./config/traefik/logs:/var/log/traefik"
|
||||
var volumes []interface{}
|
||||
var volumes []any
|
||||
|
||||
if existingVolumes, ok := traefik["volumes"].([]interface{}); ok {
|
||||
if existingVolumes, ok := traefik["volumes"].([]any); ok {
|
||||
// Check if volume already exists
|
||||
for _, v := range existingVolumes {
|
||||
if v.(string) == logVolume {
|
||||
@@ -295,13 +294,13 @@ func MergeYAML(baseFile, overlayFile string) error {
|
||||
}
|
||||
|
||||
// Parse base YAML into a map
|
||||
var baseMap map[string]interface{}
|
||||
var baseMap map[string]any
|
||||
if err := yaml.Unmarshal(baseContent, &baseMap); err != nil {
|
||||
return fmt.Errorf("error parsing base YAML: %v", err)
|
||||
}
|
||||
|
||||
// Parse overlay YAML into a map
|
||||
var overlayMap map[string]interface{}
|
||||
var overlayMap map[string]any
|
||||
if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil {
|
||||
return fmt.Errorf("error parsing overlay YAML: %v", err)
|
||||
}
|
||||
@@ -324,8 +323,8 @@ func MergeYAML(baseFile, overlayFile string) error {
|
||||
}
|
||||
|
||||
// mergeMap recursively merges two maps
|
||||
func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
func mergeMap(base, overlay map[string]any) map[string]any {
|
||||
result := make(map[string]any)
|
||||
|
||||
// Copy all key-values from base map
|
||||
for k, v := range base {
|
||||
@@ -336,8 +335,8 @@ func mergeMap(base, overlay map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range overlay {
|
||||
// If both maps have the same key and both values are maps, merge recursively
|
||||
if baseVal, ok := base[k]; ok {
|
||||
if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap {
|
||||
if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap {
|
||||
if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap {
|
||||
if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap {
|
||||
result[k] = mergeMap(baseMap, overlayMap)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -81,11 +81,19 @@ entryPoints:
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http3:
|
||||
advertisedPort: 443
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
middlewares:
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
|
||||
@@ -4,6 +4,12 @@ services:
|
||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
||||
container_name: pangolin
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
reservations:
|
||||
memory: 256m
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
healthcheck:
|
||||
@@ -32,15 +38,14 @@ services:
|
||||
- 51820:51820/udp
|
||||
- 21820:21820/udp
|
||||
- 443:443
|
||||
- 443:443/udp # For http3 QUIC if desired
|
||||
- 80:80
|
||||
{{end}}
|
||||
traefik:
|
||||
image: docker.io/traefik:v3.6
|
||||
container_name: traefik
|
||||
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:
|
||||
- 443:443
|
||||
- 80:80
|
||||
|
||||
@@ -40,12 +40,17 @@ entryPoints:
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http3:
|
||||
advertisedPort: 443
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
|
||||
ping:
|
||||
entryPoint: "web"
|
||||
entryPoint: "web"
|
||||
|
||||
@@ -144,12 +144,13 @@ func installDocker() error {
|
||||
}
|
||||
|
||||
func startDockerService() error {
|
||||
if runtime.GOOS == "linux" {
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd := exec.Command("systemctl", "enable", "--now", "docker")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
case "darwin":
|
||||
// On macOS, Docker is usually started via the Docker Desktop application
|
||||
fmt.Println("Please start Docker Desktop manually on macOS.")
|
||||
return nil
|
||||
@@ -210,6 +211,47 @@ func isDockerRunning() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func isPodmanRunning() bool {
|
||||
cmd := exec.Command("podman", "info")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// detectContainerType detects whether the system is currently using Docker or Podman
|
||||
// by checking which container runtime is running and has containers
|
||||
func detectContainerType() SupportedContainer {
|
||||
// Check if we have running containers with podman
|
||||
if isPodmanRunning() {
|
||||
cmd := exec.Command("podman", "ps", "-q")
|
||||
output, err := cmd.Output()
|
||||
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||
return Podman
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have running containers with docker
|
||||
if isDockerRunning() {
|
||||
cmd := exec.Command("docker", "ps", "-q")
|
||||
output, err := cmd.Output()
|
||||
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
|
||||
return Docker
|
||||
}
|
||||
}
|
||||
|
||||
// If no containers are running, check which one is installed and running
|
||||
if isPodmanRunning() && isPodmanInstalled() {
|
||||
return Podman
|
||||
}
|
||||
|
||||
if isDockerRunning() && isDockerInstalled() {
|
||||
return Docker
|
||||
}
|
||||
|
||||
return Undefined
|
||||
}
|
||||
|
||||
// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
|
||||
func executeDockerComposeCommandWithArgs(args ...string) error {
|
||||
var cmd *exec.Cmd
|
||||
@@ -261,7 +303,7 @@ func pullContainers(containerType SupportedContainer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
||||
}
|
||||
|
||||
// startContainers starts the containers using the appropriate command.
|
||||
@@ -284,7 +326,7 @@ func startContainers(containerType SupportedContainer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
||||
}
|
||||
|
||||
// stopContainers stops the containers using the appropriate command.
|
||||
@@ -306,7 +348,7 @@ func stopContainers(containerType SupportedContainer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
||||
}
|
||||
|
||||
// restartContainer restarts a specific container using the appropriate command.
|
||||
@@ -328,5 +370,5 @@ func restartContainer(container string, containerType SupportedContainer) error
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("Unsupported container type: %s", containerType)
|
||||
return fmt.Errorf("unsupported container type: %s", containerType)
|
||||
}
|
||||
|
||||
@@ -27,9 +27,18 @@ func installCrowdsec(config Config) error {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.MkdirAll("config/crowdsec/db", 0755)
|
||||
os.MkdirAll("config/crowdsec/acquis.d", 0755)
|
||||
os.MkdirAll("config/traefik/logs", 0755)
|
||||
if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.MkdirAll("config/traefik/logs", 0755); err != nil {
|
||||
fmt.Printf("Error creating config files: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
|
||||
fmt.Printf("Error copying docker service: %v\n", err)
|
||||
@@ -93,7 +102,7 @@ func installCrowdsec(config Config) error {
|
||||
|
||||
if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
|
||||
fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
|
||||
fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer")
|
||||
fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -117,7 +126,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) {
|
||||
}
|
||||
|
||||
// Execute the command to get the API key
|
||||
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||
cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
@@ -153,34 +162,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
|
||||
}
|
||||
|
||||
// Parse YAML into a generic map
|
||||
var compose map[string]interface{}
|
||||
var compose map[string]any
|
||||
if err := yaml.Unmarshal(data, &compose); err != nil {
|
||||
return fmt.Errorf("error parsing compose file: %w", err)
|
||||
}
|
||||
|
||||
// Get services section
|
||||
services, ok := compose["services"].(map[string]interface{})
|
||||
services, ok := compose["services"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("services section not found or invalid")
|
||||
}
|
||||
|
||||
// Get traefik service
|
||||
traefik, ok := services["traefik"].(map[string]interface{})
|
||||
traefik, ok := services["traefik"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("traefik service not found or invalid")
|
||||
}
|
||||
|
||||
// Get dependencies
|
||||
dependsOn, ok := traefik["depends_on"].(map[string]interface{})
|
||||
dependsOn, ok := traefik["depends_on"].(map[string]any)
|
||||
if ok {
|
||||
// Append the new block for crowdsec
|
||||
dependsOn["crowdsec"] = map[string]interface{}{
|
||||
dependsOn["crowdsec"] = map[string]any{
|
||||
"condition": "service_healthy",
|
||||
}
|
||||
} else {
|
||||
// No dependencies exist, create it
|
||||
traefik["depends_on"] = map[string]interface{}{
|
||||
"crowdsec": map[string]interface{}{
|
||||
traefik["depends_on"] = map[string]any{
|
||||
"crowdsec": map[string]any{
|
||||
"condition": "service_healthy",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
module installer
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
golang.org/x/term v0.38.0
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
golang.org/x/term v0.41.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.39.0 // indirect
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,7 +1,80 @@
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -1,92 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"syscall"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func readString(reader *bufio.Reader, prompt string, defaultValue string) string {
|
||||
// pangolinTheme is the custom theme using brand colors
|
||||
var pangolinTheme = ThemePangolin()
|
||||
|
||||
// isAccessibleMode checks if we should use accessible mode (simple prompts)
|
||||
// This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set
|
||||
func isAccessibleMode() bool {
|
||||
// Check if stdin is not a terminal (piped input, CI, etc.)
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
return true
|
||||
}
|
||||
// Check for dumb terminal
|
||||
if os.Getenv("TERM") == "dumb" {
|
||||
return true
|
||||
}
|
||||
// Check for explicit accessible mode request
|
||||
if os.Getenv("ACCESSIBLE") != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleAbort checks if the error is a user abort (Ctrl+C) and exits if so
|
||||
func handleAbort(err error) {
|
||||
if err != nil && errors.Is(err, huh.ErrUserAborted) {
|
||||
fmt.Println("\nInstallation cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// runField runs a single field with the Pangolin theme, handling accessible mode
|
||||
func runField(field huh.Field) error {
|
||||
if isAccessibleMode() {
|
||||
return field.RunAccessible(os.Stdout, os.Stdin)
|
||||
}
|
||||
form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme)
|
||||
return form.Run()
|
||||
}
|
||||
|
||||
func readString(prompt string, defaultValue string) string {
|
||||
var value string
|
||||
|
||||
title := prompt
|
||||
if defaultValue != "" {
|
||||
fmt.Printf("%s (default: %s): ", prompt, defaultValue)
|
||||
} else {
|
||||
fmt.Print(prompt + ": ")
|
||||
title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue)
|
||||
}
|
||||
input, _ := reader.ReadString('\n')
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func readStringNoDefault(reader *bufio.Reader, prompt string) string {
|
||||
fmt.Print(prompt + ": ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
return strings.TrimSpace(input)
|
||||
}
|
||||
input := huh.NewInput().
|
||||
Title(title).
|
||||
Value(&value)
|
||||
|
||||
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
fmt.Print(prompt + ": ")
|
||||
// Read password without echo if we're in a terminal
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt, reader)
|
||||
}
|
||||
return input
|
||||
} else {
|
||||
// Fallback to reading from stdin if not in a terminal
|
||||
return readString(reader, prompt, "")
|
||||
// If no default value, this field is required
|
||||
if defaultValue == "" {
|
||||
input = input.Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("this field is required")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||
defaultStr := "no"
|
||||
if defaultValue {
|
||||
defaultStr = "yes"
|
||||
}
|
||||
for {
|
||||
input := readString(reader, prompt+" (yes/no)", defaultStr)
|
||||
lower := strings.ToLower(input)
|
||||
if lower == "yes" {
|
||||
return true
|
||||
} else if lower == "no" {
|
||||
return false
|
||||
} else {
|
||||
fmt.Println("Please enter 'yes' or 'no'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
err := runField(input)
|
||||
handleAbort(err)
|
||||
|
||||
func readBoolNoDefault(reader *bufio.Reader, prompt string) bool {
|
||||
for {
|
||||
input := readStringNoDefault(reader, prompt+" (yes/no)")
|
||||
lower := strings.ToLower(input)
|
||||
if lower == "yes" {
|
||||
return true
|
||||
} else if lower == "no" {
|
||||
return false
|
||||
} else {
|
||||
fmt.Println("Please enter 'yes' or 'no'.")
|
||||
}
|
||||
if value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func readInt(reader *bufio.Reader, prompt string, defaultValue int) int {
|
||||
input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue))
|
||||
if input == "" {
|
||||
return defaultValue
|
||||
// Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows)
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %s\n", prompt, value)
|
||||
}
|
||||
value := defaultValue
|
||||
fmt.Sscanf(input, "%d", &value)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func readPassword(prompt string) string {
|
||||
var value string
|
||||
|
||||
for {
|
||||
input := huh.NewInput().
|
||||
Title(prompt).
|
||||
Value(&value).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runField(input)
|
||||
handleAbort(err)
|
||||
|
||||
if value != "" {
|
||||
// Print confirmation without revealing the password
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %s\n", prompt, "********")
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readBool(prompt string, defaultValue bool) bool {
|
||||
var value = defaultValue
|
||||
|
||||
confirm := huh.NewConfirm().
|
||||
Title(prompt).
|
||||
Value(&value).
|
||||
Affirmative("Yes").
|
||||
Negative("No")
|
||||
|
||||
err := runField(confirm)
|
||||
handleAbort(err)
|
||||
|
||||
// Print the answer so it remains visible in terminal history
|
||||
if !isAccessibleMode() {
|
||||
answer := "No"
|
||||
if value {
|
||||
answer = "Yes"
|
||||
}
|
||||
fmt.Printf("%s: %s\n", prompt, answer)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func readBoolNoDefault(prompt string) bool {
|
||||
var value bool
|
||||
|
||||
confirm := huh.NewConfirm().
|
||||
Title(prompt).
|
||||
Value(&value).
|
||||
Affirmative("Yes").
|
||||
Negative("No")
|
||||
|
||||
err := runField(confirm)
|
||||
handleAbort(err)
|
||||
|
||||
// Print the answer so it remains visible in terminal history
|
||||
if !isAccessibleMode() {
|
||||
answer := "No"
|
||||
if value {
|
||||
answer = "Yes"
|
||||
}
|
||||
fmt.Printf("%s: %s\n", prompt, answer)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func readInt(prompt string, defaultValue int) int {
|
||||
var value string
|
||||
|
||||
title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue)
|
||||
|
||||
input := huh.NewInput().
|
||||
Title(title).
|
||||
Value(&value).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("please enter a valid number")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
err := runField(input)
|
||||
handleAbort(err)
|
||||
|
||||
if value == "" {
|
||||
// Print the answer so it remains visible in terminal history
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
result, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %d\n", prompt, defaultValue)
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// Print the answer so it remains visible in terminal history
|
||||
if !isAccessibleMode() {
|
||||
fmt.Printf("%s: %d\n", prompt, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
|
||||
// Version variables injected at build time via -ldflags
|
||||
var (
|
||||
pangolinVersion string
|
||||
gerbilVersion string
|
||||
badgerVersion string
|
||||
)
|
||||
|
||||
func loadVersions(config *Config) {
|
||||
config.PangolinVersion = "replaceme"
|
||||
config.GerbilVersion = "replaceme"
|
||||
config.BadgerVersion = "replaceme"
|
||||
config.PangolinVersion = pangolinVersion
|
||||
config.GerbilVersion = gerbilVersion
|
||||
config.BadgerVersion = badgerVersion
|
||||
}
|
||||
|
||||
//go:embed config/*
|
||||
@@ -81,14 +87,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
var config Config
|
||||
var alreadyInstalled = false
|
||||
|
||||
// Determine installation directory
|
||||
installDir := findOrSelectInstallDirectory()
|
||||
if err := os.Chdir(installDir); err != nil {
|
||||
fmt.Printf("Error changing to installation directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// check if there is already a config file
|
||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||
config = collectUserInput(reader)
|
||||
config = collectUserInput()
|
||||
|
||||
loadVersions(&config)
|
||||
config.DoCrowdsecInstall = false
|
||||
@@ -101,7 +112,10 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
moveFile("config/docker-compose.yml", "docker-compose.yml")
|
||||
if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil {
|
||||
fmt.Printf("Error moving docker-compose.yml: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\nConfiguration files created successfully!")
|
||||
|
||||
@@ -116,13 +130,17 @@ func main() {
|
||||
|
||||
fmt.Println("\n=== Starting installation ===")
|
||||
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
if readBool("Would you like to install and start the containers?", true) {
|
||||
|
||||
config.InstallationContainerType = podmanOrDocker(reader)
|
||||
config.InstallationContainerType = podmanOrDocker()
|
||||
|
||||
if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker {
|
||||
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
|
||||
installDocker()
|
||||
if readBool("Docker is not installed. Would you like to install it?", true) {
|
||||
if err := installDocker(); err != nil {
|
||||
fmt.Printf("Error installing Docker: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// try to start docker service but ignore errors
|
||||
if err := startDockerService(); err != nil {
|
||||
fmt.Println("Error starting Docker service:", err)
|
||||
@@ -131,7 +149,7 @@ func main() {
|
||||
}
|
||||
// wait 10 seconds for docker to start checking if docker is running every 2 seconds
|
||||
fmt.Println("Waiting for Docker to start...")
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
if isDockerRunning() {
|
||||
fmt.Println("Docker is running!")
|
||||
break
|
||||
@@ -166,7 +184,7 @@ func main() {
|
||||
fmt.Println("\n=== MaxMind Database Update ===")
|
||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||
if readBool(reader, "Would you like to update the MaxMind database to the latest version?", false) {
|
||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
||||
if err := downloadMaxMindDatabase(); err != nil {
|
||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||
fmt.Println("You can try updating it manually later if needed.")
|
||||
@@ -174,7 +192,7 @@ func main() {
|
||||
}
|
||||
} else {
|
||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
||||
if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
||||
if err := downloadMaxMindDatabase(); err != nil {
|
||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||
fmt.Println("You can try downloading it manually later if needed.")
|
||||
@@ -191,11 +209,11 @@ func main() {
|
||||
if !checkIsCrowdsecInstalledInCompose() {
|
||||
fmt.Println("\n=== CrowdSec Install ===")
|
||||
// check if crowdsec is installed
|
||||
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
||||
if readBool("Would you like to install CrowdSec?", false) {
|
||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
||||
|
||||
// BUG: crowdsec installation will be skipped if the user chooses to install on the first installation.
|
||||
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
||||
if readBool("Are you willing to manage CrowdSec?", false) {
|
||||
if config.DashboardDomain == "" {
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml")
|
||||
if err != nil {
|
||||
@@ -224,12 +242,21 @@ func main() {
|
||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||
|
||||
if !readBool(reader, "Are these values correct?", true) {
|
||||
config = collectUserInput(reader)
|
||||
if !readBool("Are these values correct?", true) {
|
||||
config = collectUserInput()
|
||||
}
|
||||
}
|
||||
|
||||
config.InstallationContainerType = podmanOrDocker(reader)
|
||||
// Try to detect container type from existing installation
|
||||
detectedType := detectContainerType()
|
||||
if detectedType == Undefined {
|
||||
// If detection fails, prompt the user
|
||||
fmt.Println("Unable to detect container type from existing installation.")
|
||||
config.InstallationContainerType = podmanOrDocker()
|
||||
} else {
|
||||
config.InstallationContainerType = detectedType
|
||||
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
|
||||
}
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
err := installCrowdsec(config)
|
||||
@@ -267,8 +294,119 @@ func main() {
|
||||
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
|
||||
}
|
||||
|
||||
func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||
func hasExistingInstall(dir string) bool {
|
||||
configPath := filepath.Join(dir, "config", "config.yml")
|
||||
_, err := os.Stat(configPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func findOrSelectInstallDirectory() string {
|
||||
const defaultInstallDir = "/opt/pangolin"
|
||||
|
||||
// Get current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting current directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 1. Check current directory for existing install
|
||||
if hasExistingInstall(cwd) {
|
||||
fmt.Printf("Found existing Pangolin installation in current directory: %s\n", cwd)
|
||||
return cwd
|
||||
}
|
||||
|
||||
// 2. Check default location (/opt/pangolin) for existing install
|
||||
if cwd != defaultInstallDir && hasExistingInstall(defaultInstallDir) {
|
||||
fmt.Printf("\nFound existing Pangolin installation at: %s\n", defaultInstallDir)
|
||||
if readBool(fmt.Sprintf("Would you like to use the existing installation at %s?", defaultInstallDir), true) {
|
||||
return defaultInstallDir
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No existing install found, prompt for installation directory
|
||||
fmt.Println("\n=== Installation Directory ===")
|
||||
fmt.Println("No existing Pangolin installation detected.")
|
||||
|
||||
installDir := readString("Enter the installation directory", defaultInstallDir)
|
||||
|
||||
// Expand ~ to home directory if present
|
||||
if strings.HasPrefix(installDir, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
fmt.Printf("Error getting home directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
installDir = filepath.Join(home, installDir[1:])
|
||||
}
|
||||
|
||||
// Convert to absolute path
|
||||
absPath, err := filepath.Abs(installDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Error resolving path: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
installDir = absPath
|
||||
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(installDir); os.IsNotExist(err) {
|
||||
// Directory doesn't exist, create it
|
||||
if readBool(fmt.Sprintf("Directory %s does not exist. Create it?", installDir), true) {
|
||||
if err := os.MkdirAll(installDir, 0755); err != nil {
|
||||
fmt.Printf("Error creating directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Created directory: %s\n", installDir)
|
||||
|
||||
// Offer to change ownership if running via sudo
|
||||
changeDirectoryOwnership(installDir)
|
||||
} else {
|
||||
fmt.Println("Installation cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Installation directory: %s\n", installDir)
|
||||
return installDir
|
||||
}
|
||||
|
||||
func changeDirectoryOwnership(dir string) {
|
||||
// Check if we're running via sudo by looking for SUDO_USER
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser == "" || os.Geteuid() != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sudoUID := os.Getenv("SUDO_UID")
|
||||
sudoGID := os.Getenv("SUDO_GID")
|
||||
|
||||
if sudoUID == "" || sudoGID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nRunning as root via sudo (original user: %s)\n", sudoUser)
|
||||
if readBool(fmt.Sprintf("Would you like to change ownership of %s to user '%s'? This makes it easier to manage config files without sudo.", dir, sudoUser), true) {
|
||||
uid, err := strconv.Atoi(sudoUID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not parse SUDO_UID: %v\n", err)
|
||||
return
|
||||
}
|
||||
gid, err := strconv.Atoi(sudoGID)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: Could not parse SUDO_GID: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.Chown(dir, uid, gid); err != nil {
|
||||
fmt.Printf("Warning: Could not change ownership: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Changed ownership of %s to %s\n", dir, sudoUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func podmanOrDocker() SupportedContainer {
|
||||
inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker")
|
||||
|
||||
chosenContainer := Docker
|
||||
if strings.EqualFold(inputContainer, "docker") {
|
||||
@@ -280,16 +418,17 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if chosenContainer == Podman {
|
||||
switch chosenContainer {
|
||||
case Podman:
|
||||
if !isPodmanInstalled() {
|
||||
fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
||||
if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
|
||||
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
|
||||
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
|
||||
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true)
|
||||
approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
|
||||
if approved {
|
||||
if os.Geteuid() != 0 {
|
||||
fmt.Println("You need to run the installer as root for such a configuration.")
|
||||
@@ -300,8 +439,8 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
// container low-range ports as unprivileged ports.
|
||||
// Linux only.
|
||||
|
||||
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil {
|
||||
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
|
||||
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil {
|
||||
fmt.Printf("Error configuring unprivileged ports: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
@@ -311,7 +450,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
fmt.Println("Unprivileged ports have been configured.")
|
||||
}
|
||||
|
||||
} else if chosenContainer == Docker {
|
||||
case Docker:
|
||||
// check if docker is not installed and the user is root
|
||||
if !isDockerInstalled() {
|
||||
if os.Geteuid() != 0 {
|
||||
@@ -326,7 +465,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
fmt.Println("The installer will not be able to run docker commands without running it as root.")
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
// This shouldn't happen unless there's a third container runtime.
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -334,35 +473,35 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
|
||||
return chosenContainer
|
||||
}
|
||||
|
||||
func collectUserInput(reader *bufio.Reader) Config {
|
||||
func collectUserInput() Config {
|
||||
config := Config{}
|
||||
|
||||
// Basic configuration
|
||||
fmt.Println("\n=== Basic Configuration ===")
|
||||
|
||||
config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for persoal 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.")
|
||||
|
||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||
|
||||
// Set default dashboard domain after base domain is collected
|
||||
defaultDashboardDomain := ""
|
||||
if config.BaseDomain != "" {
|
||||
defaultDashboardDomain = "pangolin." + config.BaseDomain
|
||||
}
|
||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
||||
config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
|
||||
config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "")
|
||||
config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true)
|
||||
|
||||
// Email configuration
|
||||
fmt.Println("\n=== Email Configuration ===")
|
||||
config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false)
|
||||
config.EnableEmail = readBool("Enable email functionality (SMTP)", false)
|
||||
|
||||
if config.EnableEmail {
|
||||
config.EmailSMTPHost = readString(reader, "Enter SMTP host", "")
|
||||
config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587)
|
||||
config.EmailSMTPUser = readString(reader, "Enter SMTP username", "")
|
||||
config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword?
|
||||
config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "")
|
||||
config.EmailSMTPHost = readString("Enter SMTP host", "")
|
||||
config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587)
|
||||
config.EmailSMTPUser = readString("Enter SMTP username", "")
|
||||
config.EmailSMTPPass = readPassword("Enter SMTP password")
|
||||
config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "")
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
@@ -383,8 +522,8 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||
|
||||
fmt.Println("\n=== Advanced Configuration ===")
|
||||
|
||||
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
|
||||
config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", 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)
|
||||
|
||||
if config.DashboardDomain == "" {
|
||||
fmt.Println("Error: Dashboard Domain name is required")
|
||||
@@ -395,15 +534,23 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||
}
|
||||
|
||||
func createConfigFiles(config Config) error {
|
||||
os.MkdirAll("config", 0755)
|
||||
os.MkdirAll("config/letsencrypt", 0755)
|
||||
os.MkdirAll("config/db", 0755)
|
||||
os.MkdirAll("config/logs", 0755)
|
||||
if err := os.MkdirAll("config", 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll("config/letsencrypt", 0755); err != nil {
|
||||
return fmt.Errorf("failed to create letsencrypt directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll("config/db", 0755); err != nil {
|
||||
return fmt.Errorf("failed to create db directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll("config/logs", 0755); err != nil {
|
||||
return fmt.Errorf("failed to create logs directory: %v", err)
|
||||
}
|
||||
|
||||
// Walk through all embedded files
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, walkErr error) (err error) {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
// Skip the root fs directory itself
|
||||
@@ -454,7 +601,11 @@ func createConfigFiles(config Config) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create %s: %v", path, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
defer func() {
|
||||
if cerr := outFile.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
// Execute template
|
||||
if err := tmpl.Execute(outFile, config); err != nil {
|
||||
@@ -470,18 +621,26 @@ func createConfigFiles(config Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
func copyFile(src, dst string) (err error) {
|
||||
source, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer source.Close()
|
||||
defer func() {
|
||||
if cerr := source.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
destination, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destination.Close()
|
||||
defer func() {
|
||||
if cerr := destination.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(destination, source)
|
||||
return err
|
||||
@@ -552,22 +711,24 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
||||
fmt.Println("To get your setup token, you need to:")
|
||||
fmt.Println("")
|
||||
fmt.Println("1. Start the containers")
|
||||
if containerType == Docker {
|
||||
switch containerType {
|
||||
case Docker:
|
||||
fmt.Println(" docker compose up -d")
|
||||
} else if containerType == Podman {
|
||||
case Podman:
|
||||
fmt.Println(" podman-compose up -d")
|
||||
} else {
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
|
||||
fmt.Println("")
|
||||
fmt.Println("3. Check the container logs for the setup token")
|
||||
if containerType == Docker {
|
||||
switch containerType {
|
||||
case Docker:
|
||||
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||
} else if containerType == Podman {
|
||||
case Podman:
|
||||
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
|
||||
} else {
|
||||
}
|
||||
|
||||
fmt.Println("")
|
||||
fmt.Println("4. Look for output like")
|
||||
fmt.Println(" === SETUP TOKEN GENERATED ===")
|
||||
@@ -583,43 +744,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
|
||||
}
|
||||
|
||||
func generateRandomSecretKey() string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const length = 32
|
||||
|
||||
var seededRand *rand.Rand = rand.New(
|
||||
rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func getPublicIP() string {
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("https://ifconfig.io/ip")
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
if err != nil {
|
||||
return ""
|
||||
panic(fmt.Sprintf("Failed to generate random secret key: %v", err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
ip := strings.TrimSpace(string(body))
|
||||
|
||||
// Validate that it's a valid IP address
|
||||
if net.ParseIP(ip) != nil {
|
||||
return ip
|
||||
}
|
||||
|
||||
return ""
|
||||
return base64.StdEncoding.EncodeToString(secret)
|
||||
}
|
||||
|
||||
// Run external commands with stdio/stderr attached.
|
||||
@@ -634,10 +764,7 @@ func checkPortsAvailable(port int) error {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"ERROR: port %d is occupied or cannot be bound: %w\n\n",
|
||||
port, err,
|
||||
)
|
||||
return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err)
|
||||
}
|
||||
if closeErr := ln.Close(); closeErr != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Pangolin brand colors (converted from oklch to hex)
|
||||
var (
|
||||
// Primary orange/amber - oklch(0.6717 0.1946 41.93)
|
||||
primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"}
|
||||
// Muted foreground
|
||||
mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"}
|
||||
// Success green
|
||||
successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"}
|
||||
// Error red - oklch(0.577 0.245 27.325)
|
||||
errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"}
|
||||
// Normal text
|
||||
normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"}
|
||||
)
|
||||
|
||||
// ThemePangolin returns a huh theme using Pangolin brand colors
|
||||
func ThemePangolin() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
// Focused state styles
|
||||
t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor)
|
||||
t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true)
|
||||
t.Focused.Description = t.Focused.Description.Foreground(mutedColor)
|
||||
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor)
|
||||
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor)
|
||||
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor)
|
||||
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor)
|
||||
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor)
|
||||
t.Focused.Option = t.Focused.Option.Foreground(normalFg)
|
||||
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor)
|
||||
t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ")
|
||||
t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ")
|
||||
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor)
|
||||
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"})
|
||||
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor)
|
||||
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor)
|
||||
|
||||
// Blurred state inherits from focused but with hidden border
|
||||
t.Blurred = t.Focused
|
||||
t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
|
||||
t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false)
|
||||
t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor)
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# --- Configuration ---
|
||||
# The header text to be added to the files.
|
||||
HEADER_TEXT = """/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
"""
|
||||
|
||||
HEADER_NORMALIZED = HEADER_TEXT.strip()
|
||||
|
||||
|
||||
def extract_leading_block_comment(content):
|
||||
"""
|
||||
If the file content begins with a /* ... */ block comment, return the
|
||||
full text of that comment (including the delimiters) and the index at
|
||||
which the rest of the file starts (after any trailing newlines).
|
||||
Returns (None, 0) when no such comment is found.
|
||||
"""
|
||||
stripped = content.lstrip()
|
||||
if not stripped.startswith('/*'):
|
||||
return None, 0
|
||||
|
||||
# Account for any leading whitespace before the comment
|
||||
comment_start = content.index('/*')
|
||||
end_marker = content.find('*/', comment_start + 2)
|
||||
if end_marker == -1:
|
||||
return None, 0
|
||||
|
||||
comment_end = end_marker + 2 # position just after '*/'
|
||||
comment_text = content[comment_start:comment_end].strip()
|
||||
|
||||
# Advance past any whitespace / newlines that follow the closing */
|
||||
rest_start = comment_end
|
||||
while rest_start < len(content) and content[rest_start] in '\n\r':
|
||||
rest_start += 1
|
||||
|
||||
return comment_text, rest_start
|
||||
|
||||
|
||||
def should_add_header(file_path):
|
||||
"""
|
||||
Checks if a file should receive the commercial license header.
|
||||
Returns True if 'server/private' is in the path.
|
||||
"""
|
||||
if 'server/private' in file_path.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def process_directory(root_dir):
|
||||
"""
|
||||
Recursively scans a directory and adds/replaces/removes headers in
|
||||
qualifying .ts or .tsx files, skipping any 'node_modules' directories.
|
||||
"""
|
||||
print(f"Scanning directory: {root_dir}")
|
||||
files_processed = 0
|
||||
files_modified = 0
|
||||
|
||||
for root, dirs, files in os.walk(root_dir):
|
||||
# Exclude 'node_modules' directories from the scan.
|
||||
if 'node_modules' in dirs:
|
||||
dirs.remove('node_modules')
|
||||
|
||||
for file in files:
|
||||
if not (file.endswith('.ts') or file.endswith('.tsx')):
|
||||
continue
|
||||
|
||||
file_path = os.path.join(root, file)
|
||||
files_processed += 1
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
original_content = f.read()
|
||||
|
||||
existing_comment, body_start = extract_leading_block_comment(
|
||||
original_content
|
||||
)
|
||||
has_any_header = existing_comment is not None
|
||||
has_correct_header = existing_comment == HEADER_NORMALIZED
|
||||
|
||||
body = original_content[body_start:] if has_any_header else original_content
|
||||
|
||||
if should_add_header(file_path):
|
||||
if has_correct_header:
|
||||
print(f"Header up-to-date: {file_path}")
|
||||
else:
|
||||
# Either no header exists or the header is outdated — write
|
||||
# the correct one.
|
||||
action = "Replaced header in" if has_any_header else "Added header to"
|
||||
new_content = HEADER_NORMALIZED + '\n\n' + body
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
print(f"{action}: {file_path}")
|
||||
files_modified += 1
|
||||
else:
|
||||
if has_any_header:
|
||||
# Remove the header — it shouldn't be here.
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(body)
|
||||
print(f"Removed header from: {file_path}")
|
||||
files_modified += 1
|
||||
else:
|
||||
print(f"No header needed: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing file {file_path}: {e}")
|
||||
|
||||
print("\n--- Scan Complete ---")
|
||||
print(f"Total .ts or .tsx files found: {files_processed}")
|
||||
print(f"Files modified (added/replaced/removed): {files_modified}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get the target directory from the command line arguments.
|
||||
# If no directory is provided, it uses the current directory ('.').
|
||||
if len(sys.argv) > 1:
|
||||
target_directory = sys.argv[1]
|
||||
else:
|
||||
target_directory = '.' # Default to current directory
|
||||
|
||||
if not os.path.isdir(target_directory):
|
||||
print(f"Error: Directory '{target_directory}' not found.")
|
||||
sys.exit(1)
|
||||
|
||||
process_directory(os.path.abspath(target_directory))
|
||||
@@ -18,6 +18,8 @@
|
||||
"componentsMember": "Eres un miembro de {count, plural, =0 {ninguna organización} one {una organización} other {# organizaciones}}.",
|
||||
"componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.",
|
||||
"dismiss": "Descartar",
|
||||
"subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.",
|
||||
"subscriptionViolationViewBilling": "Ver facturación",
|
||||
"componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.",
|
||||
"componentsSupporterMessage": "¡Gracias por apoyar a Pangolin como {tier}!",
|
||||
"inviteErrorNotValid": "Lo sentimos, pero parece que la invitación a la que intentas acceder no ha sido aceptada o ya no es válida.",
|
||||
@@ -56,6 +58,9 @@
|
||||
"sitesBannerTitle": "Conectar cualquier red",
|
||||
"sitesBannerDescription": "Un sitio es una conexión a una red remota que permite a Pangolin proporcionar acceso a recursos, públicos o privados, a usuarios en cualquier lugar. Instale el conector de red del sitio (Newt) en cualquier lugar donde pueda ejecutar un binario o contenedor para establecer la conexión.",
|
||||
"sitesBannerButtonText": "Instalar sitio",
|
||||
"approvalsBannerTitle": "Aprobar o denegar el acceso al dispositivo",
|
||||
"approvalsBannerDescription": "Revisar y aprobar o denegar las solicitudes de acceso al dispositivo de los usuarios. Cuando se requieren aprobaciones de dispositivos, los usuarios deben obtener la aprobación del administrador antes de que sus dispositivos puedan conectarse a los recursos de su organización.",
|
||||
"approvalsBannerButtonText": "Saber más",
|
||||
"siteCreate": "Crear sitio",
|
||||
"siteCreateDescription2": "Siga los pasos siguientes para crear y conectar un nuevo sitio",
|
||||
"siteCreateDescription": "Crear un nuevo sitio para empezar a conectar recursos",
|
||||
@@ -143,6 +148,11 @@
|
||||
"createLink": "Crear enlace",
|
||||
"resourcesNotFound": "No se encontraron recursos",
|
||||
"resourceSearch": "Buscar recursos",
|
||||
"machineSearch": "Buscar máquinas",
|
||||
"machinesSearch": "Buscar clientes...",
|
||||
"machineNotFound": "No hay máquinas",
|
||||
"userDeviceSearch": "Buscar dispositivos de usuario",
|
||||
"userDevicesSearch": "Buscar dispositivos de usuario...",
|
||||
"openMenu": "Abrir menú",
|
||||
"resource": "Recurso",
|
||||
"title": "Título",
|
||||
@@ -153,7 +163,7 @@
|
||||
"proxyResourceTitle": "Administrar recursos públicos",
|
||||
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
|
||||
"proxyResourcesBannerTitle": "Acceso público basado en web",
|
||||
"proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
|
||||
"proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||
"clientResourceTitle": "Administrar recursos privados",
|
||||
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
|
||||
"privateResourcesBannerTitle": "Acceso privado de confianza cero",
|
||||
@@ -170,6 +180,7 @@
|
||||
"resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.",
|
||||
"resourceRaw": "Recurso TCP/UDP sin procesar",
|
||||
"resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.",
|
||||
"resourceRawDescriptionCloud": "Las peticiones de proxy sobre TCP/UDP crudas usando un número de puerto. Requiere que los sitios se conecten a un nodo remoto.",
|
||||
"resourceCreate": "Crear Recurso",
|
||||
"resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso",
|
||||
"resourceSeeAll": "Ver todos los recursos",
|
||||
@@ -196,6 +207,7 @@
|
||||
"protocolSelect": "Seleccionar un protocolo",
|
||||
"resourcePortNumber": "Número de puerto",
|
||||
"resourcePortNumberDescription": "El número de puerto externo a las solicitudes de proxy.",
|
||||
"back": "Atrás",
|
||||
"cancel": "Cancelar",
|
||||
"resourceConfig": "Fragmentos de configuración",
|
||||
"resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP",
|
||||
@@ -241,6 +253,17 @@
|
||||
"orgErrorDeleteMessage": "Se ha producido un error al eliminar la organización.",
|
||||
"orgDeleted": "Organización eliminada",
|
||||
"orgDeletedMessage": "La organización y sus datos han sido eliminados.",
|
||||
"deleteAccount": "Eliminar cuenta",
|
||||
"deleteAccountDescription": "Elimina permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
|
||||
"deleteAccountButton": "Eliminar cuenta",
|
||||
"deleteAccountConfirmTitle": "Eliminar cuenta",
|
||||
"deleteAccountConfirmMessage": "Esto borrará permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.",
|
||||
"deleteAccountConfirmString": "eliminar cuenta",
|
||||
"deleteAccountSuccess": "Cuenta eliminada",
|
||||
"deleteAccountSuccessMessage": "Tu cuenta ha sido eliminada.",
|
||||
"deleteAccountError": "Error al eliminar la cuenta",
|
||||
"deleteAccountPreviewAccount": "Tu cuenta",
|
||||
"deleteAccountPreviewOrgs": "Organizaciones que tienes (y todos sus datos)",
|
||||
"orgMissing": "Falta el ID de la organización",
|
||||
"orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.",
|
||||
"accessUsersManage": "Administrar usuarios",
|
||||
@@ -257,6 +280,8 @@
|
||||
"accessRolesSearch": "Buscar roles...",
|
||||
"accessRolesAdd": "Añadir rol",
|
||||
"accessRoleDelete": "Eliminar rol",
|
||||
"accessApprovalsManage": "Administrar aprobaciones",
|
||||
"accessApprovalsDescription": "Ver y administrar aprobaciones pendientes para el acceso a esta organización",
|
||||
"description": "Descripción",
|
||||
"inviteTitle": "Invitaciones abiertas",
|
||||
"inviteDescription": "Administrar invitaciones para que otros usuarios se unan a la organización",
|
||||
@@ -303,11 +328,59 @@
|
||||
"apiKeysDelete": "Borrar Clave API",
|
||||
"apiKeysManage": "Administrar claves API",
|
||||
"apiKeysDescription": "Las claves API se utilizan para autenticar con la API de integración",
|
||||
"provisioningKeysTitle": "Clave de aprovisionamiento",
|
||||
"provisioningKeysManage": "Administrar Claves de Aprovisionamiento",
|
||||
"provisioningKeysDescription": "Las claves de aprovisionamiento se utilizan para autenticar la provisión automatizada del sitio para su organización.",
|
||||
"provisioningManage": "Aprovisionamiento",
|
||||
"provisioningDescription": "Administrar las claves de aprovisionamiento y revisar los sitios pendientes de aprobación.",
|
||||
"pendingSites": "Sitios pendientes",
|
||||
"siteApproveSuccess": "Sitio aprobado con éxito",
|
||||
"siteApproveError": "Error al aprobar el sitio",
|
||||
"provisioningKeys": "Claves de aprovisionamiento",
|
||||
"searchProvisioningKeys": "Buscar claves de suministro...",
|
||||
"provisioningKeysAdd": "Generar clave de aprovisionamiento",
|
||||
"provisioningKeysErrorDelete": "Error al eliminar la clave de aprovisionamiento",
|
||||
"provisioningKeysErrorDeleteMessage": "Error al eliminar la clave de aprovisionamiento",
|
||||
"provisioningKeysQuestionRemove": "¿Está seguro que desea eliminar esta clave de aprovisionamiento de la organización?",
|
||||
"provisioningKeysMessageRemove": "Una vez eliminada, la clave ya no se puede utilizar para la disposición del sitio.",
|
||||
"provisioningKeysDeleteConfirm": "Confirmar Eliminar Clave de Aprovisionamiento",
|
||||
"provisioningKeysDelete": "Eliminar clave de aprovisionamiento",
|
||||
"provisioningKeysCreate": "Generar clave de aprovisionamiento",
|
||||
"provisioningKeysCreateDescription": "Generar una nueva clave de aprovisionamiento para la organización",
|
||||
"provisioningKeysSeeAll": "Ver todas las claves de aprovisionamiento",
|
||||
"provisioningKeysSave": "Guardar la clave de aprovisionamiento",
|
||||
"provisioningKeysSaveDescription": "Sólo podrás verlo una vez. Copítalo a un lugar seguro.",
|
||||
"provisioningKeysErrorCreate": "Error al crear la clave de provisioning",
|
||||
"provisioningKeysList": "Nueva clave de aprovisionamiento",
|
||||
"provisioningKeysMaxBatchSize": "Tamaño máximo de lote",
|
||||
"provisioningKeysUnlimitedBatchSize": "Tamaño ilimitado del lote (sin límite)",
|
||||
"provisioningKeysMaxBatchUnlimited": "Ilimitado",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Introduzca un tamaño máximo de lote válido (1–1,000,000).",
|
||||
"provisioningKeysValidUntil": "Válido hasta",
|
||||
"provisioningKeysValidUntilHint": "Dejar vacío para no expirar.",
|
||||
"provisioningKeysValidUntilInvalid": "Introduzca una fecha y hora válidas.",
|
||||
"provisioningKeysNumUsed": "Tiempos usados",
|
||||
"provisioningKeysLastUsed": "Último uso",
|
||||
"provisioningKeysNoExpiry": "No expiración",
|
||||
"provisioningKeysNeverUsed": "Nunca",
|
||||
"provisioningKeysEdit": "Editar clave de aprovisionamiento",
|
||||
"provisioningKeysEditDescription": "Actualizar el tamaño máximo de lote y el tiempo de caducidad para esta clave.",
|
||||
"provisioningKeysApproveNewSites": "Aprobar nuevos sitios",
|
||||
"provisioningKeysApproveNewSitesDescription": "Aprobar automáticamente los sitios que se registran con esta clave.",
|
||||
"provisioningKeysUpdateError": "Error al actualizar la clave de aprovisionamiento",
|
||||
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
|
||||
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
|
||||
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
|
||||
"provisioningKeysBannerDescription": "Genere una clave de aprovisionamiento y utilícela con el conector Newt para crear automáticamente sitios en el primer inicio: no es necesario configurar credenciales separadas para cada sitio.",
|
||||
"provisioningKeysBannerButtonText": "Saber más",
|
||||
"pendingSitesBannerTitle": "Sitios pendientes",
|
||||
"pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecerán aquí para su revisión.",
|
||||
"pendingSitesBannerButtonText": "Saber más",
|
||||
"apiKeysSettings": "Ajustes {apiKeyName}",
|
||||
"userTitle": "Administrar todos los usuarios",
|
||||
"userDescription": "Ver y administrar todos los usuarios en el sistema",
|
||||
"userAbount": "Acerca de Gestión de Usuarios",
|
||||
"userAbountDescription": "Esta tabla muestra todos los objetos de usuario root en el sistema. Cada usuario puede pertenecer a varias organizaciones. Eliminar un usuario de una organización no elimina su objeto de usuario root - permanecerán en el sistema. Para eliminar completamente un usuario del sistema, debe eliminar su objeto de usuario root usando la acción de borrar en esta tabla.",
|
||||
"userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.",
|
||||
"userServer": "Usuarios del servidor",
|
||||
"userSearch": "Buscar usuarios del servidor...",
|
||||
"userErrorDelete": "Error al eliminar el usuario",
|
||||
@@ -332,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Error al activar la clave de licencia",
|
||||
"licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.",
|
||||
"licenseAbout": "Acerca de la licencia",
|
||||
"licenseBannerTitle": "Habilitar su Licencia Enterprise",
|
||||
"licenseBannerDescription": "Desbloquea funciones empresariales para tu instancia autohospedada de Pangolin. Compra una clave de licencia para activar capacidades premium, luego agréguela a continuación.",
|
||||
"licenseBannerGetLicense": "Obtener una Licencia",
|
||||
"licenseBannerViewDocs": "Ver Documentación",
|
||||
"communityEdition": "Edición comunitaria",
|
||||
"licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.",
|
||||
"licenseKeyActivated": "Clave de licencia activada",
|
||||
@@ -446,10 +523,24 @@
|
||||
"userSettings": "Información del usuario",
|
||||
"userSettingsDescription": "Introduzca los detalles del nuevo usuario",
|
||||
"inviteEmailSent": "Enviar correo de invitación al usuario",
|
||||
"inviteValid": "Válido para",
|
||||
"inviteValid": "Invite Valid For (days)",
|
||||
"selectDuration": "Seleccionar duración",
|
||||
"selectResource": "Seleccionar Recurso",
|
||||
"filterByResource": "Filtrar por Recurso",
|
||||
"selectApprovalState": "Seleccionar Estado de Aprobación",
|
||||
"filterByApprovalState": "Filtrar por estado de aprobación",
|
||||
"approvalListEmpty": "No hay aprobaciones",
|
||||
"approvalState": "Estado de aprobación",
|
||||
"approvalLoadMore": "Cargar más",
|
||||
"loadingApprovals": "Cargando aprobaciones",
|
||||
"approve": "Aprobar",
|
||||
"approved": "Aprobado",
|
||||
"denied": "Denegado",
|
||||
"deniedApproval": "Aprobación denegada",
|
||||
"all": "Todo",
|
||||
"deny": "Denegar",
|
||||
"viewDetails": "Ver detalles",
|
||||
"requestingNewDeviceApproval": "solicitó un nuevo dispositivo",
|
||||
"resetFilters": "Reiniciar filtros",
|
||||
"totalBlocked": "Solicitudes bloqueadas por Pangolin",
|
||||
"totalRequests": "Solicitudes totales",
|
||||
@@ -475,9 +566,12 @@
|
||||
"userSaved": "Usuario guardado",
|
||||
"userSavedDescription": "El usuario ha sido actualizado.",
|
||||
"autoProvisioned": "Auto asegurado",
|
||||
"autoProvisionSettings": "Configuración de Auto Provision",
|
||||
"autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad",
|
||||
"accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización",
|
||||
"accessControlsSubmit": "Guardar controles de acceso",
|
||||
"singleRolePerUserPlanNotice": "Tu plan sólo soporta un rol por usuario.",
|
||||
"singleRolePerUserEditionNotice": "Esta edición sólo soporta un rol por usuario.",
|
||||
"roles": "Roles",
|
||||
"accessUsersRoles": "Administrar usuarios y roles",
|
||||
"accessUsersRolesDescription": "Invitar usuarios y añadirlos a roles para administrar el acceso a la organización",
|
||||
@@ -534,6 +628,8 @@
|
||||
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
|
||||
"targetErrorNoSite": "Ningún sitio seleccionado",
|
||||
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
|
||||
"targetTargetsCleared": "Objetivos eliminados",
|
||||
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso",
|
||||
"targetCreated": "Objetivo creado",
|
||||
"targetCreatedDescription": "El objetivo se ha creado correctamente",
|
||||
"targetErrorCreate": "Error al crear el objetivo",
|
||||
@@ -617,6 +713,7 @@
|
||||
"resourcesErrorUpdate": "Error al cambiar el recurso",
|
||||
"resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso",
|
||||
"access": "Acceder",
|
||||
"accessControl": "Control de acceso",
|
||||
"shareLink": "{resource} Compartir Enlace",
|
||||
"resourceSelect": "Seleccionar recurso",
|
||||
"shareLinks": "Compartir enlaces",
|
||||
@@ -729,22 +826,35 @@
|
||||
"countries": "Países",
|
||||
"accessRoleCreate": "Crear rol",
|
||||
"accessRoleCreateDescription": "Crear un nuevo rol para agrupar usuarios y administrar sus permisos.",
|
||||
"accessRoleEdit": "Editar rol",
|
||||
"accessRoleEditDescription": "Editar información de rol.",
|
||||
"accessRoleCreateSubmit": "Crear rol",
|
||||
"accessRoleCreated": "Rol creado",
|
||||
"accessRoleCreatedDescription": "El rol se ha creado correctamente.",
|
||||
"accessRoleErrorCreate": "Error al crear el rol",
|
||||
"accessRoleErrorCreateDescription": "Se ha producido un error al crear el rol.",
|
||||
"accessRoleUpdateSubmit": "Actualizar rol",
|
||||
"accessRoleUpdated": "Rol actualizado",
|
||||
"accessRoleUpdatedDescription": "El rol se ha actualizado correctamente.",
|
||||
"accessApprovalUpdated": "Aprobación procesada",
|
||||
"accessApprovalApprovedDescription": "Establezca la decisión de Solicitud de Aprobación a aprobar.",
|
||||
"accessApprovalDeniedDescription": "Define la decisión de Solicitud de Aprobación a denegar.",
|
||||
"accessRoleErrorUpdate": "Error al actualizar el rol",
|
||||
"accessRoleErrorUpdateDescription": "Se ha producido un error al actualizar el rol.",
|
||||
"accessApprovalErrorUpdate": "Error al procesar la aprobación",
|
||||
"accessApprovalErrorUpdateDescription": "Se ha producido un error al procesar la aprobación.",
|
||||
"accessRoleErrorNewRequired": "Se requiere un nuevo rol",
|
||||
"accessRoleErrorRemove": "Error al eliminar el rol",
|
||||
"accessRoleErrorRemoveDescription": "Ocurrió un error mientras se eliminaba el rol.",
|
||||
"accessRoleName": "Nombre del Rol",
|
||||
"accessRoleQuestionRemove": "Estás a punto de eliminar el rol {name} . No puedes deshacer esta acción.",
|
||||
"accessRoleQuestionRemove": "Estás a punto de eliminar el rol `{name}`. No puedes deshacer esta acción.",
|
||||
"accessRoleRemove": "Quitar rol",
|
||||
"accessRoleRemoveDescription": "Eliminar un rol de la organización",
|
||||
"accessRoleRemoveSubmit": "Quitar rol",
|
||||
"accessRoleRemoved": "Rol eliminado",
|
||||
"accessRoleRemovedDescription": "El rol se ha eliminado correctamente.",
|
||||
"accessRoleRequiredRemove": "Antes de eliminar este rol, seleccione un nuevo rol al que transferir miembros existentes.",
|
||||
"network": "Red",
|
||||
"manage": "Gestionar",
|
||||
"sitesNotFound": "Sitios no encontrados.",
|
||||
"pangolinServerAdmin": "Admin Servidor - Pangolin",
|
||||
@@ -760,6 +870,9 @@
|
||||
"sitestCountIncrease": "Aumentar el número de sitios",
|
||||
"idpManage": "Administrar proveedores de identidad",
|
||||
"idpManageDescription": "Ver y administrar proveedores de identidad en el sistema",
|
||||
"idpGlobalModeBanner": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para habilitar los IdPs por organización, edita la configuración del servidor y establece el modo de IdP en org. <configDocsLink>Consulta la documentación</configDocsLink>. Si deseas seguir utilizando IdPs globales y hacer que esto desaparezca de las configuraciones de la organización, establece explícitamente el modo en global en la configuración.",
|
||||
"idpGlobalModeBannerUpgradeRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el <adminPanelLink>panel de administración</adminPanelLink>. Para usar proveedores de identidad por organización, debes actualizar a la edición Empresarial.",
|
||||
"idpGlobalModeBannerLicenseRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando identificadores globales (compartidos en todas las organizaciones). Gestionar identificaciones globales en el panel <adminPanelLink>de administración</adminPanelLink>. Para utilizar proveedores de identidad por organización, se requiere una licencia de empresa.",
|
||||
"idpDeletedDescription": "Proveedor de identidad eliminado correctamente",
|
||||
"idpOidc": "OAuth2/OIDC",
|
||||
"idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?",
|
||||
@@ -785,6 +898,7 @@
|
||||
"idpDisplayName": "Un nombre mostrado para este proveedor de identidad",
|
||||
"idpAutoProvisionUsers": "Auto-Provisión de Usuarios",
|
||||
"idpAutoProvisionUsersDescription": "Cuando está habilitado, los usuarios serán creados automáticamente en el sistema al iniciar sesión con la capacidad de asignar a los usuarios a roles y organizaciones.",
|
||||
"idpAutoProvisionConfigureAfterCreate": "Puede configurar las configuraciones de provisión automática una vez que se haya creado el proveedor de identidad.",
|
||||
"licenseBadge": "EE",
|
||||
"idpType": "Tipo de proveedor",
|
||||
"idpTypeDescription": "Seleccione el tipo de proveedor de identidad que desea configurar",
|
||||
@@ -836,7 +950,7 @@
|
||||
"defaultMappingsRole": "Mapeo de Rol por defecto",
|
||||
"defaultMappingsRoleDescription": "El resultado de esta expresión debe devolver el nombre del rol tal y como se define en la organización como una cadena.",
|
||||
"defaultMappingsOrg": "Mapeo de organización por defecto",
|
||||
"defaultMappingsOrgDescription": "Esta expresión debe devolver el ID de org o verdadero para que el usuario pueda acceder a la organización.",
|
||||
"defaultMappingsOrgDescription": "Cuando se establece, esta expresión debe devolver el ID de la organización o verdadero para que el usuario acceda a esa organización. Cuando no se establece, definir un mapeo de roles es suficiente: se permite la entrada del usuario siempre que se pueda resolver un mapeo de roles válido para él dentro de la organización.",
|
||||
"defaultMappingsSubmit": "Guardar asignaciones por defecto",
|
||||
"orgPoliciesEdit": "Editar Política de Organización",
|
||||
"org": "Organización",
|
||||
@@ -850,6 +964,7 @@
|
||||
"orgPolicyConfig": "Configurar acceso para una organización",
|
||||
"idpUpdatedDescription": "Proveedor de identidad actualizado correctamente",
|
||||
"redirectUrl": "URL de redirección",
|
||||
"orgIdpRedirectUrls": "Redirigir URL",
|
||||
"redirectUrlAbout": "Acerca de la URL de redirección",
|
||||
"redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.",
|
||||
"pangolinAuth": "Autenticación - Pangolin",
|
||||
@@ -959,7 +1074,7 @@
|
||||
"passwordResetSmtpRequired": "Póngase en contacto con su administrador",
|
||||
"passwordResetSmtpRequiredDescription": "Se requiere un código de restablecimiento de contraseña para restablecer su contraseña. Póngase en contacto con su administrador para obtener asistencia.",
|
||||
"passwordBack": "Volver a la contraseña",
|
||||
"loginBack": "Volver a iniciar sesión",
|
||||
"loginBack": "Volver a la página principal de acceso",
|
||||
"signup": "Regístrate",
|
||||
"loginStart": "Inicia sesión para empezar",
|
||||
"idpOidcTokenValidating": "Validando token OIDC",
|
||||
@@ -982,12 +1097,12 @@
|
||||
"pangolinSetup": "Configuración - Pangolin",
|
||||
"orgNameRequired": "El nombre de la organización es obligatorio",
|
||||
"orgIdRequired": "El ID de la organización es obligatorio",
|
||||
"orgIdMaxLength": "El ID de la organización debe tener como máximo 32 caracteres",
|
||||
"orgErrorCreate": "Se ha producido un error al crear el org",
|
||||
"pageNotFound": "Página no encontrada",
|
||||
"pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.",
|
||||
"overview": "Resumen",
|
||||
"home": "Inicio",
|
||||
"accessControl": "Control de acceso",
|
||||
"settings": "Ajustes",
|
||||
"usersAll": "Todos los usuarios",
|
||||
"license": "Licencia",
|
||||
@@ -1050,6 +1165,12 @@
|
||||
"actionGetUser": "Obtener usuario",
|
||||
"actionGetOrgUser": "Obtener usuario de la organización",
|
||||
"actionListOrgDomains": "Listar dominios de la organización",
|
||||
"actionGetDomain": "Obtener dominio",
|
||||
"actionCreateOrgDomain": "Crear dominio",
|
||||
"actionUpdateOrgDomain": "Actualizar dominio",
|
||||
"actionDeleteOrgDomain": "Eliminar dominio",
|
||||
"actionGetDNSRecords": "Obtener registros DNS",
|
||||
"actionRestartOrgDomain": "Reiniciar dominio",
|
||||
"actionCreateSite": "Crear sitio",
|
||||
"actionDeleteSite": "Eliminar sitio",
|
||||
"actionGetSite": "Obtener sitio",
|
||||
@@ -1061,6 +1182,7 @@
|
||||
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
|
||||
"setupTokenRequired": "Se requiere el token de configuración",
|
||||
"actionUpdateSite": "Actualizar sitio",
|
||||
"actionResetSiteBandwidth": "Restablecer ancho de banda de la organización",
|
||||
"actionListSiteRoles": "Lista de roles permitidos del sitio",
|
||||
"actionCreateResource": "Crear Recurso",
|
||||
"actionDeleteResource": "Eliminar Recurso",
|
||||
@@ -1090,6 +1212,7 @@
|
||||
"actionRemoveUser": "Eliminar usuario",
|
||||
"actionListUsers": "Listar usuarios",
|
||||
"actionAddUserRole": "Añadir rol de usuario",
|
||||
"actionSetUserOrgRoles": "Establecer roles de usuario",
|
||||
"actionGenerateAccessToken": "Generar token de acceso",
|
||||
"actionDeleteAccessToken": "Eliminar token de acceso",
|
||||
"actionListAccessTokens": "Lista de Tokens de Acceso",
|
||||
@@ -1117,6 +1240,10 @@
|
||||
"actionUpdateIdpOrg": "Actualizar IDP Org",
|
||||
"actionCreateClient": "Crear cliente",
|
||||
"actionDeleteClient": "Eliminar cliente",
|
||||
"actionArchiveClient": "Archivar cliente",
|
||||
"actionUnarchiveClient": "Desarchivar cliente",
|
||||
"actionBlockClient": "Bloquear cliente",
|
||||
"actionUnblockClient": "Desbloquear cliente",
|
||||
"actionUpdateClient": "Actualizar cliente",
|
||||
"actionListClients": "Listar clientes",
|
||||
"actionGetClient": "Obtener cliente",
|
||||
@@ -1130,17 +1257,18 @@
|
||||
"actionViewLogs": "Ver registros",
|
||||
"noneSelected": "Ninguno seleccionado",
|
||||
"orgNotFound2": "No se encontraron organizaciones.",
|
||||
"searchProgress": "Buscar...",
|
||||
"searchPlaceholder": "Buscar...",
|
||||
"emptySearchOptions": "No se encontraron opciones",
|
||||
"create": "Crear",
|
||||
"orgs": "Organizaciones",
|
||||
"loginError": "Se ha producido un error al iniciar sesión",
|
||||
"loginRequiredForDevice": "Es necesario iniciar sesión para autenticar tu dispositivo.",
|
||||
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
|
||||
"loginRequiredForDevice": "Es necesario iniciar sesión para tu dispositivo.",
|
||||
"passwordForgot": "¿Olvidaste tu contraseña?",
|
||||
"otpAuth": "Autenticación de dos factores",
|
||||
"otpAuthDescription": "Introduzca el código de su aplicación de autenticación o uno de sus códigos de copia de seguridad de un solo uso.",
|
||||
"otpAuthSubmit": "Enviar código",
|
||||
"idpContinue": "O continuar con",
|
||||
"otpAuthBack": "Volver a iniciar sesión",
|
||||
"otpAuthBack": "Volver a la contraseña",
|
||||
"navbar": "Menú de navegación",
|
||||
"navbarDescription": "Menú de navegación principal para la aplicación",
|
||||
"navbarDocsLink": "Documentación",
|
||||
@@ -1188,29 +1316,34 @@
|
||||
"sidebarOverview": "Resumen",
|
||||
"sidebarHome": "Inicio",
|
||||
"sidebarSites": "Sitios",
|
||||
"sidebarApprovals": "Solicitudes de aprobación",
|
||||
"sidebarResources": "Recursos",
|
||||
"sidebarProxyResources": "Público",
|
||||
"sidebarClientResources": "Privado",
|
||||
"sidebarAccessControl": "Control de acceso",
|
||||
"sidebarLogsAndAnalytics": "Registros y análisis",
|
||||
"sidebarTeam": "Equipo",
|
||||
"sidebarUsers": "Usuarios",
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Invitaciones",
|
||||
"sidebarRoles": "Roles",
|
||||
"sidebarShareableLinks": "Enlaces",
|
||||
"sidebarApiKeys": "Claves API",
|
||||
"sidebarProvisioning": "Aprovisionamiento",
|
||||
"sidebarSettings": "Ajustes",
|
||||
"sidebarAllUsers": "Todos los usuarios",
|
||||
"sidebarIdentityProviders": "Proveedores de identidad",
|
||||
"sidebarLicense": "Licencia",
|
||||
"sidebarClients": "Clientes",
|
||||
"sidebarUserDevices": "Usuarios",
|
||||
"sidebarUserDevices": "Dispositivos de usuario",
|
||||
"sidebarMachineClients": "Máquinas",
|
||||
"sidebarDomains": "Dominios",
|
||||
"sidebarGeneral": "Gestionar",
|
||||
"sidebarLogAndAnalytics": "Registro y análisis",
|
||||
"sidebarBluePrints": "Planos",
|
||||
"sidebarOrganization": "Organización",
|
||||
"sidebarManagement": "Gestión",
|
||||
"sidebarBillingAndLicenses": "Facturación y licencias",
|
||||
"sidebarLogsAnalytics": "Analíticas",
|
||||
"blueprints": "Planos",
|
||||
"blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores",
|
||||
@@ -1232,7 +1365,6 @@
|
||||
"parsedContents": "Contenido analizado (Sólo lectura)",
|
||||
"enableDockerSocket": "Habilitar Plano Docker",
|
||||
"enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
|
||||
"enableDockerSocketLink": "Saber más",
|
||||
"viewDockerContainers": "Ver contenedores Docker",
|
||||
"containersIn": "Contenedores en {siteName}",
|
||||
"selectContainerDescription": "Seleccione cualquier contenedor para usar como nombre de host para este objetivo. Haga clic en un puerto para usar un puerto.",
|
||||
@@ -1276,6 +1408,7 @@
|
||||
"setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.",
|
||||
"certificateStatus": "Estado del certificado",
|
||||
"loading": "Cargando",
|
||||
"loadingAnalytics": "Cargando analíticas",
|
||||
"restart": "Reiniciar",
|
||||
"domains": "Dominios",
|
||||
"domainsDescription": "Crear y administrar dominios disponibles en la organización",
|
||||
@@ -1303,6 +1436,7 @@
|
||||
"refreshError": "Error al actualizar datos",
|
||||
"verified": "Verificado",
|
||||
"pending": "Pendiente",
|
||||
"pendingApproval": "Pendientes de aprobación",
|
||||
"sidebarBilling": "Facturación",
|
||||
"billing": "Facturación",
|
||||
"orgBillingDescription": "Administrar información de facturación y suscripciones",
|
||||
@@ -1358,6 +1492,7 @@
|
||||
"domainPickerNamespace": "Espacio de nombres: {namespace}",
|
||||
"domainPickerShowMore": "Mostrar más",
|
||||
"regionSelectorTitle": "Seleccionar Región",
|
||||
"domainPickerRemoteExitNodeWarning": "Los dominios suministrados no son compatibles cuando los sitios se conectan a nodos de salida remotos. Para que los recursos estén disponibles en nodos remotos, utilice un dominio personalizado en su lugar.",
|
||||
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
|
||||
"regionSelectorPlaceholder": "Elige una región",
|
||||
"regionSelectorComingSoon": "Próximamente",
|
||||
@@ -1367,10 +1502,11 @@
|
||||
"billingUsageLimitsOverview": "Descripción general de los límites de uso",
|
||||
"billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@pangolin.net.",
|
||||
"billingDataUsage": "Uso de datos",
|
||||
"billingOnlineTime": "Tiempo en línea del sitio",
|
||||
"billingUsers": "Usuarios activos",
|
||||
"billingDomains": "Dominios activos",
|
||||
"billingRemoteExitNodes": "Nodos autogestionados activos",
|
||||
"billingSites": "Sitios",
|
||||
"billingUsers": "Usuarios",
|
||||
"billingDomains": "Dominios",
|
||||
"billingOrganizations": "Orgánico",
|
||||
"billingRemoteExitNodes": "Nodos remotos",
|
||||
"billingNoLimitConfigured": "No se ha configurado ningún límite",
|
||||
"billingEstimatedPeriod": "Período de facturación estimado",
|
||||
"billingIncludedUsage": "Uso incluido",
|
||||
@@ -1395,15 +1531,24 @@
|
||||
"billingFailedToGetPortalUrl": "Error al obtener la URL del portal",
|
||||
"billingPortalError": "Error del portal",
|
||||
"billingDataUsageInfo": "Se le cobran todos los datos transferidos a través de sus túneles seguros cuando se conectan a la nube. Esto incluye tanto tráfico entrante como saliente a través de todos sus sitios. Cuando alcance su límite, sus sitios se desconectarán hasta que actualice su plan o reduzca el uso. Los datos no se cargan cuando se usan nodos.",
|
||||
"billingOnlineTimeInfo": "Se te cobrará en función del tiempo que tus sitios permanezcan conectados a la nube. Por ejemplo, 44.640 minutos equivale a un sitio que funciona 24/7 durante un mes completo. Cuando alcance su límite, sus sitios se desconectarán hasta que mejore su plan o reduzca el uso. No se cargará el tiempo al usar nodos.",
|
||||
"billingUsersInfo": "Se le cobra por cada usuario en la organización. La facturación se calcula diariamente según el número de cuentas de usuario activas en su órgano.",
|
||||
"billingDomainInfo": "Se le cobra por cada dominio en la organización. La facturación se calcula diariamente en función del número de cuentas de dominio activas en su órgano.",
|
||||
"billingRemoteExitNodesInfo": "Se le cobra por cada nodo administrado en la organización. La facturación se calcula diariamente en función del número de nodos activos gestionados en su órgano.",
|
||||
"billingSInfo": "Cuántos sitios puedes usar",
|
||||
"billingUsersInfo": "Cuántos usuarios puedes usar",
|
||||
"billingDomainInfo": "Cuántos dominios puedes usar",
|
||||
"billingRemoteExitNodesInfo": "Cuántos nodos remotos puedes usar",
|
||||
"billingLicenseKeys": "Claves de licencia",
|
||||
"billingLicenseKeysDescription": "Administrar las suscripciones de su clave de licencia",
|
||||
"billingLicenseSubscription": "Suscripción de licencia",
|
||||
"billingInactive": "Inactivo",
|
||||
"billingLicenseItem": "Licencia",
|
||||
"billingQuantity": "Cantidad",
|
||||
"billingTotal": "total",
|
||||
"billingModifyLicenses": "Modificar suscripción de licencia",
|
||||
"domainNotFound": "Dominio no encontrado",
|
||||
"domainNotFoundDescription": "Este recurso está deshabilitado porque el dominio ya no existe en nuestro sistema. Por favor, establece un nuevo dominio para este recurso.",
|
||||
"failed": "Fallido",
|
||||
"createNewOrgDescription": "Crear una nueva organización",
|
||||
"organization": "Organización",
|
||||
"primary": "Principal",
|
||||
"port": "Puerto",
|
||||
"securityKeyManage": "Gestionar llaves de seguridad",
|
||||
"securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña",
|
||||
@@ -1419,7 +1564,7 @@
|
||||
"securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente",
|
||||
"securityKeyRemoveError": "Error al eliminar la llave de seguridad",
|
||||
"securityKeyLoadError": "Error al cargar las llaves de seguridad",
|
||||
"securityKeyLogin": "Continuar con clave de seguridad",
|
||||
"securityKeyLogin": "Usar clave de seguridad",
|
||||
"securityKeyAuthError": "Error al autenticar con llave de seguridad",
|
||||
"securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta.",
|
||||
"registering": "Registrando...",
|
||||
@@ -1475,11 +1620,47 @@
|
||||
"resourcePortRequired": "Se requiere número de puerto para recursos no HTTP",
|
||||
"resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP",
|
||||
"billingPricingCalculatorLink": "Calculadora de Precios",
|
||||
"billingYourPlan": "Su plan",
|
||||
"billingViewOrModifyPlan": "Ver o modificar su plan actual",
|
||||
"billingViewPlanDetails": "Ver detalles del plan",
|
||||
"billingUsageAndLimits": "Uso y límites",
|
||||
"billingViewUsageAndLimits": "Ver los límites de tu plan y el uso actual",
|
||||
"billingCurrentUsage": "Uso actual",
|
||||
"billingMaximumLimits": "Límites máximos",
|
||||
"billingRemoteNodes": "Nodos remotos",
|
||||
"billingUnlimited": "Ilimitado",
|
||||
"billingPaidLicenseKeys": "Claves de licencia pagadas",
|
||||
"billingManageLicenseSubscription": "Administra tu suscripción para las claves de licencia autoalojadas pagadas",
|
||||
"billingCurrentKeys": "Claves actuales",
|
||||
"billingModifyCurrentPlan": "Modificar plan actual",
|
||||
"billingConfirmUpgrade": "Confirmar actualización",
|
||||
"billingConfirmDowngrade": "Confirmar descenso",
|
||||
"billingConfirmUpgradeDescription": "Estás a punto de actualizar tu plan. Revisa los nuevos límites y precios a continuación.",
|
||||
"billingConfirmDowngradeDescription": "Está a punto de rebajar su plan. Revise los nuevos límites y los precios a continuación.",
|
||||
"billingPlanIncludes": "Plan Incluye",
|
||||
"billingProcessing": "Procesando...",
|
||||
"billingConfirmUpgradeButton": "Confirmar actualización",
|
||||
"billingConfirmDowngradeButton": "Confirmar descenso",
|
||||
"billingLimitViolationWarning": "El uso excede los nuevos límites del plan",
|
||||
"billingLimitViolationDescription": "Su uso actual excede los límites de este plan. Después de degradar, todas las acciones se desactivarán hasta que reduzca el uso dentro de los nuevos límites. Por favor, revisa las siguientes características que están actualmente por encima de los límites. Límites en violación:",
|
||||
"billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad",
|
||||
"billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.",
|
||||
"billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})",
|
||||
"billingPastDueTitle": "Pago vencido",
|
||||
"billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.",
|
||||
"billingUnpaidTitle": "Suscripción no pagada",
|
||||
"billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.",
|
||||
"billingIncompleteTitle": "Pago incompleto",
|
||||
"billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.",
|
||||
"billingIncompleteExpiredTitle": "Pago expirado",
|
||||
"billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.",
|
||||
"billingManageSubscription": "Administra tu suscripción",
|
||||
"billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "Estoy de acuerdo con los",
|
||||
"termsOfService": "términos del servicio",
|
||||
"and": "y",
|
||||
"privacyPolicy": "política de privacidad"
|
||||
"privacyPolicy": "política de privacidad."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico."
|
||||
@@ -1546,6 +1727,26 @@
|
||||
"IntervalSeconds": "Intervalo Saludable",
|
||||
"timeoutSeconds": "Tiempo agotado (seg)",
|
||||
"timeIsInSeconds": "El tiempo está en segundos",
|
||||
"requireDeviceApproval": "Requiere aprobaciones del dispositivo",
|
||||
"requireDeviceApprovalDescription": "Los usuarios con este rol necesitan nuevos dispositivos aprobados por un administrador antes de poder conectarse y acceder a los recursos.",
|
||||
"sshAccess": "Acceso a SSH",
|
||||
"roleAllowSsh": "Permitir SSH",
|
||||
"roleAllowSshAllow": "Permitir",
|
||||
"roleAllowSshDisallow": "Rechazar",
|
||||
"roleAllowSshDescription": "Permitir a los usuarios con este rol conectarse a recursos a través de SSH. Cuando está desactivado, el rol no puede usar acceso SSH.",
|
||||
"sshSudoMode": "Acceso Sudo",
|
||||
"sshSudoModeNone": "Ninguna",
|
||||
"sshSudoModeNoneDescription": "El usuario no puede ejecutar comandos con sudo.",
|
||||
"sshSudoModeFull": "Sudo completo",
|
||||
"sshSudoModeFullDescription": "El usuario puede ejecutar cualquier comando con sudo.",
|
||||
"sshSudoModeCommands": "Comandos",
|
||||
"sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
|
||||
"sshSudo": "Permitir sudo",
|
||||
"sshSudoCommands": "Comandos Sudo",
|
||||
"sshSudoCommandsDescription": "Lista separada por comas de comandos que el usuario puede ejecutar con sudo.",
|
||||
"sshCreateHomeDir": "Crear directorio principal",
|
||||
"sshUnixGroups": "Grupos Unix",
|
||||
"sshUnixGroupsDescription": "Grupos Unix separados por comas para agregar el usuario en el host de destino.",
|
||||
"retryAttempts": "Intentos de Reintento",
|
||||
"expectedResponseCodes": "Códigos de respuesta esperados",
|
||||
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
|
||||
@@ -1586,6 +1787,8 @@
|
||||
"resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.",
|
||||
"resourcesTableDestination": "Destino",
|
||||
"resourcesTableAlias": "Alias",
|
||||
"resourcesTableAliasAddress": "Dirección del alias",
|
||||
"resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.",
|
||||
"resourcesTableClients": "Clientes",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.",
|
||||
"resourcesTableNoTargets": "Sin objetivos",
|
||||
@@ -1752,6 +1955,40 @@
|
||||
"exitNode": "Nodo de Salida",
|
||||
"country": "País",
|
||||
"rulesMatchCountry": "Actualmente basado en IP de origen",
|
||||
"region": "Región",
|
||||
"selectRegion": "Seleccionar región",
|
||||
"searchRegions": "Buscar regiones...",
|
||||
"noRegionFound": "Región no encontrada.",
|
||||
"rulesMatchRegion": "Seleccione una agrupación regional de países",
|
||||
"rulesErrorInvalidRegion": "Región no válida",
|
||||
"rulesErrorInvalidRegionDescription": "Por favor, seleccione una región válida.",
|
||||
"regionAfrica": "Africa",
|
||||
"regionNorthernAfrica": "África septentrional",
|
||||
"regionEasternAfrica": "África oriental",
|
||||
"regionMiddleAfrica": "África central",
|
||||
"regionSouthernAfrica": "África del Sur",
|
||||
"regionWesternAfrica": "África Occidental",
|
||||
"regionAmericas": "Américas",
|
||||
"regionCaribbean": "Caribe",
|
||||
"regionCentralAmerica": "América Central",
|
||||
"regionSouthAmerica": "América del Sur",
|
||||
"regionNorthernAmerica": "América del Norte",
|
||||
"regionAsia": "Asia",
|
||||
"regionCentralAsia": "Asia Central",
|
||||
"regionEasternAsia": "Asia oriental",
|
||||
"regionSouthEasternAsia": "Asia sudoriental",
|
||||
"regionSouthernAsia": "Asia meridional",
|
||||
"regionWesternAsia": "Asia Occidental",
|
||||
"regionEurope": "Europa",
|
||||
"regionEasternEurope": "Europa del Este",
|
||||
"regionNorthernEurope": "Europa septentrional",
|
||||
"regionSouthernEurope": "Europa meridional",
|
||||
"regionWesternEurope": "Europa Occidental",
|
||||
"regionOceania": "Oceania",
|
||||
"regionAustraliaAndNewZealand": "Australia y Nueva Zelanda",
|
||||
"regionMelanesia": "Melanesia",
|
||||
"regionMicronesia": "Micronesia",
|
||||
"regionPolynesia": "Polynesia",
|
||||
"managedSelfHosted": {
|
||||
"title": "Autogestionado",
|
||||
"description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra",
|
||||
@@ -1790,7 +2027,7 @@
|
||||
},
|
||||
"internationaldomaindetected": "Dominio Internacional detectado",
|
||||
"willbestoredas": "Se almacenará como:",
|
||||
"roleMappingDescription": "Determinar cómo se asignan los roles a los usuarios cuando se registran cuando está habilitada la provisión automática.",
|
||||
"roleMappingDescription": "Determine cómo se asignan los roles a los usuarios cuando inician sesión con este proveedor de identidad.",
|
||||
"selectRole": "Seleccione un rol",
|
||||
"roleMappingExpression": "Expresión",
|
||||
"selectRolePlaceholder": "Elija un rol",
|
||||
@@ -1800,6 +2037,25 @@
|
||||
"invalidValue": "Valor inválido",
|
||||
"idpTypeLabel": "Tipo de proveedor de identidad",
|
||||
"roleMappingExpressionPlaceholder": "e.g., contiene(grupos, 'administrador') && 'administrador' || 'miembro'",
|
||||
"roleMappingModeFixedRoles": "Roles fijos",
|
||||
"roleMappingModeMappingBuilder": "Constructor de mapeo",
|
||||
"roleMappingModeRawExpression": "Expresión sin procesar",
|
||||
"roleMappingFixedRolesPlaceholderSelect": "Seleccione uno o más roles",
|
||||
"roleMappingFixedRolesPlaceholderFreeform": "Nombre de rol de tipo (coincidencia exacta por organización)",
|
||||
"roleMappingFixedRolesDescriptionSameForAll": "Asignar el mismo rol establecido a cada usuario auto-provisionado.",
|
||||
"roleMappingFixedRolesDescriptionDefaultPolicy": "Para las políticas predeterminadas, escriba nombres de roles que existen en cada organización donde los usuarios son proporcionados. Los nombres deben coincidir exactamente.",
|
||||
"roleMappingClaimPath": "Reclamar ruta",
|
||||
"roleMappingClaimPathPlaceholder": "grupos",
|
||||
"roleMappingClaimPathDescription": "Ruta en el payload del token que contiene valores de origen (por ejemplo, grupos).",
|
||||
"roleMappingMatchValue": "Valor de partida",
|
||||
"roleMappingAssignRoles": "Asignar roles",
|
||||
"roleMappingAddMappingRule": "Añadir regla de mapeo",
|
||||
"roleMappingRawExpressionResultDescription": "La expresión debe evaluar a un array de cadenas o cadenas.",
|
||||
"roleMappingRawExpressionResultDescriptionSingleRole": "La expresión debe evaluar una cadena (un solo nombre de rol).",
|
||||
"roleMappingMatchValuePlaceholder": "Valor coincidente (por ejemplo: admin)",
|
||||
"roleMappingAssignRolesPlaceholderFreeform": "Escriba nombres de rol (exacto por org)",
|
||||
"roleMappingBuilderFreeformRowHint": "Los nombres de rol deben coincidir con un rol en cada organización objetivo.",
|
||||
"roleMappingRemoveRule": "Eliminar",
|
||||
"idpGoogleConfiguration": "Configuración de Google",
|
||||
"idpGoogleConfigurationDescription": "Configurar las credenciales de Google OAuth2",
|
||||
"idpGoogleClientIdDescription": "Google OAuth2 Client ID",
|
||||
@@ -1836,6 +2092,9 @@
|
||||
"authPageBrandingQuestionRemove": "¿Está seguro de que desea eliminar la marca de las páginas de autenticación?",
|
||||
"authPageBrandingDeleteConfirm": "Confirmar eliminación de la marca",
|
||||
"brandingLogoURL": "URL del logotipo",
|
||||
"brandingLogoURLOrPath": "URL o ruta de Logo",
|
||||
"brandingLogoPathDescription": "Introduzca una URL o una ruta local.",
|
||||
"brandingLogoURLDescription": "Introduzca una URL de acceso público a su imagen de logotipo.",
|
||||
"brandingPrimaryColor": "Color primario",
|
||||
"brandingLogoWidth": "Ancho (px)",
|
||||
"brandingLogoHeight": "Altura (px)",
|
||||
@@ -1860,8 +2119,10 @@
|
||||
"selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización",
|
||||
"domainPickerProvidedDomain": "Dominio proporcionado",
|
||||
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
|
||||
"domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan — no necesitas traer el tuyo propio.",
|
||||
"domainPickerVerified": "Verificado",
|
||||
"domainPickerUnverified": "Sin verificar",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
|
||||
@@ -1885,6 +2146,13 @@
|
||||
"orgAuthBackToSignIn": "Volver a iniciar sesión estándar",
|
||||
"orgAuthNoAccount": "¿No tienes una cuenta?",
|
||||
"subscriptionRequiredToUse": "Se requiere una suscripción para utilizar esta función.",
|
||||
"mustUpgradeToUse": "Debes actualizar tu suscripción para usar esta función.",
|
||||
"subscriptionRequiredTierToUse": "Esta función requiere <tierLink>{tier}</tierLink> o superior.",
|
||||
"upgradeToTierToUse": "Actualiza a <tierLink>{tier}</tierLink> o superior para usar esta función.",
|
||||
"subscriptionTierTier1": "Inicio",
|
||||
"subscriptionTierTier2": "Equipo",
|
||||
"subscriptionTierTier3": "Negocio",
|
||||
"subscriptionTierEnterprise": "Empresa",
|
||||
"idpDisabled": "Los proveedores de identidad están deshabilitados.",
|
||||
"orgAuthPageDisabled": "La página de autenticación de la organización está deshabilitada.",
|
||||
"domainRestartedDescription": "Verificación de dominio reiniciada con éxito",
|
||||
@@ -2072,6 +2340,32 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"newPricingLicenseForm": {
|
||||
"title": "Obtener una licencia",
|
||||
"description": "Elige un plan y dinos cómo planeas usar Pangolin.",
|
||||
"chooseTier": "Elige tu plan",
|
||||
"viewPricingLink": "Ver precios, características y límites",
|
||||
"tiers": {
|
||||
"starter": {
|
||||
"title": "Interruptor",
|
||||
"description": "Características de la empresa, 25 usuarios, 25 sitios y soporte comunitario."
|
||||
},
|
||||
"scale": {
|
||||
"title": "Escala",
|
||||
"description": "Funcionalidades empresariales, 50 usuarios, 100 sitios y soporte prioritario."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuar con el pago"
|
||||
},
|
||||
"toasts": {
|
||||
"checkoutError": {
|
||||
"title": "Error de pago",
|
||||
"description": "No se pudo iniciar el pago. Por favor, inténtelo de nuevo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"priority": "Prioridad",
|
||||
"priorityDescription": "Las rutas de prioridad más alta son evaluadas primero. Prioridad = 100 significa orden automático (decisiones del sistema). Utilice otro número para hacer cumplir la prioridad manual.",
|
||||
"instanceName": "Nombre de instancia",
|
||||
@@ -2122,7 +2416,7 @@
|
||||
"action": "Accin",
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"accessLogs": "Registros de acceso",
|
||||
"accessLogs": "Authentication Logs",
|
||||
"exportCsv": "Exportar CSV",
|
||||
"exportError": "Error desconocido al exportar CSV",
|
||||
"exportCsvTooltip": "Dentro del rango de tiempo",
|
||||
@@ -2142,24 +2436,26 @@
|
||||
"noMoreAuthMethods": "No Valid Auth",
|
||||
"ip": "IP",
|
||||
"reason": "Razón",
|
||||
"requestLogs": "Registros de Solicitud",
|
||||
"requestLogs": "HTTPS Request Logs",
|
||||
"requestAnalytics": "Analítica de Solicitud",
|
||||
"host": "Anfitrión",
|
||||
"location": "Ubicación",
|
||||
"actionLogs": "Registros de acción",
|
||||
"sidebarLogsRequest": "Registros de Solicitud",
|
||||
"sidebarLogsAccess": "Registros de acceso",
|
||||
"sidebarLogsAction": "Registros de acción",
|
||||
"actionLogs": "Admin Action Logs",
|
||||
"sidebarLogsRequest": "HTTPS Request Logs",
|
||||
"sidebarLogsAccess": "Authentication Logs",
|
||||
"sidebarLogsAction": "Admin Action Logs",
|
||||
"logRetention": "Retención de Log",
|
||||
"logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos",
|
||||
"requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización",
|
||||
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
|
||||
"requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización",
|
||||
"logRetentionRequestLabel": "Retención de Registro de Solicitud",
|
||||
"logRetentionRequestLabel": "HTTPS Request Log Retention",
|
||||
"logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes",
|
||||
"logRetentionAccessLabel": "Retención de Log de Acceso",
|
||||
"logRetentionAccessLabel": "Authentication Log Retention",
|
||||
"logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso",
|
||||
"logRetentionActionLabel": "Retención de registro de acción",
|
||||
"logRetentionActionLabel": "Admin Action Log Retention",
|
||||
"logRetentionActionDescription": "Cuánto tiempo retener los registros de acción",
|
||||
"logRetentionConnectionLabel": "Network Log Retention",
|
||||
"logRetentionConnectionDescription": "Cuánto tiempo conservar los registros de conexión",
|
||||
"logRetentionDisabled": "Deshabilitado",
|
||||
"logRetention3Days": "3 días",
|
||||
"logRetention7Days": "7 días",
|
||||
@@ -2170,7 +2466,15 @@
|
||||
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
||||
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
||||
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
||||
"licenseRequiredToUse": "Se requiere una licencia Enterprise para utilizar esta función.",
|
||||
"connectionLogs": "Network Logs",
|
||||
"connectionLogsDescription": "View network session logs handled by sites in this organization",
|
||||
"sidebarLogsConnection": "Network Logs",
|
||||
"sidebarLogsStreaming": "Event Streaming",
|
||||
"sourceAddress": "Dirección de origen",
|
||||
"destinationAddress": "Dirección de destino",
|
||||
"duration": "Duración",
|
||||
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> para usar esta función. <bookADemoLink>Reserve una demostración o prueba POC</bookADemoLink>.",
|
||||
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserva una demostración o prueba POC</bookADemoLink>.",
|
||||
"certResolver": "Resolver certificado",
|
||||
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
||||
"selectCertResolver": "Seleccionar Resolver Certificado",
|
||||
@@ -2231,6 +2535,8 @@
|
||||
"deviceCodeInvalidFormat": "El código debe tener 9 caracteres (por ejemplo, A1AJ-N5JD)",
|
||||
"deviceCodeInvalidOrExpired": "Código no válido o caducado",
|
||||
"deviceCodeVerifyFailed": "Error al verificar el código del dispositivo",
|
||||
"deviceCodeValidating": "Validando código de dispositivo...",
|
||||
"deviceCodeVerifying": "Verificando autorización del dispositivo...",
|
||||
"signedInAs": "Conectado como",
|
||||
"deviceCodeEnterPrompt": "Introduzca el código mostrado en el dispositivo",
|
||||
"continue": "Continuar",
|
||||
@@ -2243,7 +2549,7 @@
|
||||
"deviceOrganizationsAccess": "Acceso a todas las organizaciones a las que su cuenta tiene acceso",
|
||||
"deviceAuthorize": "Autorizar a {applicationName}",
|
||||
"deviceConnected": "¡Dispositivo conectado!",
|
||||
"deviceAuthorizedMessage": "El dispositivo está autorizado para acceder a su cuenta.",
|
||||
"deviceAuthorizedMessage": "El dispositivo está autorizado para acceder a su cuenta. Por favor, vuelva a la aplicación cliente.",
|
||||
"pangolinCloud": "Nube de Pangolin",
|
||||
"viewDevices": "Ver dispositivos",
|
||||
"viewDevicesDescription": "Administra tus dispositivos conectados",
|
||||
@@ -2305,10 +2611,14 @@
|
||||
"identifier": "Identifier",
|
||||
"deviceLoginUseDifferentAccount": "¿No tú? Utilice una cuenta diferente.",
|
||||
"deviceLoginDeviceRequestingAccessToAccount": "Un dispositivo está solicitando acceso a esta cuenta.",
|
||||
"loginSelectAuthenticationMethod": "Seleccione un método de autenticación para continuar.",
|
||||
"noData": "Sin datos",
|
||||
"machineClients": "Clientes de la máquina",
|
||||
"install": "Instalar",
|
||||
"run": "Ejecutar",
|
||||
"envFile": "Archivo de Entorno",
|
||||
"serviceFile": "Archivo de Servicio",
|
||||
"enableAndStart": "Habilitar y empezar",
|
||||
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
|
||||
"clientAddress": "Dirección del cliente (Avanzado)",
|
||||
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
|
||||
@@ -2349,6 +2659,7 @@
|
||||
"enterConfirmation": "Ingresar confirmación",
|
||||
"blueprintViewDetails": "Detalles",
|
||||
"defaultIdentityProvider": "Proveedor de identidad predeterminado",
|
||||
"defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.",
|
||||
"editInternalResourceDialogNetworkSettings": "Configuración de red",
|
||||
"editInternalResourceDialogAccessPolicy": "Política de acceso",
|
||||
"editInternalResourceDialogAddRoles": "Agregar roles",
|
||||
@@ -2363,6 +2674,17 @@
|
||||
"editInternalResourceDialogAccessControl": "Control de acceso",
|
||||
"editInternalResourceDialogAccessControlDescription": "Controla qué roles, usuarios y clientes de máquinas tienen acceso a este recurso cuando están conectados. Los administradores siempre tienen acceso.",
|
||||
"editInternalResourceDialogPortRangeValidationError": "El rango de puertos debe ser \"*\" para todos los puertos, o una lista separada por comas de puertos y rangos (por ejemplo, \"80,443,8000-9000\"). Los puertos deben estar entre 1 y 65535.",
|
||||
"internalResourceAuthDaemonStrategy": "Ubicación del demonio de autenticación SSSH",
|
||||
"internalResourceAuthDaemonStrategyDescription": "Elija dónde se ejecuta el daemon de autenticación SSH: en el sitio (Newt) o en un host remoto.",
|
||||
"internalResourceAuthDaemonDescription": "El daemon de autenticación SSSH maneja la firma de claves SSH y autenticación PAM para este recurso. Elija si se ejecuta en el sitio (Newt) o en un host remoto separado. Vea <docsLink>la documentación</docsLink> para más.",
|
||||
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
|
||||
"internalResourceAuthDaemonStrategyPlaceholder": "Seleccionar estrategia",
|
||||
"internalResourceAuthDaemonStrategyLabel": "Ubicación",
|
||||
"internalResourceAuthDaemonSite": "En el sitio",
|
||||
"internalResourceAuthDaemonSiteDescription": "Auth daemon corre en el sitio (Newt).",
|
||||
"internalResourceAuthDaemonRemote": "Host remoto",
|
||||
"internalResourceAuthDaemonRemoteDescription": "El daemon Auth corre en un host que no es el sitio.",
|
||||
"internalResourceAuthDaemonPort": "Puerto de demonio (opcional)",
|
||||
"orgAuthWhatsThis": "¿Dónde puedo encontrar el ID de mi organización?",
|
||||
"learnMore": "Más información",
|
||||
"backToHome": "Volver a inicio",
|
||||
@@ -2392,5 +2714,209 @@
|
||||
"maintenanceScreenTitle": "Servicio temporalmente no disponible",
|
||||
"maintenanceScreenMessage": "Actualmente estamos experimentando dificultades técnicas. Por favor regrese pronto.",
|
||||
"maintenanceScreenEstimatedCompletion": "Estimado completado:",
|
||||
"createInternalResourceDialogDestinationRequired": "Se requiere destino"
|
||||
"createInternalResourceDialogDestinationRequired": "Se requiere destino",
|
||||
"available": "Disponible",
|
||||
"archived": "Archivado",
|
||||
"noArchivedDevices": "No se encontraron dispositivos archivados",
|
||||
"deviceArchived": "Dispositivo archivado",
|
||||
"deviceArchivedDescription": "El dispositivo se ha archivado correctamente.",
|
||||
"errorArchivingDevice": "Error al archivar dispositivo",
|
||||
"failedToArchiveDevice": "Error al archivar el dispositivo",
|
||||
"deviceQuestionArchive": "¿Está seguro que desea archivar este dispositivo?",
|
||||
"deviceMessageArchive": "El dispositivo será archivado y eliminado de su lista de dispositivos activos.",
|
||||
"deviceArchiveConfirm": "Archivar dispositivo",
|
||||
"archiveDevice": "Archivar dispositivo",
|
||||
"archive": "Archivar",
|
||||
"deviceUnarchived": "Dispositivo desarchivado",
|
||||
"deviceUnarchivedDescription": "El dispositivo se ha desarchivado correctamente.",
|
||||
"errorUnarchivingDevice": "Error al desarchivar dispositivo",
|
||||
"failedToUnarchiveDevice": "Error al desarchivar el dispositivo",
|
||||
"unarchive": "Desarchivar",
|
||||
"archiveClient": "Archivar cliente",
|
||||
"archiveClientQuestion": "¿Está seguro que desea archivar este cliente?",
|
||||
"archiveClientMessage": "El cliente será archivado y eliminado de su lista de clientes activos.",
|
||||
"archiveClientConfirm": "Archivar cliente",
|
||||
"blockClient": "Bloquear cliente",
|
||||
"blockClientQuestion": "¿Estás seguro de que quieres bloquear a este cliente?",
|
||||
"blockClientMessage": "El dispositivo será forzado a desconectarse si está conectado actualmente. Puede desbloquear el dispositivo más tarde.",
|
||||
"blockClientConfirm": "Bloquear cliente",
|
||||
"active": "Activo",
|
||||
"usernameOrEmail": "Nombre de usuario o email",
|
||||
"selectYourOrganization": "Seleccione su organización",
|
||||
"signInTo": "Iniciar sesión en",
|
||||
"signInWithPassword": "Continuar con la contraseña",
|
||||
"noAuthMethodsAvailable": "No hay métodos de autenticación disponibles para esta organización.",
|
||||
"enterPassword": "Introduzca su contraseña",
|
||||
"enterMfaCode": "Introduzca el código de su aplicación de autenticación",
|
||||
"securityKeyRequired": "Utilice su clave de seguridad para iniciar sesión.",
|
||||
"needToUseAnotherAccount": "¿Necesitas usar una cuenta diferente?",
|
||||
"loginLegalDisclaimer": "Al hacer clic en los botones de abajo, reconoces que has leído, comprendido, y acepta los <termsOfService>Términos de Servicio</termsOfService> y <privacyPolicy>Política de Privacidad</privacyPolicy>.",
|
||||
"termsOfService": "Términos de Servicio",
|
||||
"privacyPolicy": "Política de privacidad",
|
||||
"userNotFoundWithUsername": "Ningún usuario encontrado con ese nombre de usuario.",
|
||||
"verify": "Verificar",
|
||||
"signIn": "Iniciar sesión",
|
||||
"forgotPassword": "¿Olvidaste la contraseña?",
|
||||
"orgSignInTip": "Si has iniciado sesión antes, puedes introducir tu nombre de usuario o correo electrónico arriba para autenticarte con el proveedor de identidad de tu organización. ¡Es más fácil!",
|
||||
"continueAnyway": "Continuar de todos modos",
|
||||
"dontShowAgain": "No volver a mostrar",
|
||||
"orgSignInNotice": "¿Sabía usted?",
|
||||
"signupOrgNotice": "¿Intentando iniciar sesión?",
|
||||
"signupOrgTip": "¿Estás intentando iniciar sesión a través del proveedor de identidad de tu organización?",
|
||||
"signupOrgLink": "Inicia sesión o regístrate con tu organización",
|
||||
"verifyEmailLogInWithDifferentAccount": "Usar una cuenta diferente",
|
||||
"logIn": "Iniciar sesión",
|
||||
"deviceInformation": "Información del dispositivo",
|
||||
"deviceInformationDescription": "Información sobre el dispositivo y el agente",
|
||||
"deviceSecurity": "Seguridad del dispositivo",
|
||||
"deviceSecurityDescription": "Información de postura de seguridad del dispositivo",
|
||||
"platform": "Plataforma",
|
||||
"macosVersion": "versión macOS",
|
||||
"windowsVersion": "Versión de Windows",
|
||||
"iosVersion": "Versión de iOS",
|
||||
"androidVersion": "Versión de Android",
|
||||
"osVersion": "Versión del SO",
|
||||
"kernelVersion": "Versión de Kernel",
|
||||
"deviceModel": "Modelo de dispositivo",
|
||||
"serialNumber": "Número Serial",
|
||||
"hostname": "Hostname",
|
||||
"firstSeen": "Primer detectado",
|
||||
"lastSeen": "Último Visto",
|
||||
"biometricsEnabled": "Biometría habilitada",
|
||||
"diskEncrypted": "Disco cifrado",
|
||||
"firewallEnabled": "Cortafuegos activado",
|
||||
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas",
|
||||
"tpmAvailable": "TPM disponible",
|
||||
"windowsAntivirusEnabled": "Antivirus activado",
|
||||
"macosSipEnabled": "Protección de integridad del sistema (SIP)",
|
||||
"macosGatekeeperEnabled": "Gatekeeper",
|
||||
"macosFirewallStealthMode": "Modo Sigilo Firewall",
|
||||
"linuxAppArmorEnabled": "AppArmor",
|
||||
"linuxSELinuxEnabled": "SELinux",
|
||||
"deviceSettingsDescription": "Ver información y ajustes del dispositivo",
|
||||
"devicePendingApprovalDescription": "Este dispositivo está esperando su aprobación",
|
||||
"deviceBlockedDescription": "Este dispositivo está actualmente bloqueado. No podrá conectarse a ningún recurso a menos que sea desbloqueado.",
|
||||
"unblockClient": "Desbloquear cliente",
|
||||
"unblockClientDescription": "El dispositivo ha sido desbloqueado",
|
||||
"unarchiveClient": "Desarchivar cliente",
|
||||
"unarchiveClientDescription": "El dispositivo ha sido desarchivado",
|
||||
"block": "Bloque",
|
||||
"unblock": "Desbloquear",
|
||||
"deviceActions": "Acciones del dispositivo",
|
||||
"deviceActionsDescription": "Administrar estado y acceso al dispositivo",
|
||||
"devicePendingApprovalBannerDescription": "Este dispositivo está pendiente de aprobación. No podrá conectarse a recursos hasta que sea aprobado.",
|
||||
"connected": "Conectado",
|
||||
"disconnected": "Desconectado",
|
||||
"approvalsEmptyStateTitle": "Aprobaciones de dispositivo no habilitadas",
|
||||
"approvalsEmptyStateDescription": "Habilita las aprobaciones de dispositivos para que los roles requieran aprobación del administrador antes de que los usuarios puedan conectar nuevos dispositivos.",
|
||||
"approvalsEmptyStateStep1Title": "Ir a roles",
|
||||
"approvalsEmptyStateStep1Description": "Navega a la configuración de roles de tu organización para configurar las aprobaciones de dispositivos.",
|
||||
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
|
||||
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
|
||||
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
|
||||
"approvalsEmptyStateButtonText": "Administrar roles",
|
||||
"domainErrorTitle": "Estamos teniendo problemas para verificar su dominio",
|
||||
"idpAdminAutoProvisionPoliciesTabHint": "Configure el mapeo de roles y las políticas de organización en la pestaña <policiesTabLink>Configuración de provisión automática</policiesTabLink>.",
|
||||
"streamingTitle": "Transmisión de Eventos",
|
||||
"streamingDescription": "Transmita eventos desde su organización a destinos externos en tiempo real.",
|
||||
"streamingUnnamedDestination": "Destino sin nombre",
|
||||
"streamingNoUrlConfigured": "No hay URL configurada",
|
||||
"streamingAddDestination": "Añadir destino",
|
||||
"streamingHttpWebhookTitle": "Webhook HTTP",
|
||||
"streamingHttpWebhookDescription": "Enviar eventos a cualquier extremo HTTP con autenticación flexible y plantilla.",
|
||||
"streamingS3Title": "Amazon S3",
|
||||
"streamingS3Description": "Transmite eventos a un bucket de almacenamiento de objetos compatible con S3. Próximamente.",
|
||||
"streamingDatadogTitle": "Datadog",
|
||||
"streamingDatadogDescription": "Reenviar eventos directamente a tu cuenta de Datadog. Próximamente.",
|
||||
"streamingTypePickerDescription": "Elija un tipo de destino para empezar.",
|
||||
"streamingFailedToLoad": "Error al cargar destinos",
|
||||
"streamingUnexpectedError": "Se ha producido un error inesperado.",
|
||||
"streamingFailedToUpdate": "Error al actualizar destino",
|
||||
"streamingDeletedSuccess": "Destino eliminado correctamente",
|
||||
"streamingFailedToDelete": "Error al eliminar destino",
|
||||
"streamingDeleteTitle": "Eliminar destino",
|
||||
"streamingDeleteButtonText": "Eliminar destino",
|
||||
"streamingDeleteDialogAreYouSure": "¿Está seguro que desea eliminar",
|
||||
"streamingDeleteDialogThisDestination": "este destino",
|
||||
"streamingDeleteDialogPermanentlyRemoved": "? Toda la configuración se eliminará permanentemente.",
|
||||
"httpDestEditTitle": "Editar destino",
|
||||
"httpDestAddTitle": "Añadir destino HTTP",
|
||||
"httpDestEditDescription": "Actualizar la configuración para este destino de transmisión de eventos HTTP.",
|
||||
"httpDestAddDescription": "Configure un nuevo extremo HTTP para recibir los eventos de su organización.",
|
||||
"httpDestTabSettings": "Ajustes",
|
||||
"httpDestTabHeaders": "Encabezados",
|
||||
"httpDestTabBody": "Cuerpo",
|
||||
"httpDestTabLogs": "Registros",
|
||||
"httpDestNamePlaceholder": "Mi destino HTTP",
|
||||
"httpDestUrlLabel": "URL de destino",
|
||||
"httpDestUrlErrorHttpRequired": "URL debe usar http o https",
|
||||
"httpDestUrlErrorHttpsRequired": "HTTPS es necesario en implementaciones en la nube",
|
||||
"httpDestUrlErrorInvalid": "Introduzca una URL válida (ej. https://example.com/webhook)",
|
||||
"httpDestAuthTitle": "Autenticación",
|
||||
"httpDestAuthDescription": "Elija cómo están autenticadas las solicitudes en su punto final.",
|
||||
"httpDestAuthNoneTitle": "Sin autenticación",
|
||||
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
|
||||
"httpDestAuthBearerTitle": "Tóken de portador",
|
||||
"httpDestAuthBearerDescription": "Añade un encabezado Authorization: Bearer '<token>' a cada solicitud.",
|
||||
"httpDestAuthBearerPlaceholder": "Tu clave o token API",
|
||||
"httpDestAuthBasicTitle": "Auth Básica",
|
||||
"httpDestAuthBasicDescription": "Añade un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.",
|
||||
"httpDestAuthBasicPlaceholder": "usuario:contraseña",
|
||||
"httpDestAuthCustomTitle": "Cabecera personalizada",
|
||||
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",
|
||||
"httpDestAuthCustomHeaderNamePlaceholder": "Nombre de cabecera (ej. X-API-Key)",
|
||||
"httpDestAuthCustomHeaderValuePlaceholder": "Valor de cabecera",
|
||||
"httpDestCustomHeadersTitle": "Cabeceras HTTP personalizadas",
|
||||
"httpDestCustomHeadersDescription": "Añadir cabeceras personalizadas a cada petición saliente. Útil para tokens estáticos o un tipo de contenido personalizado. De forma predeterminada, Content Type: application/json es enviado.",
|
||||
"httpDestNoHeadersConfigured": "No hay cabeceras personalizadas. Haga clic en \"Añadir cabecera\" para añadir una.",
|
||||
"httpDestHeaderNamePlaceholder": "Nombre de cabecera",
|
||||
"httpDestHeaderValuePlaceholder": "Valor",
|
||||
"httpDestAddHeader": "Añadir cabecera",
|
||||
"httpDestBodyTemplateTitle": "Plantilla de cuerpo personalizada",
|
||||
"httpDestBodyTemplateDescription": "Controla la estructura de carga de JSON enviada a tu punto final. Si está desactivado, se envía un objeto JSON por defecto para cada evento.",
|
||||
"httpDestEnableBodyTemplate": "Activar plantilla de cuerpo personalizado",
|
||||
"httpDestBodyTemplateLabel": "Plantilla de cuerpo (JSON)",
|
||||
"httpDestBodyTemplateHint": "Utilice variables de plantilla para referenciar los campos del evento en su carga útil.",
|
||||
"httpDestPayloadFormatTitle": "Formato de carga",
|
||||
"httpDestPayloadFormatDescription": "Cómo se serializan los eventos en cada cuerpo de solicitud.",
|
||||
"httpDestFormatJsonArrayTitle": "Matriz JSON",
|
||||
"httpDestFormatJsonArrayDescription": "Una petición por lote, cuerpo es una matriz JSON. Compatible con la mayoría de los webhooks y Datadog.",
|
||||
"httpDestFormatNdjsonTitle": "NDJSON",
|
||||
"httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea — un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.",
|
||||
"httpDestFormatSingleTitle": "Un evento por solicitud",
|
||||
"httpDestFormatSingleDescription": "Envía un HTTP POST separado para cada evento individual. Úsalo sólo para los extremos que no pueden manejar lotes.",
|
||||
"httpDestLogTypesTitle": "Tipos de Log",
|
||||
"httpDestLogTypesDescription": "Elija qué tipos de registro son reenviados a este destino. Sólo los tipos de registro habilitados serán transmitidos.",
|
||||
"httpDestAccessLogsTitle": "Authentication Logs",
|
||||
"httpDestAccessLogsDescription": "Intentos de acceso a recursos, incluyendo solicitudes autenticadas y denegadas.",
|
||||
"httpDestActionLogsTitle": "Admin Action Logs",
|
||||
"httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.",
|
||||
"httpDestConnectionLogsTitle": "Network Logs",
|
||||
"httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.",
|
||||
"httpDestRequestLogsTitle": "HTTPS Request Logs",
|
||||
"httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.",
|
||||
"httpDestSaveChanges": "Guardar Cambios",
|
||||
"httpDestCreateDestination": "Crear destino",
|
||||
"httpDestUpdatedSuccess": "Destino actualizado correctamente",
|
||||
"httpDestCreatedSuccess": "Destino creado correctamente",
|
||||
"httpDestUpdateFailed": "Error al actualizar destino",
|
||||
"httpDestCreateFailed": "Error al crear el destino",
|
||||
"idpAddActionCreateNew": "Crear nuevo proveedor de identidad",
|
||||
"idpAddActionImportFromOrg": "Importar de otra organización",
|
||||
"idpImportDialogTitle": "Importar Proveedor de Identidad",
|
||||
"idpImportDialogDescription": "Elija un proveedor de identidad de una organización donde usted sea administrador. Se vinculará a esta organización.",
|
||||
"idpImportSearchPlaceholder": "Buscar por nombre de organización o proveedor...",
|
||||
"idpImportEmpty": "No se encontraron proveedores de identidad.",
|
||||
"idpImportedDescription": "Proveedor de identidad importado con éxito.",
|
||||
"idpDeleteGlobalQuestion": "¿Está seguro de que desea eliminar permanentemente este proveedor de identidad?",
|
||||
"idpDeleteGlobalDescription": "Esto eliminará permanentemente el proveedor de identidad de todas las organizaciones con las que está asociado.",
|
||||
"idpUnassociateTitle": "Desasociar Proveedor de Identidad",
|
||||
"idpUnassociateQuestion": "¿Está seguro de que desea desasociar este proveedor de identidad de esta organización?",
|
||||
"idpUnassociateDescription": "Todos los usuarios asociados con este proveedor de identidad serán eliminados de esta organización, pero el proveedor de identidad continuará existiendo para otras organizaciones asociadas.",
|
||||
"idpUnassociateConfirm": "Confirme Desasociar Proveedor de Identidad",
|
||||
"idpUnassociateWarning": "Esto no se puede deshacer para esta organización.",
|
||||
"idpUnassociatedDescription": "Proveedor de identidad desasociado de esta organización con éxito",
|
||||
"idpUnassociateMenu": "Desasociar",
|
||||
"idpDeleteAllOrgsMenu": "Eliminar",
|
||||
"publicIpEndpoint": "Endpoint"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"setupCreate": "創建您的第一個組織、網站和資源",
|
||||
"headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。",
|
||||
"headerAuthCompatibility": "擴展相容性",
|
||||
"setupNewOrg": "新建組織",
|
||||
"setupCreateOrg": "創建組織",
|
||||
"setupCreateResources": "創建資源",
|
||||
@@ -13,7 +15,7 @@
|
||||
"welcome": "歡迎使用 Pangolin",
|
||||
"welcomeTo": "歡迎來到",
|
||||
"componentsCreateOrg": "創建組織",
|
||||
"componentsMember": "您屬於{count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。",
|
||||
"componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。",
|
||||
"componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。",
|
||||
"dismiss": "忽略",
|
||||
"componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。",
|
||||
@@ -51,6 +53,9 @@
|
||||
"siteQuestionRemove": "您確定要從組織中刪除該站點嗎?",
|
||||
"siteManageSites": "管理站點",
|
||||
"siteDescription": "允許通過安全隧道連接到您的網路",
|
||||
"sitesBannerTitle": "連接任何網路",
|
||||
"sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。",
|
||||
"sitesBannerButtonText": "安裝站點",
|
||||
"siteCreate": "創建站點",
|
||||
"siteCreateDescription2": "按照下面的步驟創建和連接一個新站點",
|
||||
"siteCreateDescription": "創建一個新站點開始連接您的資源",
|
||||
@@ -65,8 +70,8 @@
|
||||
"siteLoadWGConfig": "正在載入 WireGuard 配置...",
|
||||
"siteDocker": "擴展 Docker 部署詳細資訊",
|
||||
"toggle": "切換",
|
||||
"dockerCompose": "Docker 配置",
|
||||
"dockerRun": "停靠欄",
|
||||
"dockerCompose": "Docker Compose",
|
||||
"dockerRun": "Docker Run",
|
||||
"siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多",
|
||||
"siteConfirmCopy": "我已經複製了配置資訊",
|
||||
"searchSitesProgress": "搜索站點...",
|
||||
@@ -93,14 +98,15 @@
|
||||
"siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。",
|
||||
"siteWg": "基本 WireGuard",
|
||||
"siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。",
|
||||
"siteWgDescriptionSaas": "使用任何WireGuard用戶端建立隧道。需要手動配置NAT。僅適用於自託管節點。",
|
||||
"siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。",
|
||||
"siteLocalDescription": "僅限本地資源。不需要隧道。",
|
||||
"siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。",
|
||||
"siteSeeAll": "查看所有站點",
|
||||
"siteTunnelDescription": "確定如何連接到您的網站",
|
||||
"siteNewtCredentials": "Newt 憑據",
|
||||
"siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑據",
|
||||
"siteCredentialsSave": "保存您的憑據",
|
||||
"siteNewtCredentials": "Newt 憑證",
|
||||
"siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證",
|
||||
"remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式",
|
||||
"siteCredentialsSave": "保存您的憑證",
|
||||
"siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。",
|
||||
"siteInfo": "站點資訊",
|
||||
"status": "狀態",
|
||||
@@ -144,8 +150,14 @@
|
||||
"expires": "過期時間",
|
||||
"never": "永不過期",
|
||||
"shareErrorSelectResource": "請選擇一個資源",
|
||||
"resourceTitle": "管理資源",
|
||||
"resourceDescription": "為您的私人應用程式創建安全代理",
|
||||
"proxyResourceTitle": "管理公開資源",
|
||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
||||
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||
"clientResourceTitle": "管理私有資源",
|
||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||
"privateResourcesBannerTitle": "零信任私有存取",
|
||||
"privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。",
|
||||
"resourcesSearch": "搜索資源...",
|
||||
"resourceAdd": "添加資源",
|
||||
"resourceErrorDelte": "刪除資源時出錯",
|
||||
@@ -179,7 +191,7 @@
|
||||
"baseDomain": "根域名",
|
||||
"subdomnainDescription": "您的資源可以訪問的子域名。",
|
||||
"resourceRawSettings": "TCP/UDP 設置",
|
||||
"resourceRawSettingsDescription": "配置如何通過 TCP/UDP 訪問您的資源。 您映射資源到主機Pangolin伺服器上的埠,這樣您就可以訪問伺服器-公共-ip:mapped埠的資源。",
|
||||
"resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源",
|
||||
"protocol": "協議",
|
||||
"protocolSelect": "選擇協議",
|
||||
"resourcePortNumber": "埠號",
|
||||
@@ -320,6 +332,10 @@
|
||||
"licenseErrorKeyActivate": "啟用許可證金鑰失敗",
|
||||
"licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。",
|
||||
"licenseAbout": "關於許可協議",
|
||||
"licenseBannerTitle": "Enable Your Enterprise License",
|
||||
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
|
||||
"licenseBannerGetLicense": "Get a License",
|
||||
"licenseBannerViewDocs": "View Documentation",
|
||||
"communityEdition": "社區版",
|
||||
"licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。",
|
||||
"licenseKeyActivated": "授權金鑰已啟用",
|
||||
@@ -436,6 +452,16 @@
|
||||
"inviteEmailSent": "發送邀請郵件給用戶",
|
||||
"inviteValid": "有效",
|
||||
"selectDuration": "選擇持續時間",
|
||||
"selectResource": "選擇資源",
|
||||
"filterByResource": "依資源篩選",
|
||||
"resetFilters": "重設篩選條件",
|
||||
"totalBlocked": "被 Pangolin 阻擋的請求",
|
||||
"totalRequests": "總請求數",
|
||||
"requestsByCountry": "依國家/地區的請求",
|
||||
"requestsByDay": "依日期的請求",
|
||||
"blocked": "已阻擋",
|
||||
"allowed": "已允許",
|
||||
"topCountries": "熱門國家/地區",
|
||||
"accessRoleSelect": "選擇角色",
|
||||
"inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。",
|
||||
"inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。",
|
||||
@@ -465,7 +491,7 @@
|
||||
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
|
||||
"proxyEnableSSL": "啟用 SSL",
|
||||
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
|
||||
"target": "Target",
|
||||
"target": "目標",
|
||||
"configureTarget": "配置目標",
|
||||
"targetErrorFetch": "獲取目標失敗",
|
||||
"targetErrorFetchDescription": "獲取目標時出錯",
|
||||
@@ -516,6 +542,8 @@
|
||||
"targetCreatedDescription": "目標已成功創建",
|
||||
"targetErrorCreate": "創建目標失敗",
|
||||
"targetErrorCreateDescription": "創建目標時出錯",
|
||||
"tlsServerName": "TLS 伺服器名稱",
|
||||
"tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱",
|
||||
"save": "保存",
|
||||
"proxyAdditional": "附加代理設置",
|
||||
"proxyAdditionalDescription": "配置你的資源如何處理代理設置",
|
||||
@@ -702,6 +730,7 @@
|
||||
"resourceTransferSubmit": "轉移資源",
|
||||
"siteDestination": "目標站點",
|
||||
"searchSites": "搜索站點",
|
||||
"countries": "國家/地區",
|
||||
"accessRoleCreate": "創建角色",
|
||||
"accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。",
|
||||
"accessRoleCreateSubmit": "創建角色",
|
||||
@@ -825,6 +854,7 @@
|
||||
"orgPolicyConfig": "配置組織訪問權限",
|
||||
"idpUpdatedDescription": "身份提供商更新成功",
|
||||
"redirectUrl": "重定向網址",
|
||||
"orgIdpRedirectUrls": "重新導向網址",
|
||||
"redirectUrlAbout": "關於重定向網址",
|
||||
"redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。",
|
||||
"pangolinAuth": "認證 - Pangolin",
|
||||
@@ -909,6 +939,10 @@
|
||||
"passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。",
|
||||
"passwordResetCode": "驗證碼",
|
||||
"passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。",
|
||||
"generatePasswordResetCode": "產生密碼重設代碼",
|
||||
"passwordResetCodeGenerated": "密碼重設代碼已產生",
|
||||
"passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。",
|
||||
"passwordResetUrl": "重設網址",
|
||||
"passwordNew": "新密碼",
|
||||
"passwordNewConfirm": "確認新密碼",
|
||||
"changePassword": "更改密碼",
|
||||
@@ -926,6 +960,9 @@
|
||||
"pincodeAuth": "驗證器代碼",
|
||||
"pincodeSubmit2": "提交代碼",
|
||||
"passwordResetSubmit": "請求重設",
|
||||
"passwordResetAlreadyHaveCode": "輸入代碼",
|
||||
"passwordResetSmtpRequired": "請聯絡您的管理員",
|
||||
"passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。",
|
||||
"passwordBack": "回到密碼",
|
||||
"loginBack": "返回登錄",
|
||||
"signup": "註冊",
|
||||
@@ -1013,6 +1050,7 @@
|
||||
"updateOrgUser": "更新組織用戶",
|
||||
"createOrgUser": "創建組織用戶",
|
||||
"actionUpdateOrg": "更新組織",
|
||||
"actionRemoveInvitation": "移除邀請",
|
||||
"actionUpdateUser": "更新用戶",
|
||||
"actionGetUser": "獲取用戶",
|
||||
"actionGetOrgUser": "獲取組織用戶",
|
||||
@@ -1057,6 +1095,7 @@
|
||||
"actionRemoveUser": "刪除用戶",
|
||||
"actionListUsers": "列出用戶",
|
||||
"actionAddUserRole": "添加用戶角色",
|
||||
"actionSetUserOrgRoles": "Set User Roles",
|
||||
"actionGenerateAccessToken": "生成訪問令牌",
|
||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||
"actionListAccessTokens": "訪問令牌",
|
||||
@@ -1093,12 +1132,15 @@
|
||||
"actionListSiteResources": "列出站點資源",
|
||||
"actionUpdateSiteResource": "更新站點資源",
|
||||
"actionListInvitations": "邀請列表",
|
||||
"actionExportLogs": "匯出日誌",
|
||||
"actionViewLogs": "查看日誌",
|
||||
"noneSelected": "未選擇",
|
||||
"orgNotFound2": "未找到組織。",
|
||||
"searchProgress": "搜索中...",
|
||||
"create": "創建",
|
||||
"orgs": "組織",
|
||||
"loginError": "登錄時出錯",
|
||||
"loginRequiredForDevice": "需要登入以驗證您的裝置。",
|
||||
"passwordForgot": "忘記密碼?",
|
||||
"otpAuth": "兩步驗證",
|
||||
"otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。",
|
||||
@@ -1153,8 +1195,12 @@
|
||||
"sidebarHome": "首頁",
|
||||
"sidebarSites": "站點",
|
||||
"sidebarResources": "資源",
|
||||
"sidebarProxyResources": "公開",
|
||||
"sidebarClientResources": "私有",
|
||||
"sidebarAccessControl": "訪問控制",
|
||||
"sidebarLogsAndAnalytics": "日誌與分析",
|
||||
"sidebarUsers": "用戶",
|
||||
"sidebarAdmin": "管理員",
|
||||
"sidebarInvitations": "邀請",
|
||||
"sidebarRoles": "角色",
|
||||
"sidebarShareableLinks": "分享連結",
|
||||
@@ -1164,8 +1210,14 @@
|
||||
"sidebarIdentityProviders": "身份提供商",
|
||||
"sidebarLicense": "證書",
|
||||
"sidebarClients": "用戶端",
|
||||
"sidebarUserDevices": "使用者",
|
||||
"sidebarMachineClients": "機器",
|
||||
"sidebarDomains": "域",
|
||||
"sidebarGeneral": "管理",
|
||||
"sidebarLogAndAnalytics": "日誌與分析",
|
||||
"sidebarBluePrints": "藍圖",
|
||||
"sidebarOrganization": "組織",
|
||||
"sidebarLogsAnalytics": "分析",
|
||||
"blueprints": "藍圖",
|
||||
"blueprintsDescription": "應用聲明配置並查看先前運行的",
|
||||
"blueprintAdd": "添加藍圖",
|
||||
@@ -1275,12 +1327,24 @@
|
||||
"accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!",
|
||||
"documentation": "文件",
|
||||
"saveAllSettings": "保存所有設置",
|
||||
"saveResourceTargets": "儲存目標",
|
||||
"saveResourceHttp": "儲存代理設定",
|
||||
"saveProxyProtocol": "儲存代理協定設定",
|
||||
"settingsUpdated": "設置已更新",
|
||||
"settingsUpdatedDescription": "所有設置已成功更新",
|
||||
"settingsErrorUpdate": "設置更新失敗",
|
||||
"settingsErrorUpdateDescription": "更新設置時發生錯誤",
|
||||
"sidebarCollapse": "摺疊",
|
||||
"sidebarExpand": "展開",
|
||||
"productUpdateMoreInfo": "還有 {noOfUpdates} 項更新",
|
||||
"productUpdateInfo": "{noOfUpdates} 項更新",
|
||||
"productUpdateWhatsNew": "新功能",
|
||||
"productUpdateTitle": "產品更新",
|
||||
"productUpdateEmpty": "沒有更新",
|
||||
"dismissAll": "全部關閉",
|
||||
"pangolinUpdateAvailable": "有可用更新",
|
||||
"pangolinUpdateAvailableInfo": "版本 {version} 已準備好安裝",
|
||||
"pangolinUpdateAvailableReleaseNotes": "查看發行說明",
|
||||
"newtUpdateAvailable": "更新可用",
|
||||
"newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。",
|
||||
"domainPickerEnterDomain": "域名",
|
||||
@@ -1423,6 +1487,9 @@
|
||||
"and": "和",
|
||||
"privacyPolicy": "隱私政策"
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。"
|
||||
},
|
||||
"siteRequired": "需要站點。",
|
||||
"olmTunnel": "Olm 隧道",
|
||||
"olmTunnelDescription": "使用 Olm 進行用戶端連接",
|
||||
@@ -1456,15 +1523,14 @@
|
||||
"sitesFetchError": "獲取站點時出錯。",
|
||||
"olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。",
|
||||
"olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。",
|
||||
"remoteSubnets": "遠程子網",
|
||||
"enterCidrRange": "輸入 CIDR 範圍",
|
||||
"remoteSubnetsDescription": "添加可以通過用戶端遠端存取該站點的 CIDR 範圍。使用類似 10.0.0.0/24 的格式。這僅適用於 VPN 用戶端連接。",
|
||||
"resourceEnableProxy": "啟用公共代理",
|
||||
"resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。",
|
||||
"externalProxyEnabled": "外部代理已啟用",
|
||||
"addNewTarget": "添加新目標",
|
||||
"targetsList": "目標列表",
|
||||
"advancedMode": "高級模式",
|
||||
"advancedSettings": "進階設定",
|
||||
"targetErrorDuplicateTargetFound": "找到重複的目標",
|
||||
"healthCheckHealthy": "正常",
|
||||
"healthCheckUnhealthy": "不正常",
|
||||
@@ -1476,6 +1542,7 @@
|
||||
"enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。",
|
||||
"healthScheme": "方法",
|
||||
"healthSelectScheme": "選擇方法",
|
||||
"healthCheckPortInvalid": "健康檢查連接埠必須介於 1 到 65535 之間",
|
||||
"healthCheckPath": "路徑",
|
||||
"healthHostname": "IP / 主機",
|
||||
"healthPort": "埠",
|
||||
@@ -1524,9 +1591,15 @@
|
||||
"resourcesTableNoProxyResourcesFound": "未找到代理資源。",
|
||||
"resourcesTableNoInternalResourcesFound": "未找到內部資源。",
|
||||
"resourcesTableDestination": "目標",
|
||||
"resourcesTableTheseResourcesForUseWith": "這些資源供...使用",
|
||||
"resourcesTableAlias": "別名",
|
||||
"resourcesTableClients": "用戶端",
|
||||
"resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。",
|
||||
"resourcesTableNoTargets": "無目標",
|
||||
"resourcesTableHealthy": "健康",
|
||||
"resourcesTableDegraded": "降級",
|
||||
"resourcesTableOffline": "離線",
|
||||
"resourcesTableUnknown": "未知",
|
||||
"resourcesTableNotMonitored": "未監控",
|
||||
"editInternalResourceDialogEditClientResource": "編輯用戶端資源",
|
||||
"editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。",
|
||||
"editInternalResourceDialogResourceProperties": "資源屬性",
|
||||
@@ -1547,6 +1620,17 @@
|
||||
"editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式",
|
||||
"editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1",
|
||||
"editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536",
|
||||
"editInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠",
|
||||
"editInternalResourceDialogMode": "模式",
|
||||
"editInternalResourceDialogModePort": "連接埠",
|
||||
"editInternalResourceDialogModeHost": "主機",
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogDestination": "目的地",
|
||||
"editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。",
|
||||
"editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。",
|
||||
"editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。",
|
||||
"editInternalResourceDialogAlias": "別名",
|
||||
"editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。",
|
||||
"createInternalResourceDialogNoSitesAvailable": "暫無可用站點",
|
||||
"createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。",
|
||||
"createInternalResourceDialogClose": "關閉",
|
||||
@@ -1555,9 +1639,8 @@
|
||||
"createInternalResourceDialogResourceProperties": "資源屬性",
|
||||
"createInternalResourceDialogName": "名稱",
|
||||
"createInternalResourceDialogSite": "站點",
|
||||
"createInternalResourceDialogSelectSite": "選擇站點...",
|
||||
"createInternalResourceDialogSearchSites": "搜索站點...",
|
||||
"createInternalResourceDialogNoSitesFound": "未找到站點。",
|
||||
"selectSite": "選擇站點...",
|
||||
"noSitesFound": "找不到站點。",
|
||||
"createInternalResourceDialogProtocol": "協議",
|
||||
"createInternalResourceDialogTcp": "TCP",
|
||||
"createInternalResourceDialogUdp": "UDP",
|
||||
@@ -1580,11 +1663,22 @@
|
||||
"createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式",
|
||||
"createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1",
|
||||
"createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536",
|
||||
"createInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠",
|
||||
"createInternalResourceDialogMode": "模式",
|
||||
"createInternalResourceDialogModePort": "連接埠",
|
||||
"createInternalResourceDialogModeHost": "主機",
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogDestination": "目的地",
|
||||
"createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。",
|
||||
"createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。",
|
||||
"createInternalResourceDialogAlias": "別名",
|
||||
"createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。",
|
||||
"siteConfiguration": "配置",
|
||||
"siteAcceptClientConnections": "接受用戶端連接",
|
||||
"siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。",
|
||||
"siteAddress": "站點地址",
|
||||
"siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。",
|
||||
"siteNameDescription": "站點的顯示名稱,可以稍後更改。",
|
||||
"autoLoginExternalIdp": "自動使用外部 IDP 登錄",
|
||||
"autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。",
|
||||
"selectIdp": "選擇 IDP",
|
||||
@@ -1608,6 +1702,8 @@
|
||||
"remoteExitNodeConfirmDelete": "確認刪除節點",
|
||||
"remoteExitNodeDelete": "刪除節點",
|
||||
"sidebarRemoteExitNodes": "遠程節點",
|
||||
"remoteExitNodeId": "ID",
|
||||
"remoteExitNodeSecretKey": "密鑰",
|
||||
"remoteExitNodeCreate": {
|
||||
"title": "創建節點",
|
||||
"description": "創建一個新節點來擴展您的網路連接",
|
||||
@@ -1731,12 +1827,33 @@
|
||||
"idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID",
|
||||
"idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰",
|
||||
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者",
|
||||
"subnet": "子網",
|
||||
"subnetDescription": "此組織網路配置的子網。",
|
||||
"customDomain": "自訂網域",
|
||||
"authPage": "認證頁面",
|
||||
"authPageDescription": "配置您的組織認證頁面",
|
||||
"authPageDomain": "認證頁面域",
|
||||
"authPageBranding": "自訂品牌",
|
||||
"authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌",
|
||||
"authPageBrandingUpdated": "驗證頁面品牌更新成功",
|
||||
"authPageBrandingRemoved": "驗證頁面品牌移除成功",
|
||||
"authPageBrandingRemoveTitle": "移除驗證頁面品牌",
|
||||
"authPageBrandingQuestionRemove": "您確定要移除驗證頁面的品牌嗎?",
|
||||
"authPageBrandingDeleteConfirm": "確認刪除品牌",
|
||||
"brandingLogoURL": "Logo 網址",
|
||||
"brandingPrimaryColor": "主要顏色",
|
||||
"brandingLogoWidth": "寬度 (px)",
|
||||
"brandingLogoHeight": "高度 (px)",
|
||||
"brandingOrgTitle": "組織驗證頁面標題",
|
||||
"brandingOrgDescription": "{orgName} 將被替換為組織名稱",
|
||||
"brandingOrgSubtitle": "組織驗證頁面副標題",
|
||||
"brandingResourceTitle": "資源驗證頁面標題",
|
||||
"brandingResourceSubtitle": "資源驗證頁面副標題",
|
||||
"brandingResourceDescription": "{resourceName} 將被替換為組織名稱",
|
||||
"saveAuthPageDomain": "儲存網域",
|
||||
"saveAuthPageBranding": "儲存品牌",
|
||||
"removeAuthPageBranding": "移除品牌",
|
||||
"noDomainSet": "沒有域設置",
|
||||
"changeDomain": "更改域",
|
||||
"selectDomain": "選擇域",
|
||||
@@ -1764,6 +1881,15 @@
|
||||
"orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續",
|
||||
"orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。",
|
||||
"orgAuthSignInWithPangolin": "使用 Pangolin 登錄",
|
||||
"orgAuthSignInToOrg": "登入組織",
|
||||
"orgAuthSelectOrgTitle": "組織登入",
|
||||
"orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續",
|
||||
"orgAuthOrgIdPlaceholder": "your-organization",
|
||||
"orgAuthOrgIdHelp": "輸入您組織的唯一識別碼",
|
||||
"orgAuthSelectOrgHelp": "輸入組織 ID 後,您將被導向到組織的登入頁面,在那裡您可以使用 SSO 或組織憑證。",
|
||||
"orgAuthRememberOrgId": "記住此組織 ID",
|
||||
"orgAuthBackToSignIn": "返回標準登入",
|
||||
"orgAuthNoAccount": "沒有帳戶?",
|
||||
"subscriptionRequiredToUse": "需要訂閱才能使用此功能。",
|
||||
"idpDisabled": "身份提供者已禁用。",
|
||||
"orgAuthPageDisabled": "組織認證頁面已禁用。",
|
||||
@@ -1778,6 +1904,8 @@
|
||||
"enableTwoFactorAuthentication": "啟用兩步驗證",
|
||||
"completeSecuritySteps": "完成安全步驟",
|
||||
"securitySettings": "安全設定",
|
||||
"dangerSection": "危險區域",
|
||||
"dangerSectionDescription": "永久刪除與此組織相關的所有資料",
|
||||
"securitySettingsDescription": "配置您組織的安全策略",
|
||||
"requireTwoFactorForAllUsers": "所有用戶需要兩步驗證",
|
||||
"requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。",
|
||||
@@ -1815,7 +1943,7 @@
|
||||
"securityPolicyChangeWarningText": "這將影響組織中的所有用戶",
|
||||
"authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯",
|
||||
"authPageErrorUpdate": "無法更新認證頁面",
|
||||
"authPageUpdated": "身份驗證頁面更新成功",
|
||||
"authPageDomainUpdated": "驗證頁面網域更新成功",
|
||||
"healthCheckNotAvailable": "本地的",
|
||||
"rewritePath": "重寫路徑",
|
||||
"rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。",
|
||||
@@ -1841,8 +1969,19 @@
|
||||
"enterpriseEdition": "企業版",
|
||||
"unlicensed": "未授權",
|
||||
"beta": "測試版",
|
||||
"manageClients": "管理用戶端",
|
||||
"manageClientsDescription": "用戶端是可以連接到您的站點的設備",
|
||||
"manageUserDevices": "使用者裝置",
|
||||
"manageUserDevicesDescription": "查看和管理使用者用於私密連接資源的裝置",
|
||||
"downloadClientBannerTitle": "下載 Pangolin 客戶端",
|
||||
"downloadClientBannerDescription": "下載適用於您系統的 Pangolin 客戶端,以連接到 Pangolin 網路並私密存取資源。",
|
||||
"manageMachineClients": "管理機器客戶端",
|
||||
"manageMachineClientsDescription": "建立和管理伺服器和系統用於私密連接資源的客戶端",
|
||||
"machineClientsBannerTitle": "伺服器與自動化系統",
|
||||
"machineClientsBannerDescription": "機器客戶端適用於與特定使用者無關的伺服器和自動化系統。它們使用 ID 和密鑰進行驗證,可以透過 Pangolin CLI、Olm CLI 或 Olm 容器執行。",
|
||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||
"machineClientsBannerOlmContainer": "Olm 容器",
|
||||
"clientsTableUserClients": "使用者",
|
||||
"clientsTableMachineClients": "機器",
|
||||
"licenseTableValidUntil": "有效期至",
|
||||
"saasLicenseKeysSettingsTitle": "企業許可證",
|
||||
"saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰",
|
||||
@@ -1982,6 +2121,7 @@
|
||||
"clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。",
|
||||
"sidebarLogs": "日誌",
|
||||
"request": "請求",
|
||||
"requests": "請求",
|
||||
"logs": "日誌",
|
||||
"logsSettingsDescription": "監視從此 orginization 中收集的日誌",
|
||||
"searchLogs": "搜索日誌...",
|
||||
@@ -1990,6 +2130,8 @@
|
||||
"timestamp": "時間戳",
|
||||
"accessLogs": "訪問日誌",
|
||||
"exportCsv": "導出 CSV",
|
||||
"exportError": "匯出 CSV 時發生未知錯誤",
|
||||
"exportCsvTooltip": "在時間範圍內",
|
||||
"actorId": "執行者 ID",
|
||||
"allowedByRule": "根據規則允許",
|
||||
"allowedNoAuth": "無認證",
|
||||
@@ -2007,6 +2149,7 @@
|
||||
"ip": "IP",
|
||||
"reason": "原因",
|
||||
"requestLogs": "請求日誌",
|
||||
"requestAnalytics": "請求分析",
|
||||
"host": "主機",
|
||||
"location": "地點",
|
||||
"actionLogs": "操作日誌",
|
||||
@@ -2016,6 +2159,7 @@
|
||||
"logRetention": "日誌保留",
|
||||
"logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌",
|
||||
"requestLogsDescription": "查看此機構資源的詳細請求日誌",
|
||||
"requestAnalyticsDescription": "查看此組織資源的詳細請求分析",
|
||||
"logRetentionRequestLabel": "請求日誌保留",
|
||||
"logRetentionRequestDescription": "保留請求日誌的時間",
|
||||
"logRetentionAccessLabel": "訪問日誌保留",
|
||||
@@ -2029,6 +2173,7 @@
|
||||
"logRetention30Days": "30 天",
|
||||
"logRetention90Days": "90 天",
|
||||
"logRetentionForever": "永遠的",
|
||||
"logRetentionEndOfFollowingYear": "次年年底",
|
||||
"actionLogsDescription": "查看此機構執行的操作歷史",
|
||||
"accessLogsDescription": "查看此機構資源的訪問認證請求",
|
||||
"licenseRequiredToUse": "需要企業許可證才能使用此功能。",
|
||||
@@ -2084,6 +2229,43 @@
|
||||
"supportMessageSent": "消息已發送!",
|
||||
"supportWillContact": "我們很快就會聯繫起來!",
|
||||
"selectLogRetention": "選擇保留日誌",
|
||||
"terms": "條款",
|
||||
"privacy": "隱私權",
|
||||
"security": "安全性",
|
||||
"docs": "文件",
|
||||
"deviceActivation": "裝置啟用",
|
||||
"deviceCodeInvalidFormat": "代碼必須為 9 個字元(例如:A1AJ-N5JD)",
|
||||
"deviceCodeInvalidOrExpired": "代碼無效或已過期",
|
||||
"deviceCodeVerifyFailed": "驗證裝置代碼失敗",
|
||||
"signedInAs": "已登入為",
|
||||
"deviceCodeEnterPrompt": "輸入裝置上顯示的代碼",
|
||||
"continue": "繼續",
|
||||
"deviceUnknownLocation": "未知位置",
|
||||
"deviceAuthorizationRequested": "此授權請求來自 {location},時間為 {date}。請確保您信任此裝置,因為它將獲得帳戶存取權限。",
|
||||
"deviceLabel": "裝置:{deviceName}",
|
||||
"deviceWantsAccess": "想要存取您的帳戶",
|
||||
"deviceExistingAccess": "現有存取權限:",
|
||||
"deviceFullAccess": "完整帳戶存取權限",
|
||||
"deviceOrganizationsAccess": "存取您帳戶有權限的所有組織",
|
||||
"deviceAuthorize": "授權 {applicationName}",
|
||||
"deviceConnected": "裝置已連接!",
|
||||
"deviceAuthorizedMessage": "裝置已獲授權存取您的帳戶。請返回客戶端應用程式。",
|
||||
"pangolinCloud": "Pangolin 雲端",
|
||||
"viewDevices": "查看裝置",
|
||||
"viewDevicesDescription": "管理您已連接的裝置",
|
||||
"noDevices": "找不到裝置",
|
||||
"dateCreated": "建立日期",
|
||||
"unnamedDevice": "未命名裝置",
|
||||
"deviceQuestionRemove": "您確定要刪除此裝置嗎?",
|
||||
"deviceMessageRemove": "此操作無法復原。",
|
||||
"deviceDeleteConfirm": "刪除裝置",
|
||||
"deleteDevice": "刪除裝置",
|
||||
"errorLoadingDevices": "載入裝置時發生錯誤",
|
||||
"failedToLoadDevices": "載入裝置失敗",
|
||||
"deviceDeleted": "裝置已刪除",
|
||||
"deviceDeletedDescription": "裝置已成功刪除。",
|
||||
"errorDeletingDevice": "刪除裝置時發生錯誤",
|
||||
"failedToDeleteDevice": "刪除裝置失敗",
|
||||
"showColumns": "顯示列",
|
||||
"hideColumns": "隱藏列",
|
||||
"columnVisibility": "列可見性",
|
||||
@@ -2097,5 +2279,125 @@
|
||||
"selectedResources": "選定的資源",
|
||||
"enableSelected": "啟用選中的",
|
||||
"disableSelected": "禁用選中的",
|
||||
"checkSelectedStatus": "檢查選中的狀態"
|
||||
}
|
||||
"checkSelectedStatus": "檢查選中的狀態",
|
||||
"clients": "客戶端",
|
||||
"accessClientSelect": "選擇機器客戶端",
|
||||
"resourceClientDescription": "可以存取此資源的機器客戶端",
|
||||
"regenerate": "重新產生",
|
||||
"credentials": "憑證",
|
||||
"savecredentials": "儲存憑證",
|
||||
"regenerateCredentialsButton": "重新產生憑證",
|
||||
"regenerateCredentials": "重新產生憑證",
|
||||
"generatedcredentials": "已產生的憑證",
|
||||
"copyandsavethesecredentials": "複製並儲存這些憑證",
|
||||
"copyandsavethesecredentialsdescription": "離開此頁面後將不會再顯示這些憑證。請立即安全儲存。",
|
||||
"credentialsSaved": "憑證已儲存",
|
||||
"credentialsSavedDescription": "憑證已成功重新產生並儲存。",
|
||||
"credentialsSaveError": "憑證儲存錯誤",
|
||||
"credentialsSaveErrorDescription": "重新產生和儲存憑證時發生錯誤。",
|
||||
"regenerateCredentialsWarning": "重新產生憑證將使先前的憑證失效並導致斷線。請確保更新任何使用這些憑證的設定。",
|
||||
"confirm": "確認",
|
||||
"regenerateCredentialsConfirmation": "您確定要重新產生憑證嗎?",
|
||||
"endpoint": "端點",
|
||||
"Id": "ID",
|
||||
"SecretKey": "密鑰",
|
||||
"niceId": "友善 ID",
|
||||
"niceIdUpdated": "友善 ID 已更新",
|
||||
"niceIdUpdatedSuccessfully": "友善 ID 更新成功",
|
||||
"niceIdUpdateError": "更新友善 ID 時發生錯誤",
|
||||
"niceIdUpdateErrorDescription": "更新友善 ID 時發生錯誤。",
|
||||
"niceIdCannotBeEmpty": "友善 ID 不能為空",
|
||||
"enterIdentifier": "輸入識別碼",
|
||||
"identifier": "識別碼",
|
||||
"deviceLoginUseDifferentAccount": "不是您嗎?使用其他帳戶。",
|
||||
"deviceLoginDeviceRequestingAccessToAccount": "有裝置正在請求存取此帳戶。",
|
||||
"noData": "無資料",
|
||||
"machineClients": "機器客戶端",
|
||||
"install": "安裝",
|
||||
"run": "執行",
|
||||
"clientNameDescription": "客戶端的顯示名稱,可以稍後更改。",
|
||||
"clientAddress": "客戶端位址(進階)",
|
||||
"setupFailedToFetchSubnet": "取得預設子網路失敗",
|
||||
"setupSubnetAdvanced": "子網路(進階)",
|
||||
"setupSubnetDescription": "此組織內部網路的子網路。",
|
||||
"setupUtilitySubnet": "工具子網路(進階)",
|
||||
"setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。",
|
||||
"siteRegenerateAndDisconnect": "重新產生並斷開連接",
|
||||
"siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?",
|
||||
"siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。",
|
||||
"siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?",
|
||||
"siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。",
|
||||
"clientRegenerateAndDisconnect": "重新產生並斷開連接",
|
||||
"clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?",
|
||||
"clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。",
|
||||
"clientRegenerateCredentialsConfirmation": "您確定要重新產生此客戶端的憑證嗎?",
|
||||
"clientRegenerateCredentialsWarning": "這將重新產生憑證。客戶端將保持連接,直到您手動重新啟動並使用新憑證。",
|
||||
"remoteExitNodeRegenerateAndDisconnect": "重新產生並斷開連接",
|
||||
"remoteExitNodeRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此遠端出口節點的連接嗎?",
|
||||
"remoteExitNodeRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開遠端出口節點連接。遠端出口節點需要使用新憑證重新啟動。",
|
||||
"remoteExitNodeRegenerateCredentialsConfirmation": "您確定要重新產生此遠端出口節點的憑證嗎?",
|
||||
"remoteExitNodeRegenerateCredentialsWarning": "這將重新產生憑證。遠端出口節點將保持連接,直到您手動重新啟動並使用新憑證。",
|
||||
"agent": "代理",
|
||||
"personalUseOnly": "僅限個人使用",
|
||||
"loginPageLicenseWatermark": "此實例僅授權個人使用。",
|
||||
"instanceIsUnlicensed": "此實例未授權。",
|
||||
"portRestrictions": "連接埠限制",
|
||||
"allPorts": "全部",
|
||||
"custom": "自訂",
|
||||
"allPortsAllowed": "允許所有連接埠",
|
||||
"allPortsBlocked": "阻擋所有連接埠",
|
||||
"tcpPortsDescription": "指定此資源允許的 TCP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:80,443,8000-9000)。",
|
||||
"udpPortsDescription": "指定此資源允許的 UDP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:53,123,500-600)。",
|
||||
"organizationLoginPageTitle": "組織登入頁面",
|
||||
"organizationLoginPageDescription": "自訂此組織的登入頁面",
|
||||
"resourceLoginPageTitle": "資源登入頁面",
|
||||
"resourceLoginPageDescription": "自訂個別資源的登入頁面",
|
||||
"enterConfirmation": "輸入確認",
|
||||
"blueprintViewDetails": "詳細資訊",
|
||||
"defaultIdentityProvider": "預設身份提供者",
|
||||
"defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。",
|
||||
"editInternalResourceDialogNetworkSettings": "網路設定",
|
||||
"editInternalResourceDialogAccessPolicy": "存取策略",
|
||||
"editInternalResourceDialogAddRoles": "新增角色",
|
||||
"editInternalResourceDialogAddUsers": "新增使用者",
|
||||
"editInternalResourceDialogAddClients": "新增客戶端",
|
||||
"editInternalResourceDialogDestinationLabel": "目的地",
|
||||
"editInternalResourceDialogDestinationDescription": "指定內部資源的目的地位址。根據所選模式,這可以是主機名稱、IP 位址或 CIDR 範圍。可選擇設定內部 DNS 別名以便識別。",
|
||||
"editInternalResourceDialogPortRestrictionsDescription": "限制對特定 TCP/UDP 連接埠的存取,或允許/阻擋所有連接埠。",
|
||||
"editInternalResourceDialogTcp": "TCP",
|
||||
"editInternalResourceDialogUdp": "UDP",
|
||||
"editInternalResourceDialogIcmp": "ICMP",
|
||||
"editInternalResourceDialogAccessControl": "存取控制",
|
||||
"editInternalResourceDialogAccessControlDescription": "控制哪些角色、使用者和機器客戶端在連接時可以存取此資源。管理員始終擁有存取權限。",
|
||||
"editInternalResourceDialogPortRangeValidationError": "連接埠範圍必須是「*」表示所有連接埠,或以逗號分隔的連接埠和範圍列表(例如:「80,443,8000-9000」)。連接埠必須介於 1 到 65535 之間。",
|
||||
"orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?",
|
||||
"learnMore": "了解更多",
|
||||
"backToHome": "返回首頁",
|
||||
"needToSignInToOrg": "需要使用您組織的身份提供者嗎?",
|
||||
"maintenanceMode": "維護模式",
|
||||
"maintenanceModeDescription": "向訪客顯示維護頁面",
|
||||
"maintenanceModeType": "維護模式類型",
|
||||
"showMaintenancePage": "向訪客顯示維護頁面",
|
||||
"enableMaintenanceMode": "啟用維護模式",
|
||||
"automatic": "自動",
|
||||
"automaticModeDescription": "僅在所有後端目標都關閉或不健康時顯示維護頁面。只要至少有一個目標健康,您的資源就會正常運作。",
|
||||
"forced": "強制",
|
||||
"forcedModeDescription": "無論後端健康狀況如何,始終顯示維護頁面。當您想要阻止所有存取時,用於計劃維護。",
|
||||
"warning:": "警告:",
|
||||
"forcedeModeWarning": "所有流量將被導向維護頁面。您的後端資源將不會收到任何請求。",
|
||||
"pageTitle": "頁面標題",
|
||||
"pageTitleDescription": "維護頁面上顯示的主標題",
|
||||
"maintenancePageMessage": "維護訊息",
|
||||
"maintenancePageMessagePlaceholder": "我們很快就會回來!我們的網站目前正在進行預定維護。",
|
||||
"maintenancePageMessageDescription": "說明維護的詳細訊息",
|
||||
"maintenancePageTimeTitle": "預計完成時間(可選)",
|
||||
"maintenanceTime": "例如:2 小時、11 月 1 日下午 5:00",
|
||||
"maintenanceEstimatedTimeDescription": "您預計何時完成維護",
|
||||
"editDomain": "編輯網域",
|
||||
"editDomainDescription": "為您的資源選擇網域",
|
||||
"maintenanceModeDisabledTooltip": "此功能需要有效的授權才能啟用。",
|
||||
"maintenanceScreenTitle": "服務暫時無法使用",
|
||||
"maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。",
|
||||
"maintenanceScreenEstimatedCompletion": "預計完成時間:",
|
||||
"createInternalResourceDialogDestinationRequired": "目的地為必填欄位"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
|
||||
"description": "Identity-aware VPN and proxy for remote access to anything, anywhere and Dashboard UI",
|
||||
"homepage": "https://github.com/fosrl/pangolin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,30 +12,29 @@
|
||||
"license": "SEE LICENSE IN LICENSE AND README.md",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
||||
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
|
||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||
"dev:check": "npx tsc --noEmit && npm run format:check",
|
||||
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
|
||||
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
|
||||
"db:push": "npx tsx server/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
|
||||
"db:clear-migrations": "rm -rf server/migrations",
|
||||
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||
"next:build": "next build",
|
||||
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
|
||||
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
|
||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts",
|
||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts",
|
||||
"build:next": "next build",
|
||||
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
|
||||
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
|
||||
"email": "email dev --dir server/emails/templates --port 3005",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "8.2.0",
|
||||
"@aws-sdk/client-s3": "3.955.0",
|
||||
"@faker-js/faker": "10.1.0",
|
||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||
"@aws-sdk/client-s3": "3.1011.0",
|
||||
"@faker-js/faker": "10.3.0",
|
||||
"@headlessui/react": "2.2.9",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -60,90 +59,83 @@
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@react-email/components": "1.0.2",
|
||||
"@react-email/render": "2.0.0",
|
||||
"@react-email/tailwind": "2.0.2",
|
||||
"@simplewebauthn/browser": "13.2.2",
|
||||
"@simplewebauthn/server": "13.2.2",
|
||||
"@react-email/components": "1.0.8",
|
||||
"@react-email/render": "2.0.4",
|
||||
"@react-email/tailwind": "2.0.5",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.0",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.90.12",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.13.2",
|
||||
"axios": "1.13.5",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.1.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cookies": "0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"cors": "2.8.6",
|
||||
"crypto-js": "4.2.0",
|
||||
"d3": "7.9.0",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "16.1.0",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"glob": "13.0.0",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
"i": "0.3.7",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.8.2",
|
||||
"ioredis": "5.10.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "0.562.0",
|
||||
"maxmind": "5.0.1",
|
||||
"lucide-react": "0.577.0",
|
||||
"maxmind": "5.0.5",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.5.9",
|
||||
"next-intl": "4.6.1",
|
||||
"next": "15.5.14",
|
||||
"next-intl": "4.8.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "7.0.11",
|
||||
"npm": "11.7.0",
|
||||
"nprogress": "0.2.0",
|
||||
"nodemailer": "8.0.4",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.16.3",
|
||||
"posthog-node": "5.17.4",
|
||||
"pg": "8.20.0",
|
||||
"posthog-node": "5.28.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.68.0",
|
||||
"react-icons": "5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"react-hook-form": "7.71.2",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.6.0",
|
||||
"semver": "7.7.3",
|
||||
"stripe": "20.1.0",
|
||||
"reodotdev": "1.1.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "20.4.1",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.0",
|
||||
"uuid": "13.0.0",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
"winston-daily-rotate-file": "5.0.0",
|
||||
"ws": "8.18.3",
|
||||
"yaml": "2.8.2",
|
||||
"ws": "8.19.0",
|
||||
"yaml": "2.8.3",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "4.2.1",
|
||||
"zod": "4.3.6",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.51.2",
|
||||
"@dotenvx/dotenvx": "1.54.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@tailwindcss/postcss": "4.1.18",
|
||||
"@tanstack/react-query-devtools": "5.91.1",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@tailwindcss/postcss": "4.2.2",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -152,30 +144,37 @@
|
||||
"@types/express": "5.0.6",
|
||||
"@types/express-session": "1.18.2",
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "24.10.2",
|
||||
"@types/nodemailer": "7.0.4",
|
||||
"@types/node": "25.3.5",
|
||||
"@types/nodemailer": "7.0.11",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.16.0",
|
||||
"@types/react": "19.2.7",
|
||||
"@types/pg": "8.18.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/yargs": "17.0.35",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.8",
|
||||
"esbuild": "0.27.2",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.27.4",
|
||||
"esbuild-node-externals": "1.20.1",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.7.4",
|
||||
"react-email": "5.0.7",
|
||||
"tailwindcss": "4.1.18",
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-next": "16.1.7",
|
||||
"postcss": "8.5.8",
|
||||
"prettier": "3.8.1",
|
||||
"react-email": "5.2.10",
|
||||
"tailwindcss": "4.2.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.49.0"
|
||||
"typescript-eslint": "8.56.1"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.27.4",
|
||||
"dompurify": "3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 569 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 588 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -1,9 +1,10 @@
|
||||
import { Request } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { userActions, roleActions, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { userActions, roleActions } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
@@ -19,6 +20,7 @@ export enum ActionsEnum {
|
||||
getSite = "getSite",
|
||||
listSites = "listSites",
|
||||
updateSite = "updateSite",
|
||||
resetSiteBandwidth = "resetSiteBandwidth",
|
||||
reGenerateSecret = "reGenerateSecret",
|
||||
createResource = "createResource",
|
||||
deleteResource = "deleteResource",
|
||||
@@ -52,6 +54,8 @@ export enum ActionsEnum {
|
||||
listRoleResources = "listRoleResources",
|
||||
// listRoleActions = "listRoleActions",
|
||||
addUserRole = "addUserRole",
|
||||
removeUserRole = "removeUserRole",
|
||||
setUserOrgRoles = "setUserOrgRoles",
|
||||
// addUserSite = "addUserSite",
|
||||
// addUserAction = "addUserAction",
|
||||
// removeUserAction = "removeUserAction",
|
||||
@@ -78,6 +82,10 @@ export enum ActionsEnum {
|
||||
updateSiteResource = "updateSiteResource",
|
||||
createClient = "createClient",
|
||||
deleteClient = "deleteClient",
|
||||
archiveClient = "archiveClient",
|
||||
unarchiveClient = "unarchiveClient",
|
||||
blockClient = "blockClient",
|
||||
unblockClient = "unblockClient",
|
||||
updateClient = "updateClient",
|
||||
listClients = "listClients",
|
||||
getClient = "getClient",
|
||||
@@ -104,6 +112,10 @@ export enum ActionsEnum {
|
||||
listApiKeyActions = "listApiKeyActions",
|
||||
listApiKeys = "listApiKeys",
|
||||
getApiKey = "getApiKey",
|
||||
createSiteProvisioningKey = "createSiteProvisioningKey",
|
||||
listSiteProvisioningKeys = "listSiteProvisioningKeys",
|
||||
updateSiteProvisioningKey = "updateSiteProvisioningKey",
|
||||
deleteSiteProvisioningKey = "deleteSiteProvisioningKey",
|
||||
getCertificate = "getCertificate",
|
||||
restartCertificate = "restartCertificate",
|
||||
billing = "billing",
|
||||
@@ -125,7 +137,14 @@ export enum ActionsEnum {
|
||||
getBlueprint = "getBlueprint",
|
||||
applyBlueprint = "applyBlueprint",
|
||||
viewLogs = "viewLogs",
|
||||
exportLogs = "exportLogs"
|
||||
exportLogs = "exportLogs",
|
||||
listApprovals = "listApprovals",
|
||||
updateApprovals = "updateApprovals",
|
||||
signSshKey = "signSshKey",
|
||||
createEventStreamingDestination = "createEventStreamingDestination",
|
||||
updateEventStreamingDestination = "updateEventStreamingDestination",
|
||||
deleteEventStreamingDestination = "deleteEventStreamingDestination",
|
||||
listEventStreamingDestinations = "listEventStreamingDestinations"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
@@ -146,29 +165,16 @@ export async function checkUserActionPermission(
|
||||
}
|
||||
|
||||
try {
|
||||
let userOrgRoleId = req.userOrgRoleId;
|
||||
let userOrgRoleIds = req.userOrgRoleIds;
|
||||
|
||||
// If userOrgRoleId is not available on the request, fetch it
|
||||
if (userOrgRoleId === undefined) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userOrgRole.length === 0) {
|
||||
if (userOrgRoleIds === undefined) {
|
||||
userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
|
||||
if (userOrgRoleIds.length === 0) {
|
||||
throw createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
);
|
||||
}
|
||||
|
||||
userOrgRoleId = userOrgRole[0].roleId;
|
||||
}
|
||||
|
||||
// Check if the user has direct permission for the action in the current org
|
||||
@@ -179,7 +185,7 @@ export async function checkUserActionPermission(
|
||||
and(
|
||||
eq(userActions.userId, userId),
|
||||
eq(userActions.actionId, actionId),
|
||||
eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org
|
||||
eq(userActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
@@ -188,14 +194,14 @@ export async function checkUserActionPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no direct permission, check role-based permission
|
||||
// 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),
|
||||
eq(roleActions.roleId, userOrgRoleId!),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { roleResources, userResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleId
|
||||
roleIds
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleId: number;
|
||||
roleIds: number[];
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const roleResourceAccess =
|
||||
roleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { db } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||
|
||||
export async function canUserAccessSiteResource({
|
||||
userId,
|
||||
resourceId,
|
||||
roleIds
|
||||
}: {
|
||||
userId: string;
|
||||
resourceId: number;
|
||||
roleIds: number[];
|
||||
}): Promise<boolean> {
|
||||
const roleResourceAccess =
|
||||
roleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleSiteResources.siteResourceId, resourceId),
|
||||
inArray(roleSiteResources.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userSiteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userSiteResources.userId, userId),
|
||||
eq(userSiteResources.siteResourceId, resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (userResourceAccess.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -3,7 +3,14 @@ import {
|
||||
encodeHexLowerCase
|
||||
} from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, Session, sessions, User, users } from "@server/db";
|
||||
import {
|
||||
resourceSessions,
|
||||
safeRead,
|
||||
Session,
|
||||
sessions,
|
||||
User,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
@@ -54,11 +61,15 @@ export async function validateSessionToken(
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const result = await db
|
||||
.select({ user: users, session: sessions })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.userId))
|
||||
.where(eq(sessions.sessionId, sessionId));
|
||||
|
||||
const result = await safeRead((db) =>
|
||||
db
|
||||
.select({ user: users, session: sessions })
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.userId))
|
||||
.where(eq(sessions.sessionId, sessionId))
|
||||
);
|
||||
|
||||
if (result.length < 1) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { resourceSessions, ResourceSession } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { db, safeRead } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -66,15 +66,17 @@ export async function validateResourceSessionToken(
|
||||
const sessionId = encodeHexLowerCase(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
const result = await db
|
||||
.select()
|
||||
.from(resourceSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceSessions.sessionId, sessionId),
|
||||
eq(resourceSessions.resourceId, resourceId)
|
||||
const result = await safeRead((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(resourceSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(resourceSessions.sessionId, sessionId),
|
||||
eq(resourceSessions.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
if (result.length < 1) {
|
||||
return { resourceSession: null };
|
||||
@@ -85,7 +87,7 @@ export async function validateResourceSessionToken(
|
||||
if (Date.now() >= resourceSession.expiresAt) {
|
||||
await db
|
||||
.delete(resourceSessions)
|
||||
.where(eq(resourceSessions.sessionId, resourceSessions.sessionId));
|
||||
.where(eq(resourceSessions.sessionId, sessionId));
|
||||
return { resourceSession: null };
|
||||
} else if (
|
||||
Date.now() >=
|
||||
@@ -179,7 +181,7 @@ export function serializeResourceSessionCookie(
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`;
|
||||
} else {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=$domain}`;
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${domain}`;
|
||||
}
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||
import { flushConnectionLogToDb } from "#dynamic/routers/newt";
|
||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||
|
||||
async function cleanup() {
|
||||
await stopPingAccumulator();
|
||||
await flushBandwidthToDb();
|
||||
await flushConnectionLogToDb();
|
||||
await flushSiteBandwidthToDb();
|
||||
await wsCleanup();
|
||||
|
||||
process.exit(0);
|
||||
|
||||
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
|
||||
You must have a connection string in your config file, as shown above.
|
||||
|
||||
```bash
|
||||
npm run db:pg:generate
|
||||
npm run db:pg:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
### SQLite
|
||||
|
||||
```bash
|
||||
npm run db:sqlite:generate
|
||||
npm run db:sqlite:push
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
## Build Time
|
||||
|
||||
@@ -68,7 +68,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS36351",
|
||||
asn: 36351
|
||||
},
|
||||
|
||||
|
||||
// CDNs
|
||||
{
|
||||
name: "Cloudflare",
|
||||
@@ -90,7 +90,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS16625",
|
||||
asn: 16625
|
||||
},
|
||||
|
||||
|
||||
// Mobile Carriers - US
|
||||
{
|
||||
name: "T-Mobile USA",
|
||||
@@ -117,7 +117,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS6430",
|
||||
asn: 6430
|
||||
},
|
||||
|
||||
|
||||
// Mobile Carriers - Europe
|
||||
{
|
||||
name: "Vodafone UK",
|
||||
@@ -144,7 +144,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS12430",
|
||||
asn: 12430
|
||||
},
|
||||
|
||||
|
||||
// Mobile Carriers - Asia
|
||||
{
|
||||
name: "NTT DoCoMo (Japan)",
|
||||
@@ -176,7 +176,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS9808",
|
||||
asn: 9808
|
||||
},
|
||||
|
||||
|
||||
// Major US ISPs
|
||||
{
|
||||
name: "AT&T Services",
|
||||
@@ -208,7 +208,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS209",
|
||||
asn: 209
|
||||
},
|
||||
|
||||
|
||||
// Major European ISPs
|
||||
{
|
||||
name: "Deutsche Telekom",
|
||||
@@ -235,7 +235,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS12956",
|
||||
asn: 12956
|
||||
},
|
||||
|
||||
|
||||
// Major Asian ISPs
|
||||
{
|
||||
name: "China Telecom",
|
||||
@@ -262,7 +262,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS55836",
|
||||
asn: 55836
|
||||
},
|
||||
|
||||
|
||||
// VPN/Proxy Providers
|
||||
{
|
||||
name: "Private Internet Access",
|
||||
@@ -279,7 +279,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS213281",
|
||||
asn: 213281
|
||||
},
|
||||
|
||||
|
||||
// Social Media / Major Tech
|
||||
{
|
||||
name: "Facebook/Meta",
|
||||
@@ -301,7 +301,7 @@ export const MAJOR_ASNS = [
|
||||
code: "AS2906",
|
||||
asn: 2906
|
||||
},
|
||||
|
||||
|
||||
// Academic/Research
|
||||
{
|
||||
name: "MIT",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"iPad1,1": "iPad",
|
||||
"iPad2,1": "iPad 2",
|
||||
"iPad2,2": "iPad 2",
|
||||
"iPad2,3": "iPad 2",
|
||||
"iPad2,4": "iPad 2",
|
||||
"iPad3,1": "iPad 3rd Gen",
|
||||
"iPad3,3": "iPad 3rd Gen",
|
||||
"iPad3,2": "iPad 3rd Gen",
|
||||
"iPad3,4": "iPad 4th Gen",
|
||||
"iPad3,5": "iPad 4th Gen",
|
||||
"iPad3,6": "iPad 4th Gen",
|
||||
"iPad6,11": "iPad 9.7 5th Gen",
|
||||
"iPad6,12": "iPad 9.7 5th Gen",
|
||||
"iPad7,5": "iPad 9.7 6th Gen",
|
||||
"iPad7,6": "iPad 9.7 6th Gen",
|
||||
"iPad7,11": "iPad 10.2 7th Gen",
|
||||
"iPad7,12": "iPad 10.2 7th Gen",
|
||||
"iPad11,6": "iPad 10.2 8th Gen",
|
||||
"iPad11,7": "iPad 10.2 8th Gen",
|
||||
"iPad12,1": "iPad 10.2 9th Gen",
|
||||
"iPad12,2": "iPad 10.2 9th Gen",
|
||||
"iPad13,18": "iPad 10.9 10th Gen",
|
||||
"iPad13,19": "iPad 10.9 10th Gen",
|
||||
"iPad4,1": "iPad Air",
|
||||
"iPad4,2": "iPad Air",
|
||||
"iPad4,3": "iPad Air",
|
||||
"iPad5,3": "iPad Air 2",
|
||||
"iPad5,4": "iPad Air 2",
|
||||
"iPad11,3": "iPad Air 3rd Gen",
|
||||
"iPad11,4": "iPad Air 3rd Gen",
|
||||
"iPad13,1": "iPad Air 4th Gen",
|
||||
"iPad13,2": "iPad Air 4th Gen",
|
||||
"iPad13,16": "iPad Air 5th Gen",
|
||||
"iPad13,17": "iPad Air 5th Gen",
|
||||
"iPad14,8": "iPad Air M2 11",
|
||||
"iPad14,9": "iPad Air M2 11",
|
||||
"iPad14,10": "iPad Air M2 13",
|
||||
"iPad14,11": "iPad Air M2 13",
|
||||
"iPad2,5": "iPad mini",
|
||||
"iPad2,6": "iPad mini",
|
||||
"iPad2,7": "iPad mini",
|
||||
"iPad4,4": "iPad mini 2",
|
||||
"iPad4,5": "iPad mini 2",
|
||||
"iPad4,6": "iPad mini 2",
|
||||
"iPad4,7": "iPad mini 3",
|
||||
"iPad4,8": "iPad mini 3",
|
||||
"iPad4,9": "iPad mini 3",
|
||||
"iPad5,1": "iPad mini 4",
|
||||
"iPad5,2": "iPad mini 4",
|
||||
"iPad11,1": "iPad mini 5th Gen",
|
||||
"iPad11,2": "iPad mini 5th Gen",
|
||||
"iPad14,1": "iPad mini 6th Gen",
|
||||
"iPad14,2": "iPad mini 6th Gen",
|
||||
"iPad6,7": "iPad Pro 12.9",
|
||||
"iPad6,8": "iPad Pro 12.9",
|
||||
"iPad6,3": "iPad Pro 9.7",
|
||||
"iPad6,4": "iPad Pro 9.7",
|
||||
"iPad7,3": "iPad Pro 10.5",
|
||||
"iPad7,4": "iPad Pro 10.5",
|
||||
"iPad7,1": "iPad Pro 12.9",
|
||||
"iPad7,2": "iPad Pro 12.9",
|
||||
"iPad8,1": "iPad Pro 11",
|
||||
"iPad8,2": "iPad Pro 11",
|
||||
"iPad8,3": "iPad Pro 11",
|
||||
"iPad8,4": "iPad Pro 11",
|
||||
"iPad8,5": "iPad Pro 12.9",
|
||||
"iPad8,6": "iPad Pro 12.9",
|
||||
"iPad8,7": "iPad Pro 12.9",
|
||||
"iPad8,8": "iPad Pro 12.9",
|
||||
"iPad8,9": "iPad Pro 11",
|
||||
"iPad8,10": "iPad Pro 11",
|
||||
"iPad8,11": "iPad Pro 12.9",
|
||||
"iPad8,12": "iPad Pro 12.9",
|
||||
"iPad13,4": "iPad Pro 11",
|
||||
"iPad13,5": "iPad Pro 11",
|
||||
"iPad13,6": "iPad Pro 11",
|
||||
"iPad13,7": "iPad Pro 11",
|
||||
"iPad13,8": "iPad Pro 12.9",
|
||||
"iPad13,9": "iPad Pro 12.9",
|
||||
"iPad13,10": "iPad Pro 12.9",
|
||||
"iPad13,11": "iPad Pro 12.9",
|
||||
"iPad14,3": "iPad Pro 11",
|
||||
"iPad14,4": "iPad Pro 11",
|
||||
"iPad14,5": "iPad Pro 12.9",
|
||||
"iPad14,6": "iPad Pro 12.9",
|
||||
"iPad16,3": "iPad Pro M4 11",
|
||||
"iPad16,4": "iPad Pro M4 11",
|
||||
"iPad16,5": "iPad Pro M4 13",
|
||||
"iPad16,6": "iPad Pro M4 13",
|
||||
"iPhone1,1": "iPhone",
|
||||
"iPhone1,2": "iPhone 3G",
|
||||
"iPhone2,1": "iPhone 3GS",
|
||||
"iPhone3,1": "iPhone 4",
|
||||
"iPhone3,2": "iPhone 4",
|
||||
"iPhone3,3": "iPhone 4",
|
||||
"iPhone4,1": "iPhone 4S",
|
||||
"iPhone5,1": "iPhone 5",
|
||||
"iPhone5,2": "iPhone 5",
|
||||
"iPhone5,3": "iPhone 5c",
|
||||
"iPhone5,4": "iPhone 5c",
|
||||
"iPhone6,1": "iPhone 5s",
|
||||
"iPhone6,2": "iPhone 5s",
|
||||
"iPhone7,2": "iPhone 6",
|
||||
"iPhone7,1": "iPhone 6 Plus",
|
||||
"iPhone8,1": "iPhone 6s",
|
||||
"iPhone8,2": "iPhone 6s Plus",
|
||||
"iPhone8,4": "iPhone SE",
|
||||
"iPhone9,1": "iPhone 7",
|
||||
"iPhone9,3": "iPhone 7",
|
||||
"iPhone9,2": "iPhone 7 Plus",
|
||||
"iPhone9,4": "iPhone 7 Plus",
|
||||
"iPhone10,1": "iPhone 8",
|
||||
"iPhone10,4": "iPhone 8",
|
||||
"iPhone10,2": "iPhone 8 Plus",
|
||||
"iPhone10,5": "iPhone 8 Plus",
|
||||
"iPhone10,3": "iPhone X",
|
||||
"iPhone10,6": "iPhone X",
|
||||
"iPhone11,2": "iPhone Xs",
|
||||
"iPhone11,6": "iPhone Xs Max",
|
||||
"iPhone11,8": "iPhone XR",
|
||||
"iPhone12,1": "iPhone 11",
|
||||
"iPhone12,3": "iPhone 11 Pro",
|
||||
"iPhone12,5": "iPhone 11 Pro Max",
|
||||
"iPhone12,8": "iPhone SE",
|
||||
"iPhone13,1": "iPhone 12 mini",
|
||||
"iPhone13,2": "iPhone 12",
|
||||
"iPhone13,3": "iPhone 12 Pro",
|
||||
"iPhone13,4": "iPhone 12 Pro Max",
|
||||
"iPhone14,4": "iPhone 13 mini",
|
||||
"iPhone14,5": "iPhone 13",
|
||||
"iPhone14,2": "iPhone 13 Pro",
|
||||
"iPhone14,3": "iPhone 13 Pro Max",
|
||||
"iPhone14,6": "iPhone SE",
|
||||
"iPhone14,7": "iPhone 14",
|
||||
"iPhone14,8": "iPhone 14 Plus",
|
||||
"iPhone15,2": "iPhone 14 Pro",
|
||||
"iPhone15,3": "iPhone 14 Pro Max",
|
||||
"iPhone15,4": "iPhone 15",
|
||||
"iPhone15,5": "iPhone 15 Plus",
|
||||
"iPhone16,1": "iPhone 15 Pro",
|
||||
"iPhone16,2": "iPhone 15 Pro Max",
|
||||
"iPod1,1": "iPod touch Original",
|
||||
"iPod2,1": "iPod touch 2nd",
|
||||
"iPod3,1": "iPod touch 3rd Gen",
|
||||
"iPod4,1": "iPod touch 4th",
|
||||
"iPod5,1": "iPod touch 5th",
|
||||
"iPod7,1": "iPod touch 6th Gen",
|
||||
"iPod9,1": "iPod touch 7th Gen"
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"PowerMac4,4": "eMac",
|
||||
"PowerMac6,4": "eMac",
|
||||
"PowerBook2,1": "iBook",
|
||||
"PowerBook2,2": "iBook",
|
||||
"PowerBook4,1": "iBook",
|
||||
"PowerBook4,2": "iBook",
|
||||
"PowerBook4,3": "iBook",
|
||||
"PowerBook6,3": "iBook",
|
||||
"PowerBook6,5": "iBook",
|
||||
"PowerBook6,7": "iBook",
|
||||
"iMac,1": "iMac",
|
||||
"PowerMac2,1": "iMac",
|
||||
"PowerMac2,2": "iMac",
|
||||
"PowerMac4,1": "iMac",
|
||||
"PowerMac4,2": "iMac",
|
||||
"PowerMac4,5": "iMac",
|
||||
"PowerMac6,1": "iMac",
|
||||
"PowerMac6,3*": "iMac",
|
||||
"PowerMac6,3": "iMac",
|
||||
"PowerMac8,1": "iMac",
|
||||
"PowerMac8,2": "iMac",
|
||||
"PowerMac12,1": "iMac",
|
||||
"iMac4,1": "iMac",
|
||||
"iMac4,2": "iMac",
|
||||
"iMac5,2": "iMac",
|
||||
"iMac5,1": "iMac",
|
||||
"iMac6,1": "iMac",
|
||||
"iMac7,1": "iMac",
|
||||
"iMac8,1": "iMac",
|
||||
"iMac9,1": "iMac",
|
||||
"iMac10,1": "iMac",
|
||||
"iMac11,1": "iMac",
|
||||
"iMac11,2": "iMac",
|
||||
"iMac11,3": "iMac",
|
||||
"iMac12,1": "iMac",
|
||||
"iMac12,2": "iMac",
|
||||
"iMac13,1": "iMac",
|
||||
"iMac13,2": "iMac",
|
||||
"iMac14,1": "iMac",
|
||||
"iMac14,3": "iMac",
|
||||
"iMac14,2": "iMac",
|
||||
"iMac14,4": "iMac",
|
||||
"iMac15,1": "iMac",
|
||||
"iMac16,1": "iMac",
|
||||
"iMac16,2": "iMac",
|
||||
"iMac17,1": "iMac",
|
||||
"iMac18,1": "iMac",
|
||||
"iMac18,2": "iMac",
|
||||
"iMac18,3": "iMac",
|
||||
"iMac19,2": "iMac",
|
||||
"iMac19,1": "iMac",
|
||||
"iMac20,1": "iMac",
|
||||
"iMac20,2": "iMac",
|
||||
"iMac21,2": "iMac",
|
||||
"iMac21,1": "iMac",
|
||||
"iMacPro1,1": "iMac Pro",
|
||||
"PowerMac10,1": "Mac mini",
|
||||
"PowerMac10,2": "Mac mini",
|
||||
"Macmini1,1": "Mac mini",
|
||||
"Macmini2,1": "Mac mini",
|
||||
"Macmini3,1": "Mac mini",
|
||||
"Macmini4,1": "Mac mini",
|
||||
"Macmini5,1": "Mac mini",
|
||||
"Macmini5,2": "Mac mini",
|
||||
"Macmini5,3": "Mac mini",
|
||||
"Macmini6,1": "Mac mini",
|
||||
"Macmini6,2": "Mac mini",
|
||||
"Macmini7,1": "Mac mini",
|
||||
"Macmini8,1": "Mac mini",
|
||||
"ADP3,2": "Mac mini",
|
||||
"Macmini9,1": "Mac mini",
|
||||
"Mac14,3": "Mac mini",
|
||||
"Mac14,12": "Mac mini",
|
||||
"MacPro1,1*": "Mac Pro",
|
||||
"MacPro2,1": "Mac Pro",
|
||||
"MacPro3,1": "Mac Pro",
|
||||
"MacPro4,1": "Mac Pro",
|
||||
"MacPro5,1": "Mac Pro",
|
||||
"MacPro6,1": "Mac Pro",
|
||||
"MacPro7,1": "Mac Pro",
|
||||
"N/A*": "Power Macintosh",
|
||||
"PowerMac1,1": "Power Macintosh",
|
||||
"PowerMac3,1": "Power Macintosh",
|
||||
"PowerMac3,3": "Power Macintosh",
|
||||
"PowerMac3,4": "Power Macintosh",
|
||||
"PowerMac3,5": "Power Macintosh",
|
||||
"PowerMac3,6": "Power Macintosh",
|
||||
"Mac13,1": "Mac Studio",
|
||||
"Mac13,2": "Mac Studio",
|
||||
"MacBook1,1": "MacBook",
|
||||
"MacBook2,1": "MacBook",
|
||||
"MacBook3,1": "MacBook",
|
||||
"MacBook4,1": "MacBook",
|
||||
"MacBook5,1": "MacBook",
|
||||
"MacBook5,2": "MacBook",
|
||||
"MacBook6,1": "MacBook",
|
||||
"MacBook7,1": "MacBook",
|
||||
"MacBook8,1": "MacBook",
|
||||
"MacBook9,1": "MacBook",
|
||||
"MacBook10,1": "MacBook",
|
||||
"MacBookAir1,1": "MacBook Air",
|
||||
"MacBookAir2,1": "MacBook Air",
|
||||
"MacBookAir3,1": "MacBook Air",
|
||||
"MacBookAir3,2": "MacBook Air",
|
||||
"MacBookAir4,1": "MacBook Air",
|
||||
"MacBookAir4,2": "MacBook Air",
|
||||
"MacBookAir5,1": "MacBook Air",
|
||||
"MacBookAir5,2": "MacBook Air",
|
||||
"MacBookAir6,1": "MacBook Air",
|
||||
"MacBookAir6,2": "MacBook Air",
|
||||
"MacBookAir7,1": "MacBook Air",
|
||||
"MacBookAir7,2": "MacBook Air",
|
||||
"MacBookAir8,1": "MacBook Air",
|
||||
"MacBookAir8,2": "MacBook Air",
|
||||
"MacBookAir9,1": "MacBook Air",
|
||||
"MacBookAir10,1": "MacBook Air",
|
||||
"Mac14,2": "MacBook Air",
|
||||
"MacBookPro1,1": "MacBook Pro",
|
||||
"MacBookPro1,2": "MacBook Pro",
|
||||
"MacBookPro2,2": "MacBook Pro",
|
||||
"MacBookPro2,1": "MacBook Pro",
|
||||
"MacBookPro3,1": "MacBook Pro",
|
||||
"MacBookPro4,1": "MacBook Pro",
|
||||
"MacBookPro5,1": "MacBook Pro",
|
||||
"MacBookPro5,2": "MacBook Pro",
|
||||
"MacBookPro5,5": "MacBook Pro",
|
||||
"MacBookPro5,4": "MacBook Pro",
|
||||
"MacBookPro5,3": "MacBook Pro",
|
||||
"MacBookPro7,1": "MacBook Pro",
|
||||
"MacBookPro6,2": "MacBook Pro",
|
||||
"MacBookPro6,1": "MacBook Pro",
|
||||
"MacBookPro8,1": "MacBook Pro",
|
||||
"MacBookPro8,2": "MacBook Pro",
|
||||
"MacBookPro8,3": "MacBook Pro",
|
||||
"MacBookPro9,2": "MacBook Pro",
|
||||
"MacBookPro9,1": "MacBook Pro",
|
||||
"MacBookPro10,1": "MacBook Pro",
|
||||
"MacBookPro10,2": "MacBook Pro",
|
||||
"MacBookPro11,1": "MacBook Pro",
|
||||
"MacBookPro11,2": "MacBook Pro",
|
||||
"MacBookPro11,3": "MacBook Pro",
|
||||
"MacBookPro12,1": "MacBook Pro",
|
||||
"MacBookPro11,4": "MacBook Pro",
|
||||
"MacBookPro11,5": "MacBook Pro",
|
||||
"MacBookPro13,1": "MacBook Pro",
|
||||
"MacBookPro13,2": "MacBook Pro",
|
||||
"MacBookPro13,3": "MacBook Pro",
|
||||
"MacBookPro14,1": "MacBook Pro",
|
||||
"MacBookPro14,2": "MacBook Pro",
|
||||
"MacBookPro14,3": "MacBook Pro",
|
||||
"MacBookPro15,2": "MacBook Pro",
|
||||
"MacBookPro15,1": "MacBook Pro",
|
||||
"MacBookPro15,3": "MacBook Pro",
|
||||
"MacBookPro15,4": "MacBook Pro",
|
||||
"MacBookPro16,1": "MacBook Pro",
|
||||
"MacBookPro16,3": "MacBook Pro",
|
||||
"MacBookPro16,2": "MacBook Pro",
|
||||
"MacBookPro16,4": "MacBook Pro",
|
||||
"MacBookPro17,1": "MacBook Pro",
|
||||
"MacBookPro18,3": "MacBook Pro",
|
||||
"MacBookPro18,4": "MacBook Pro",
|
||||
"MacBookPro18,1": "MacBook Pro",
|
||||
"MacBookPro18,2": "MacBook Pro",
|
||||
"Mac14,7": "MacBook Pro",
|
||||
"Mac14,9": "MacBook Pro",
|
||||
"Mac14,5": "MacBook Pro",
|
||||
"Mac14,10": "MacBook Pro",
|
||||
"Mac14,6": "MacBook Pro",
|
||||
"PowerMac1,2": "Power Macintosh",
|
||||
"PowerMac5,1": "Power Macintosh",
|
||||
"PowerMac7,2": "Power Macintosh",
|
||||
"PowerMac7,3": "Power Macintosh",
|
||||
"PowerMac9,1": "Power Macintosh",
|
||||
"PowerMac11,2": "Power Macintosh",
|
||||
"PowerBook1,1": "PowerBook",
|
||||
"PowerBook3,1": "PowerBook",
|
||||
"PowerBook3,2": "PowerBook",
|
||||
"PowerBook3,3": "PowerBook",
|
||||
"PowerBook3,4": "PowerBook",
|
||||
"PowerBook3,5": "PowerBook",
|
||||
"PowerBook6,1": "PowerBook",
|
||||
"PowerBook5,1": "PowerBook",
|
||||
"PowerBook6,2": "PowerBook",
|
||||
"PowerBook5,2": "PowerBook",
|
||||
"PowerBook5,3": "PowerBook",
|
||||
"PowerBook6,4": "PowerBook",
|
||||
"PowerBook5,4": "PowerBook",
|
||||
"PowerBook5,5": "PowerBook",
|
||||
"PowerBook6,8": "PowerBook",
|
||||
"PowerBook5,6": "PowerBook",
|
||||
"PowerBook5,7": "PowerBook",
|
||||
"PowerBook5,8": "PowerBook",
|
||||
"PowerBook5,9": "PowerBook",
|
||||
"RackMac1,1": "Xserve",
|
||||
"RackMac1,2": "Xserve",
|
||||
"RackMac3,1": "Xserve",
|
||||
"Xserve1,1": "Xserve",
|
||||
"Xserve2,1": "Xserve",
|
||||
"Xserve3,1": "Xserve"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { runMigrations } from "./";
|
||||
|
||||
await runMigrations();
|
||||
@@ -16,6 +16,24 @@ if (!dev) {
|
||||
}
|
||||
export const names = JSON.parse(readFileSync(file, "utf-8"));
|
||||
|
||||
// Load iOS and Mac model mappings
|
||||
let iosModelsFile: string;
|
||||
let macModelsFile: string;
|
||||
if (!dev) {
|
||||
iosModelsFile = join(__DIRNAME, "ios_models.json");
|
||||
macModelsFile = join(__DIRNAME, "mac_models.json");
|
||||
} else {
|
||||
iosModelsFile = join("server/db/ios_models.json");
|
||||
macModelsFile = join("server/db/mac_models.json");
|
||||
}
|
||||
|
||||
const iosModels: Record<string, string> = JSON.parse(
|
||||
readFileSync(iosModelsFile, "utf-8")
|
||||
);
|
||||
const macModels: Record<string, string> = JSON.parse(
|
||||
readFileSync(macModelsFile, "utf-8")
|
||||
);
|
||||
|
||||
export async function getUniqueClientName(orgId: string): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
@@ -159,3 +177,29 @@ export function generateName(): string {
|
||||
// clean out any non-alphanumeric characters except for dashes
|
||||
return name.replace(/[^a-z0-9-]/g, "");
|
||||
}
|
||||
|
||||
export function getMacDeviceName(macIdentifier?: string | null): string | null {
|
||||
if (macIdentifier && macModels[macIdentifier]) {
|
||||
return macModels[macIdentifier];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getIosDeviceName(iosIdentifier?: string | null): string | null {
|
||||
if (iosIdentifier && iosModels[iosIdentifier]) {
|
||||
return iosModels[iosIdentifier];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUserDeviceName(
|
||||
model: string | null,
|
||||
fallBack: string | null
|
||||
): string {
|
||||
return (
|
||||
getMacDeviceName(model) ||
|
||||
getIosDeviceName(model) ||
|
||||
fallBack ||
|
||||
"Unknown Device"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
import { createPool } from "./poolConfig";
|
||||
|
||||
function createDb() {
|
||||
const config = readConfigFile();
|
||||
@@ -39,12 +39,17 @@ function createDb() {
|
||||
|
||||
// Create connection pools instead of individual connections
|
||||
const poolConfig = config.postgres.pool;
|
||||
const primaryPool = new Pool({
|
||||
const maxConnections = poolConfig?.max_connections || 20;
|
||||
const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000;
|
||||
const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000;
|
||||
|
||||
const primaryPool = createPool(
|
||||
connectionString,
|
||||
max: poolConfig?.max_connections || 20,
|
||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||
connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000
|
||||
});
|
||||
maxConnections,
|
||||
idleTimeoutMs,
|
||||
connectionTimeoutMs,
|
||||
"primary"
|
||||
);
|
||||
|
||||
const replicas = [];
|
||||
|
||||
@@ -55,14 +60,15 @@ function createDb() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
||||
for (const conn of replicaConnections) {
|
||||
const replicaPool = new Pool({
|
||||
connectionString: conn.connection_string,
|
||||
max: poolConfig?.max_replica_connections || 20,
|
||||
idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000,
|
||||
connectionTimeoutMillis:
|
||||
poolConfig?.connection_timeout_ms || 5000
|
||||
});
|
||||
const replicaPool = createPool(
|
||||
conn.connection_string,
|
||||
maxReplicaConnections,
|
||||
idleTimeoutMs,
|
||||
connectionTimeoutMs,
|
||||
"replica"
|
||||
);
|
||||
replicas.push(
|
||||
DrizzlePostgres(replicaPool, {
|
||||
logger: process.env.QUERY_LOGGING == "true"
|
||||
@@ -85,3 +91,4 @@ export const primaryDb = db.$primary;
|
||||
export type Transaction = Parameters<
|
||||
Parameters<(typeof db)["transaction"]>[0]
|
||||
>[0];
|
||||
export const DB_TYPE: "pg" | "sqlite" = "pg";
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from "./driver";
|
||||
export * from "./logsDriver";
|
||||
export * from "./safeRead";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
import { build } from "@server/build";
|
||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||
import { createPool } from "./poolConfig";
|
||||
|
||||
function createLogsDb() {
|
||||
// Only use separate logs database in SaaS builds
|
||||
if (build !== "saas") {
|
||||
return mainDb;
|
||||
}
|
||||
|
||||
const config = readConfigFile();
|
||||
|
||||
// Merge configs, prioritizing private config
|
||||
const logsConfig = config.postgres_logs;
|
||||
|
||||
// Check environment variable first
|
||||
let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING;
|
||||
let replicaConnections: Array<{ connection_string: string }> = [];
|
||||
|
||||
if (!connectionString && logsConfig) {
|
||||
connectionString = logsConfig.connection_string;
|
||||
replicaConnections = logsConfig.replicas || [];
|
||||
}
|
||||
|
||||
// If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it
|
||||
if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) {
|
||||
replicaConnections =
|
||||
process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map(
|
||||
(conn) => ({
|
||||
connection_string: conn.trim()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// If no logs database is configured, fall back to main database
|
||||
if (!connectionString) {
|
||||
return mainDb;
|
||||
}
|
||||
|
||||
// Create separate connection pool for logs database
|
||||
const poolConfig = logsConfig?.pool || config.postgres?.pool;
|
||||
const maxConnections = poolConfig?.max_connections || 20;
|
||||
const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000;
|
||||
const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000;
|
||||
|
||||
const primaryPool = createPool(
|
||||
connectionString,
|
||||
maxConnections,
|
||||
idleTimeoutMs,
|
||||
connectionTimeoutMs,
|
||||
"logs-primary"
|
||||
);
|
||||
|
||||
const replicas = [];
|
||||
|
||||
if (!replicaConnections.length) {
|
||||
replicas.push(
|
||||
DrizzlePostgres(primaryPool, {
|
||||
logger: process.env.QUERY_LOGGING == "true"
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const maxReplicaConnections =
|
||||
poolConfig?.max_replica_connections || 20;
|
||||
for (const conn of replicaConnections) {
|
||||
const replicaPool = createPool(
|
||||
conn.connection_string,
|
||||
maxReplicaConnections,
|
||||
idleTimeoutMs,
|
||||
connectionTimeoutMs,
|
||||
"logs-replica"
|
||||
);
|
||||
replicas.push(
|
||||
DrizzlePostgres(replicaPool, {
|
||||
logger: process.env.QUERY_LOGGING == "true"
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return withReplicas(
|
||||
DrizzlePostgres(primaryPool, {
|
||||
logger: process.env.QUERY_LOGGING == "true"
|
||||
}),
|
||||
replicas as any
|
||||
);
|
||||
}
|
||||
|
||||
export const logsDb = createLogsDb();
|
||||
export default logsDb;
|
||||
export const primaryLogsDb = logsDb.$primary;
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
await migrate(db as any, {
|
||||
@@ -17,5 +17,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Pool, PoolConfig } from "pg";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export function createPoolConfig(
|
||||
connectionString: string,
|
||||
maxConnections: number,
|
||||
idleTimeoutMs: number,
|
||||
connectionTimeoutMs: number
|
||||
): PoolConfig {
|
||||
return {
|
||||
connectionString,
|
||||
max: maxConnections,
|
||||
idleTimeoutMillis: idleTimeoutMs,
|
||||
connectionTimeoutMillis: connectionTimeoutMs,
|
||||
// TCP keepalive to prevent silent connection drops by NAT gateways,
|
||||
// load balancers, and other intermediate network devices (e.g. AWS
|
||||
// NAT Gateway drops idle TCP connections after ~350s)
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
|
||||
// Allow connections to be released and recreated more aggressively
|
||||
// to avoid stale connections building up
|
||||
allowExitOnIdle: false
|
||||
};
|
||||
}
|
||||
|
||||
export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
||||
pool.on("error", (err) => {
|
||||
// This catches errors on idle clients in the pool. Without this
|
||||
// handler an unexpected disconnect would crash the process.
|
||||
logger.error(
|
||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||
);
|
||||
});
|
||||
|
||||
pool.on("connect", (client) => {
|
||||
// Set a statement timeout on every new connection so a single slow
|
||||
// query can't block the pool forever
|
||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||
logger.warn(
|
||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createPool(
|
||||
connectionString: string,
|
||||
maxConnections: number,
|
||||
idleTimeoutMs: number,
|
||||
connectionTimeoutMs: number,
|
||||
label: string
|
||||
): Pool {
|
||||
const pool = new Pool(
|
||||
createPoolConfig(
|
||||
connectionString,
|
||||
maxConnections,
|
||||
idleTimeoutMs,
|
||||
connectionTimeoutMs
|
||||
)
|
||||
);
|
||||
attachPoolErrorHandlers(pool, label);
|
||||
return pool;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { db, primaryDb } from "./driver";
|
||||
|
||||
/**
|
||||
* Runs a read query with replica fallback for Postgres.
|
||||
* Executes the query against the replica first (when replicas exist).
|
||||
* If the query throws or returns no data (null, undefined, or empty array),
|
||||
* runs the same query against the primary.
|
||||
*/
|
||||
export async function safeRead<T>(
|
||||
query: (d: typeof db | typeof primaryDb) => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await query(db);
|
||||
if (result === undefined || result === null) {
|
||||
return query(primaryDb);
|
||||
}
|
||||
if (Array.isArray(result) && result.length === 0) {
|
||||
return query(primaryDb);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return query(primaryDb);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,22 @@ import {
|
||||
bigint,
|
||||
real,
|
||||
text,
|
||||
index
|
||||
index,
|
||||
primaryKey,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
||||
import {
|
||||
domains,
|
||||
orgs,
|
||||
targets,
|
||||
users,
|
||||
exitNodes,
|
||||
sessions,
|
||||
clients,
|
||||
siteResources,
|
||||
sites
|
||||
} from "./schema";
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
@@ -74,11 +86,16 @@ export const subscriptions = pgTable("subscriptions", {
|
||||
canceledAt: bigint("canceledAt", { mode: "number" }),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }),
|
||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" })
|
||||
version: integer("version"),
|
||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
||||
});
|
||||
|
||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||
subscriptionItemId: serial("subscriptionItemId").primaryKey(),
|
||||
stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", {
|
||||
length: 255
|
||||
}),
|
||||
subscriptionId: varchar("subscriptionId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
@@ -86,6 +103,7 @@ export const subscriptionItems = pgTable("subscriptionItems", {
|
||||
}),
|
||||
planId: varchar("planId", { length: 255 }).notNull(),
|
||||
priceId: varchar("priceId", { length: 255 }),
|
||||
featureId: varchar("featureId", { length: 255 }),
|
||||
meterId: varchar("meterId", { length: 255 }),
|
||||
unitAmount: real("unitAmount"),
|
||||
tiers: text("tiers"),
|
||||
@@ -128,6 +146,7 @@ export const limits = pgTable("limits", {
|
||||
})
|
||||
.notNull(),
|
||||
value: real("value"),
|
||||
override: boolean("override").default(false),
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
@@ -206,7 +225,7 @@ export const loginPageOrg = pgTable("loginPageOrg", {
|
||||
|
||||
export const loginPageBranding = pgTable("loginPageBranding", {
|
||||
loginPageBrandingId: serial("loginPageBrandingId").primaryKey(),
|
||||
logoUrl: text("logoUrl").notNull(),
|
||||
logoUrl: text("logoUrl"),
|
||||
logoWidth: integer("logoWidth").notNull(),
|
||||
logoHeight: integer("logoHeight").notNull(),
|
||||
primaryColor: text("primaryColor"),
|
||||
@@ -273,6 +292,7 @@ export const accessAuditLog = pgTable(
|
||||
actor: varchar("actor", { length: 255 }),
|
||||
actorId: varchar("actorId", { length: 255 }),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: varchar("ip", { length: 45 }),
|
||||
type: varchar("type", { length: 100 }).notNull(),
|
||||
action: boolean("action").notNull(),
|
||||
@@ -289,6 +309,156 @@ export const accessAuditLog = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const connectionAuditLog = pgTable(
|
||||
"connectionAuditLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceAddr: text("sourceAddr").notNull(),
|
||||
destAddr: text("destAddr").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
startedAt: integer("startedAt").notNull(),
|
||||
endedAt: integer("endedAt"),
|
||||
bytesTx: integer("bytesTx"),
|
||||
bytesRx: integer("bytesRx")
|
||||
},
|
||||
(table) => [
|
||||
index("idx_accessAuditLog_startedAt").on(table.startedAt),
|
||||
index("idx_accessAuditLog_org_startedAt").on(
|
||||
table.orgId,
|
||||
table.startedAt
|
||||
),
|
||||
index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId)
|
||||
]
|
||||
);
|
||||
|
||||
export const approvals = pgTable("approvals", {
|
||||
approvalId: serial("approvalId").primaryKey(),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}), // clients reference user devices (in this case)
|
||||
userId: varchar("userId")
|
||||
.references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
decision: varchar("decision")
|
||||
.$type<"approved" | "denied" | "pending">()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
type: varchar("type")
|
||||
.$type<"user_device" /*| 'proxy' // for later */>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const bannedEmails = pgTable("bannedEmails", {
|
||||
email: varchar("email", { length: 255 }).primaryKey()
|
||||
});
|
||||
|
||||
export const bannedIps = pgTable("bannedIps", {
|
||||
ip: varchar("ip", { length: 255 }).primaryKey()
|
||||
});
|
||||
|
||||
export const siteProvisioningKeys = pgTable("siteProvisioningKeys", {
|
||||
siteProvisioningKeyId: varchar("siteProvisioningKeyId", {
|
||||
length: 255
|
||||
}).primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
|
||||
lastChars: varchar("lastChars", { length: 4 }).notNull(),
|
||||
createdAt: varchar("dateCreated", { length: 255 }).notNull(),
|
||||
lastUsed: varchar("lastUsed", { length: 255 }),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: varchar("validUntil", { length: 255 }),
|
||||
approveNewSites: boolean("approveNewSites").notNull().default(true)
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = pgTable(
|
||||
"siteProvisioningKeyOrg",
|
||||
{
|
||||
siteProvisioningKeyId: varchar("siteProvisioningKeyId", {
|
||||
length: 255
|
||||
})
|
||||
.notNull()
|
||||
.references(() => siteProvisioningKeys.siteProvisioningKeyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: varchar("orgId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.siteProvisioningKeyId, table.orgId]
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
export const eventStreamingDestinations = pgTable(
|
||||
"eventStreamingDestinations",
|
||||
{
|
||||
destinationId: serial("destinationId").primaryKey(),
|
||||
orgId: varchar("orgId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false),
|
||||
sendRequestLogs: boolean("sendRequestLogs").notNull().default(false),
|
||||
sendActionLogs: boolean("sendActionLogs").notNull().default(false),
|
||||
sendAccessLogs: boolean("sendAccessLogs").notNull().default(false),
|
||||
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
|
||||
config: text("config").notNull(), // JSON string with the configuration for the destination
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
}
|
||||
);
|
||||
|
||||
export const eventStreamingCursors = pgTable(
|
||||
"eventStreamingCursors",
|
||||
{
|
||||
cursorId: serial("cursorId").primaryKey(),
|
||||
destinationId: integer("destinationId")
|
||||
.notNull()
|
||||
.references(() => eventStreamingDestinations.destinationId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection"
|
||||
lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0),
|
||||
lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_eventStreamingCursors_dest_type").on(
|
||||
table.destinationId,
|
||||
table.logType
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
@@ -309,3 +479,19 @@ export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||
export type ConnectionAuditLog = InferSelectModel<typeof connectionAuditLog>;
|
||||
export type SessionTransferToken = InferSelectModel<
|
||||
typeof sessionTransferToken
|
||||
>;
|
||||
export type BannedEmail = InferSelectModel<typeof bannedEmails>;
|
||||
export type BannedIp = InferSelectModel<typeof bannedIps>;
|
||||
export type SiteProvisioningKey = InferSelectModel<typeof siteProvisioningKeys>;
|
||||
export type SiteProvisioningKeyOrg = InferSelectModel<
|
||||
typeof siteProvisioningKeyOrg
|
||||
>;
|
||||
export type EventStreamingDestination = InferSelectModel<
|
||||
typeof eventStreamingDestinations
|
||||
>;
|
||||
export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
varchar,
|
||||
boolean,
|
||||
integer,
|
||||
bigint,
|
||||
real,
|
||||
text,
|
||||
index,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { alias } from "yargs";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
real,
|
||||
serial,
|
||||
text,
|
||||
unique,
|
||||
varchar
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const domains = pgTable("domains", {
|
||||
domainId: varchar("domainId").primaryKey(),
|
||||
@@ -24,7 +24,8 @@ export const domains = pgTable("domains", {
|
||||
tries: integer("tries").notNull().default(0),
|
||||
certResolver: varchar("certResolver"),
|
||||
customCertResolver: varchar("customCertResolver"),
|
||||
preferWildcardCert: boolean("preferWildcardCert")
|
||||
preferWildcardCert: boolean("preferWildcardCert"),
|
||||
errorMessage: text("errorMessage")
|
||||
});
|
||||
|
||||
export const dnsRecords = pgTable("dnsRecords", {
|
||||
@@ -55,7 +56,14 @@ export const orgs = pgTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: boolean("isBillingOrg"),
|
||||
billingOrgId: varchar("billingOrgId")
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
@@ -86,12 +94,14 @@ export const sites = pgTable("sites", {
|
||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||
online: boolean("online").notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
address: varchar("address"),
|
||||
endpoint: varchar("endpoint"),
|
||||
publicKey: varchar("publicKey"),
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true)
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
status: varchar("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
@@ -134,13 +144,16 @@ export const resources = pgTable("resources", {
|
||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled").notNull().default(false),
|
||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
enum: ["forced", "automatic"]
|
||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
@@ -185,7 +198,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||
hcMethod: varchar("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
@@ -215,7 +230,7 @@ export const siteResources = pgTable("siteResources", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: varchar("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -223,9 +238,13 @@ export const siteResources = pgTable("siteResources", {
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
alias: varchar("alias"),
|
||||
aliasAddress: varchar("aliasAddress"),
|
||||
tcpPortRangeString: varchar("tcpPortRangeString"),
|
||||
udpPortRangeString: varchar("udpPortRangeString"),
|
||||
disableIcmp: boolean("disableIcmp").notNull().default(false)
|
||||
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
|
||||
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
|
||||
disableIcmp: boolean("disableIcmp").notNull().default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
});
|
||||
|
||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||
@@ -272,8 +291,10 @@ export const users = pgTable("user", {
|
||||
dateCreated: varchar("dateCreated").notNull(),
|
||||
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||
termsVersion: varchar("termsVersion"),
|
||||
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
||||
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }),
|
||||
locale: varchar("locale")
|
||||
});
|
||||
|
||||
export const newts = pgTable("newt", {
|
||||
@@ -321,11 +342,9 @@ export const userOrgs = pgTable("userOrgs", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: boolean("isOwner").notNull().default(false),
|
||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
||||
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||
@@ -363,9 +382,30 @@ export const roles = pgTable("roles", {
|
||||
.notNull(),
|
||||
isAdmin: boolean("isAdmin"),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description")
|
||||
description: varchar("description"),
|
||||
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
|
||||
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
|
||||
export const userOrgRoles = pgTable(
|
||||
"userOrgRoles",
|
||||
{
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||
);
|
||||
|
||||
export const roleActions = pgTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
@@ -433,12 +473,22 @@ export const userInvites = pgTable("userInvites", {
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
email: varchar("email").notNull(),
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
|
||||
tokenHash: varchar("token").notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
tokenHash: varchar("token").notNull()
|
||||
});
|
||||
|
||||
export const userInviteRoles = pgTable(
|
||||
"userInviteRoles",
|
||||
{
|
||||
inviteId: varchar("inviteId")
|
||||
.notNull()
|
||||
.references(() => userInvites.inviteId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.inviteId, t.roleId] })]
|
||||
);
|
||||
|
||||
export const resourcePincode = pgTable("resourcePincode", {
|
||||
pincodeId: serial("pincodeId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
@@ -464,13 +514,22 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", {
|
||||
headerAuthHash: varchar("headerAuthHash").notNull()
|
||||
});
|
||||
|
||||
export const resourceHeaderAuthExtendedCompatibility = pgTable("resourceHeaderAuthExtendedCompatibility", {
|
||||
headerAuthExtendedCompatibilityId: serial("headerAuthExtendedCompatibilityId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
extendedCompatibilityIsActivated: boolean("extendedCompatibilityIsActivated").notNull().default(true),
|
||||
});
|
||||
export const resourceHeaderAuthExtendedCompatibility = pgTable(
|
||||
"resourceHeaderAuthExtendedCompatibility",
|
||||
{
|
||||
headerAuthExtendedCompatibilityId: serial(
|
||||
"headerAuthExtendedCompatibilityId"
|
||||
).primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
extendedCompatibilityIsActivated: boolean(
|
||||
"extendedCompatibilityIsActivated"
|
||||
)
|
||||
.notNull()
|
||||
.default(true)
|
||||
}
|
||||
);
|
||||
|
||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||
@@ -580,7 +639,8 @@ export const idp = pgTable("idp", {
|
||||
type: varchar("type").notNull(),
|
||||
defaultRoleMapping: varchar("defaultRoleMapping"),
|
||||
defaultOrgMapping: varchar("defaultOrgMapping"),
|
||||
autoProvision: boolean("autoProvision").notNull().default(false)
|
||||
autoProvision: boolean("autoProvision").notNull().default(false),
|
||||
tags: text("tags")
|
||||
});
|
||||
|
||||
export const idpOidcConfig = pgTable("idpOidcConfig", {
|
||||
@@ -677,7 +737,12 @@ export const clients = pgTable("clients", {
|
||||
online: boolean("online").notNull().default(false),
|
||||
// endpoint: varchar("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
maxConnections: integer("maxConnections")
|
||||
maxConnections: integer("maxConnections"),
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
blocked: boolean("blocked").notNull().default(false),
|
||||
approvalState: varchar("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
|
||||
export const clientSitesAssociationsCache = pgTable(
|
||||
@@ -687,6 +752,7 @@ export const clientSitesAssociationsCache = pgTable(
|
||||
.notNull(),
|
||||
siteId: integer("siteId").notNull(),
|
||||
isRelayed: boolean("isRelayed").notNull().default(false),
|
||||
isJitMode: boolean("isJitMode").notNull().default(false),
|
||||
endpoint: varchar("endpoint"),
|
||||
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||
}
|
||||
@@ -701,6 +767,16 @@ export const clientSiteResourcesAssociationsCache = pgTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
|
||||
snapshotId: serial("snapshotId").primaryKey(),
|
||||
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
|
||||
collectedAt: integer("collectedAt").notNull()
|
||||
});
|
||||
|
||||
export const olms = pgTable("olms", {
|
||||
olmId: varchar("id").primaryKey(),
|
||||
secretHash: varchar("secretHash").notNull(),
|
||||
@@ -715,7 +791,118 @@ export const olms = pgTable("olms", {
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
})
|
||||
}),
|
||||
archived: boolean("archived").notNull().default(false)
|
||||
});
|
||||
|
||||
export const currentFingerprint = pgTable("currentFingerprint", {
|
||||
fingerprintId: serial("id").primaryKey(),
|
||||
|
||||
olmId: text("olmId")
|
||||
.references(() => olms.olmId, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
|
||||
firstSeen: integer("firstSeen").notNull(),
|
||||
lastSeen: integer("lastSeen").notNull(),
|
||||
lastCollectedAt: integer("lastCollectedAt").notNull(),
|
||||
|
||||
username: text("username"),
|
||||
hostname: text("hostname"),
|
||||
platform: text("platform"),
|
||||
osVersion: text("osVersion"),
|
||||
kernelVersion: text("kernelVersion"),
|
||||
arch: text("arch"),
|
||||
deviceModel: text("deviceModel"),
|
||||
serialNumber: text("serialNumber"),
|
||||
platformFingerprint: varchar("platformFingerprint"),
|
||||
|
||||
// Platform-agnostic checks
|
||||
|
||||
biometricsEnabled: boolean("biometricsEnabled").notNull().default(false),
|
||||
diskEncrypted: boolean("diskEncrypted").notNull().default(false),
|
||||
firewallEnabled: boolean("firewallEnabled").notNull().default(false),
|
||||
autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false),
|
||||
tpmAvailable: boolean("tpmAvailable").notNull().default(false),
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// macOS-specific posture check information
|
||||
|
||||
macosSipEnabled: boolean("macosSipEnabled").notNull().default(false),
|
||||
macosGatekeeperEnabled: boolean("macosGatekeeperEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosFirewallStealthMode: boolean("macosFirewallStealthMode")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Linux-specific posture check information
|
||||
|
||||
linuxAppArmorEnabled: boolean("linuxAppArmorEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
linuxSELinuxEnabled: boolean("linuxSELinuxEnabled").notNull().default(false)
|
||||
});
|
||||
|
||||
export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
|
||||
snapshotId: serial("id").primaryKey(),
|
||||
|
||||
fingerprintId: integer("fingerprintId").references(
|
||||
() => currentFingerprint.fingerprintId,
|
||||
{
|
||||
onDelete: "set null"
|
||||
}
|
||||
),
|
||||
|
||||
username: text("username"),
|
||||
hostname: text("hostname"),
|
||||
platform: text("platform"),
|
||||
osVersion: text("osVersion"),
|
||||
kernelVersion: text("kernelVersion"),
|
||||
arch: text("arch"),
|
||||
deviceModel: text("deviceModel"),
|
||||
serialNumber: text("serialNumber"),
|
||||
platformFingerprint: varchar("platformFingerprint"),
|
||||
|
||||
// Platform-agnostic checks
|
||||
|
||||
biometricsEnabled: boolean("biometricsEnabled").notNull().default(false),
|
||||
diskEncrypted: boolean("diskEncrypted").notNull().default(false),
|
||||
firewallEnabled: boolean("firewallEnabled").notNull().default(false),
|
||||
autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false),
|
||||
tpmAvailable: boolean("tpmAvailable").notNull().default(false),
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// macOS-specific posture check information
|
||||
|
||||
macosSipEnabled: boolean("macosSipEnabled").notNull().default(false),
|
||||
macosGatekeeperEnabled: boolean("macosGatekeeperEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosFirewallStealthMode: boolean("macosFirewallStealthMode")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Linux-specific posture check information
|
||||
|
||||
linuxAppArmorEnabled: boolean("linuxAppArmorEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
linuxSELinuxEnabled: boolean("linuxSELinuxEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
hash: text("hash").notNull(),
|
||||
collectedAt: integer("collectedAt").notNull()
|
||||
});
|
||||
|
||||
export const olmSessions = pgTable("clientSession", {
|
||||
@@ -844,6 +1031,16 @@ export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", {
|
||||
})
|
||||
});
|
||||
|
||||
export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||
messageId: serial("messageId").primaryKey(),
|
||||
wsClientId: varchar("clientId"),
|
||||
messageType: varchar("messageType"),
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull(),
|
||||
receivedAt: bigint("receivedAt", { mode: "number" }),
|
||||
error: text("error"),
|
||||
complete: boolean("complete").notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -867,18 +1064,23 @@ export type UserSite = InferSelectModel<typeof userSites>;
|
||||
export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
|
||||
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
|
||||
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<
|
||||
typeof resourceHeaderAuthExtendedCompatibility
|
||||
>;
|
||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type DnsRecord = InferSelectModel<typeof dnsRecords>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
@@ -902,3 +1104,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import {
|
||||
db, loginPage, LoginPage, loginPageOrg, Org, orgs,
|
||||
db,
|
||||
loginPage,
|
||||
LoginPage,
|
||||
loginPageOrg,
|
||||
Org,
|
||||
orgs,
|
||||
roles
|
||||
} from "@server/db";
|
||||
import {
|
||||
Resource,
|
||||
@@ -14,20 +20,19 @@ import {
|
||||
resources,
|
||||
roleResources,
|
||||
sessions,
|
||||
userOrgs,
|
||||
userResources,
|
||||
users,
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
|
||||
export type ResourceWithAuth = {
|
||||
resource: Resource | null;
|
||||
pincode: ResourcePincode | null;
|
||||
password: ResourcePassword | null;
|
||||
headerAuth: ResourceHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
@@ -59,12 +64,12 @@ export async function getResourceByDomain(
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
|
||||
)
|
||||
.innerJoin(
|
||||
orgs,
|
||||
eq(orgs.orgId, resources.orgId)
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.fullDomain, domain))
|
||||
.limit(1);
|
||||
|
||||
@@ -77,7 +82,8 @@ export async function getResourceByDomain(
|
||||
pincode: result.resourcePincode,
|
||||
password: result.resourcePassword,
|
||||
headerAuth: result.resourceHeaderAuth,
|
||||
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility,
|
||||
headerAuthExtendedCompatibility:
|
||||
result.resourceHeaderAuthExtendedCompatibility,
|
||||
org: result.orgs
|
||||
};
|
||||
}
|
||||
@@ -105,16 +111,15 @@ export async function getUserSessionWithUser(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user organization role
|
||||
* Get role name by role ID (for display).
|
||||
*/
|
||||
export async function getUserOrgRole(userId: string, orgId: string) {
|
||||
const userOrgRole = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
export async function getRoleName(roleId: number): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ name: roles.name })
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.limit(1);
|
||||
|
||||
return userOrgRole.length > 0 ? userOrgRole[0] : null;
|
||||
return row?.name ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +127,7 @@ export async function getUserOrgRole(userId: string, orgId: string) {
|
||||
*/
|
||||
export async function getRoleResourceAccess(
|
||||
resourceId: number,
|
||||
roleId: number
|
||||
roleIds: number[]
|
||||
) {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
@@ -130,12 +135,11 @@ export async function getRoleResourceAccess(
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
|
||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
// Regions of the World
|
||||
// as of 2025-10-25
|
||||
//
|
||||
// Adapted according to the United Nations Geoscheme
|
||||
// see https://www.unicode.org/cldr/charts/48/supplemental/territory_containment_un_m_49.html
|
||||
// see https://unstats.un.org/unsd/methodology/m49
|
||||
|
||||
export const REGIONS = [
|
||||
{
|
||||
name: "regionAfrica",
|
||||
id: "002",
|
||||
includes: [
|
||||
{
|
||||
name: "regionNorthernAfrica",
|
||||
id: "015",
|
||||
countries: ["DZ", "EG", "LY", "MA", "SD", "TN", "EH"]
|
||||
},
|
||||
{
|
||||
name: "regionEasternAfrica",
|
||||
id: "014",
|
||||
countries: ["IO", "BI", "KM", "DJ", "ER", "ET", "TF", "KE", "MG", "MW", "MU", "YT", "MZ", "RE", "RW", "SC", "SO", "SS", "UG", "ZM", "ZW"]
|
||||
},
|
||||
{
|
||||
name: "regionMiddleAfrica",
|
||||
id: "017",
|
||||
countries: ["AO", "CM", "CF", "TD", "CG", "CD", "GQ", "GA", "ST"]
|
||||
},
|
||||
{
|
||||
name: "regionSouthernAfrica",
|
||||
id: "018",
|
||||
countries: ["BW", "SZ", "LS", "NA", "ZA"]
|
||||
},
|
||||
{
|
||||
name: "regionWesternAfrica",
|
||||
id: "011",
|
||||
countries: ["BJ", "BF", "CV", "CI", "GM", "GH", "GN", "GW", "LR", "ML", "MR", "NE", "NG", "SH", "SN", "SL", "TG"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "regionAmericas",
|
||||
id: "019",
|
||||
includes: [
|
||||
{
|
||||
name: "regionCaribbean",
|
||||
id: "029",
|
||||
countries: ["AI", "AG", "AW", "BS", "BB", "BQ", "VG", "KY", "CU", "CW", "DM", "DO", "GD", "GP", "HT", "JM", "MQ", "MS", "PR", "BL", "KN", "LC", "MF", "VC", "SX", "TT", "TC", "VI"]
|
||||
},
|
||||
{
|
||||
name: "regionCentralAmerica",
|
||||
id: "013",
|
||||
countries: ["BZ", "CR", "SV", "GT", "HN", "MX", "NI", "PA"]
|
||||
},
|
||||
{
|
||||
name: "regionSouthAmerica",
|
||||
id: "005",
|
||||
countries: ["AR", "BO", "BV", "BR", "CL", "CO", "EC", "FK", "GF", "GY", "PY", "PE", "GS", "SR", "UY", "VE"]
|
||||
},
|
||||
{
|
||||
name: "regionNorthernAmerica",
|
||||
id: "021",
|
||||
countries: ["BM", "CA", "GL", "PM", "US"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "regionAsia",
|
||||
id: "142",
|
||||
includes: [
|
||||
{
|
||||
name: "regionCentralAsia",
|
||||
id: "143",
|
||||
countries: ["KZ", "KG", "TJ", "TM", "UZ"]
|
||||
},
|
||||
{
|
||||
name: "regionEasternAsia",
|
||||
id: "030",
|
||||
countries: ["CN", "HK", "MO", "KP", "JP", "MN", "KR"]
|
||||
},
|
||||
{
|
||||
name: "regionSouthEasternAsia",
|
||||
id: "035",
|
||||
countries: ["BN", "KH", "ID", "LA", "MY", "MM", "PH", "SG", "TH", "TL", "VN"]
|
||||
},
|
||||
{
|
||||
name: "regionSouthernAsia",
|
||||
id: "034",
|
||||
countries: ["AF", "BD", "BT", "IN", "IR", "MV", "NP", "PK", "LK"]
|
||||
},
|
||||
{
|
||||
name: "regionWesternAsia",
|
||||
id: "145",
|
||||
countries: ["AM", "AZ", "BH", "CY", "GE", "IQ", "IL", "JO", "KW", "LB", "OM", "QA", "SA", "PS", "SY", "TR", "AE", "YE"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "regionEurope",
|
||||
id: "150",
|
||||
includes: [
|
||||
{
|
||||
name: "regionEasternEurope",
|
||||
id: "151",
|
||||
countries: ["BY", "BG", "CZ", "HU", "PL", "MD", "RO", "RU", "SK", "UA"]
|
||||
},
|
||||
{
|
||||
name: "regionNorthernEurope",
|
||||
id: "154",
|
||||
countries: ["AX", "DK", "EE", "FO", "FI", "GG", "IS", "IE", "IM", "JE", "LV", "LT", "NO", "SJ", "SE", "GB"]
|
||||
},
|
||||
{
|
||||
name: "regionSouthernEurope",
|
||||
id: "039",
|
||||
countries: ["AL", "AD", "BA", "HR", "GI", "GR", "VA", "IT", "MT", "ME", "MK", "PT", "SM", "RS", "SI", "ES"]
|
||||
},
|
||||
{
|
||||
name: "regionWesternEurope",
|
||||
id: "155",
|
||||
countries: ["AT", "BE", "FR", "DE", "LI", "LU", "MC", "NL", "CH"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "regionOceania",
|
||||
id: "009",
|
||||
includes: [
|
||||
{
|
||||
name: "regionAustraliaAndNewZealand",
|
||||
id: "053",
|
||||
countries: ["AU", "CX", "CC", "HM", "NZ", "NF"]
|
||||
},
|
||||
{
|
||||
name: "regionMelanesia",
|
||||
id: "054",
|
||||
countries: ["FJ", "NC", "PG", "SB", "VU"]
|
||||
},
|
||||
{
|
||||
name: "regionMicronesia",
|
||||
id: "057",
|
||||
countries: ["GU", "KI", "MH", "FM", "NR", "MP", "PW", "UM"]
|
||||
},
|
||||
{
|
||||
name: "regionPolynesia",
|
||||
id: "061",
|
||||
countries: ["AS", "CK", "PF", "NU", "PN", "WS", "TK", "TO", "TV", "WF"]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
type Subregion = {
|
||||
name: string;
|
||||
id: string;
|
||||
countries: string[];
|
||||
};
|
||||
|
||||
type Region = {
|
||||
name: string;
|
||||
id: string;
|
||||
includes: Subregion[];
|
||||
};
|
||||
|
||||
export function getRegionNameById(regionId: string): string | undefined {
|
||||
// Check top-level regions
|
||||
const region = REGIONS.find((r) => r.id === regionId);
|
||||
if (region) {
|
||||
return region.name;
|
||||
}
|
||||
|
||||
// Check subregions
|
||||
for (const region of REGIONS) {
|
||||
for (const subregion of region.includes) {
|
||||
if (subregion.id === regionId) {
|
||||
return subregion.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isValidRegionId(regionId: string): boolean {
|
||||
// Check top-level regions
|
||||
if (REGIONS.find((r) => r.id === regionId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check subregions
|
||||
for (const region of REGIONS) {
|
||||
if (region.includes.find((s) => s.id === regionId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -23,7 +23,8 @@ export default db;
|
||||
export const primaryDb = db;
|
||||
export type Transaction = Parameters<
|
||||
Parameters<(typeof db)["transaction"]>[0]
|
||||
>[0];
|
||||
>[0];
|
||||
export const DB_TYPE: "pg" | "sqlite" = "sqlite";
|
||||
|
||||
function checkFileExists(filePath: string): boolean {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from "./driver";
|
||||
export * from "./logsDriver";
|
||||
export * from "./safeRead";
|
||||
export * from "./schema/schema";
|
||||
export * from "./schema/privateSchema";
|
||||
export * from "./migrate";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { db as mainDb } from "./driver";
|
||||
|
||||
// SQLite doesn't support separate databases for logs in the same way as Postgres
|
||||
// Always use the main database connection for SQLite
|
||||
export const logsDb = mainDb;
|
||||
export default logsDb;
|
||||
export const primaryLogsDb = logsDb;
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
|
||||
const migrationsFolder = path.join("server/migrations");
|
||||
|
||||
const runMigrations = async () => {
|
||||
export const runMigrations = async () => {
|
||||
console.log("Running migrations...");
|
||||
try {
|
||||
migrate(db as any, {
|
||||
@@ -16,5 +16,3 @@ const runMigrations = async () => {
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { db } from "./driver";
|
||||
|
||||
/**
|
||||
* Runs a read query. For SQLite there is no replica/primary distinction,
|
||||
* so the query is executed once against the database.
|
||||
*/
|
||||
export async function safeRead<T>(
|
||||
query: (d: typeof db) => Promise<T>
|
||||
): Promise<T> {
|
||||
return query(db);
|
||||
}
|
||||
@@ -2,11 +2,22 @@ import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
real,
|
||||
sqliteTable,
|
||||
text
|
||||
text,
|
||||
uniqueIndex
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||
import {
|
||||
clients,
|
||||
domains,
|
||||
exitNodes,
|
||||
orgs,
|
||||
sessions,
|
||||
siteResources,
|
||||
sites,
|
||||
users
|
||||
} from "./schema";
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
@@ -70,13 +81,16 @@ export const subscriptions = sqliteTable("subscriptions", {
|
||||
canceledAt: integer("canceledAt"),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt"),
|
||||
billingCycleAnchor: integer("billingCycleAnchor")
|
||||
version: integer("version"),
|
||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||
type: text("type") // tier1, tier2, tier3, or license
|
||||
});
|
||||
|
||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||
subscriptionItemId: integer("subscriptionItemId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
stripeSubscriptionItemId: text("stripeSubscriptionItemId"),
|
||||
subscriptionId: text("subscriptionId")
|
||||
.notNull()
|
||||
.references(() => subscriptions.subscriptionId, {
|
||||
@@ -84,6 +98,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||
}),
|
||||
planId: text("planId").notNull(),
|
||||
priceId: text("priceId"),
|
||||
featureId: text("featureId"),
|
||||
meterId: text("meterId"),
|
||||
unitAmount: real("unitAmount"),
|
||||
tiers: text("tiers"),
|
||||
@@ -126,6 +141,7 @@ export const limits = sqliteTable("limits", {
|
||||
})
|
||||
.notNull(),
|
||||
value: real("value"),
|
||||
override: integer("override", { mode: "boolean" }).default(false),
|
||||
description: text("description")
|
||||
});
|
||||
|
||||
@@ -206,7 +222,7 @@ export const loginPageBranding = sqliteTable("loginPageBranding", {
|
||||
loginPageBrandingId: integer("loginPageBrandingId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
logoUrl: text("logoUrl").notNull(),
|
||||
logoUrl: text("logoUrl"),
|
||||
logoWidth: integer("logoWidth").notNull(),
|
||||
logoHeight: integer("logoHeight").notNull(),
|
||||
primaryColor: text("primaryColor"),
|
||||
@@ -273,6 +289,7 @@ export const accessAuditLog = sqliteTable(
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
type: text("type").notNull(),
|
||||
@@ -289,6 +306,156 @@ export const accessAuditLog = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const connectionAuditLog = sqliteTable(
|
||||
"connectionAuditLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: text("sessionId").notNull(),
|
||||
siteResourceId: integer("siteResourceId").references(
|
||||
() => siteResources.siteResourceId,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceAddr: text("sourceAddr").notNull(),
|
||||
destAddr: text("destAddr").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
startedAt: integer("startedAt").notNull(),
|
||||
endedAt: integer("endedAt"),
|
||||
bytesTx: integer("bytesTx"),
|
||||
bytesRx: integer("bytesRx")
|
||||
},
|
||||
(table) => [
|
||||
index("idx_accessAuditLog_startedAt").on(table.startedAt),
|
||||
index("idx_accessAuditLog_org_startedAt").on(
|
||||
table.orgId,
|
||||
table.startedAt
|
||||
),
|
||||
index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId)
|
||||
]
|
||||
);
|
||||
|
||||
export const approvals = sqliteTable("approvals", {
|
||||
approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}), // olms reference user devices clients
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
decision: text("decision")
|
||||
.$type<"approved" | "denied" | "pending">()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
type: text("type")
|
||||
.$type<"user_device" /*| 'proxy' // for later */>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const bannedEmails = sqliteTable("bannedEmails", {
|
||||
email: text("email").primaryKey()
|
||||
});
|
||||
|
||||
export const bannedIps = sqliteTable("bannedIps", {
|
||||
ip: text("ip").primaryKey()
|
||||
});
|
||||
|
||||
export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", {
|
||||
siteProvisioningKeyId: text("siteProvisioningKeyId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(),
|
||||
lastChars: text("lastChars").notNull(),
|
||||
createdAt: text("dateCreated").notNull(),
|
||||
lastUsed: text("lastUsed"),
|
||||
maxBatchSize: integer("maxBatchSize"), // null = no limit
|
||||
numUsed: integer("numUsed").notNull().default(0),
|
||||
validUntil: text("validUntil"),
|
||||
approveNewSites: integer("approveNewSites", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
});
|
||||
|
||||
export const siteProvisioningKeyOrg = sqliteTable(
|
||||
"siteProvisioningKeyOrg",
|
||||
{
|
||||
siteProvisioningKeyId: text("siteProvisioningKeyId")
|
||||
.notNull()
|
||||
.references(() => siteProvisioningKeys.siteProvisioningKeyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.siteProvisioningKeyId, table.orgId]
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
export const eventStreamingDestinations = sqliteTable(
|
||||
"eventStreamingDestinations",
|
||||
{
|
||||
destinationId: integer("destinationId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false),
|
||||
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false),
|
||||
sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false),
|
||||
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false),
|
||||
type: text("type").notNull(), // e.g. "http", "kafka", etc.
|
||||
config: text("config").notNull(), // JSON string with the configuration for the destination
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
}
|
||||
);
|
||||
|
||||
export const eventStreamingCursors = sqliteTable(
|
||||
"eventStreamingCursors",
|
||||
{
|
||||
cursorId: integer("cursorId").primaryKey({ autoIncrement: true }),
|
||||
destinationId: integer("destinationId")
|
||||
.notNull()
|
||||
.references(() => eventStreamingDestinations.destinationId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
logType: text("logType").notNull(), // "request" | "action" | "access" | "connection"
|
||||
lastSentId: integer("lastSentId").notNull().default(0),
|
||||
lastSentAt: integer("lastSentAt") // epoch milliseconds, null if never sent
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_eventStreamingCursors_dest_type").on(
|
||||
table.destinationId,
|
||||
table.logType
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
@@ -309,3 +476,13 @@ export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||
export type ConnectionAuditLog = InferSelectModel<typeof connectionAuditLog>;
|
||||
export type BannedEmail = InferSelectModel<typeof bannedEmails>;
|
||||
export type BannedIp = InferSelectModel<typeof bannedIps>;
|
||||
export type SiteProvisioningKey = InferSelectModel<typeof siteProvisioningKeys>;
|
||||
export type EventStreamingDestination = InferSelectModel<
|
||||
typeof eventStreamingDestinations
|
||||
>;
|
||||
export type EventStreamingCursor = InferSelectModel<
|
||||
typeof eventStreamingCursors
|
||||
>;
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
primaryKey,
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex
|
||||
unique
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { no } from "zod/v4/locales";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
baseDomain: text("baseDomain").notNull(),
|
||||
configManaged: integer("configManaged", {mode: "boolean"})
|
||||
configManaged: integer("configManaged", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
type: text("type"), // "ns", "cname", "wildcard"
|
||||
verified: integer("verified", {mode: "boolean"}).notNull().default(false),
|
||||
failed: integer("failed", {mode: "boolean"}).notNull().default(false),
|
||||
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
|
||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||
tries: integer("tries").notNull().default(0),
|
||||
certResolver: text("certResolver"),
|
||||
preferWildcardCert: integer("preferWildcardCert", {mode: "boolean"})
|
||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||
errorMessage: text("errorMessage")
|
||||
});
|
||||
|
||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||
id: integer("id").primaryKey({autoIncrement: true}),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, {onDelete: "cascade"}),
|
||||
.references(() => domains.domainId, { onDelete: "cascade" }),
|
||||
|
||||
recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT"
|
||||
baseDomain: text("baseDomain"),
|
||||
@@ -41,7 +42,7 @@ export const orgs = sqliteTable("orgs", {
|
||||
subnet: text("subnet"),
|
||||
utilitySubnet: text("utilitySubnet"), // this is the subnet for utility addresses
|
||||
createdAt: text("createdAt"),
|
||||
requireTwoFactor: integer("requireTwoFactor", {mode: "boolean"}),
|
||||
requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }),
|
||||
maxSessionLengthHours: integer("maxSessionLengthHours"), // hours
|
||||
passwordExpiryDays: integer("passwordExpiryDays"), // days
|
||||
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
@@ -52,29 +53,36 @@ export const orgs = sqliteTable("orgs", {
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0)
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
|
||||
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
|
||||
billingOrgId: text("billingOrgId")
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, {onDelete: "cascade"})
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const orgDomains = sqliteTable("orgDomains", {
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"}),
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, {onDelete: "cascade"})
|
||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const sites = sqliteTable("sites", {
|
||||
siteId: integer("siteId").primaryKey({autoIncrement: true}),
|
||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
@@ -91,7 +99,8 @@ export const sites = sqliteTable("sites", {
|
||||
megabytesOut: integer("bytesOut").default(0),
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
type: text("type").notNull(), // "newt" or "wireguard"
|
||||
online: integer("online", {mode: "boolean"}).notNull().default(false),
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
|
||||
// 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
|
||||
@@ -99,14 +108,15 @@ export const sites = sqliteTable("sites", {
|
||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", {mode: "boolean"})
|
||||
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true)
|
||||
.default(true),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({autoIncrement: true}),
|
||||
resourceGuid: text("resourceGuid", {length: 36})
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
.$defaultFn(() => randomUUID()),
|
||||
@@ -122,35 +132,39 @@ export const resources = sqliteTable("resources", {
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
ssl: integer("ssl", {mode: "boolean"}).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", {mode: "boolean"})
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", {mode: "boolean"}).notNull().default(true),
|
||||
http: integer("http", {mode: "boolean"}).notNull().default(true),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
http: integer("http", { mode: "boolean" }).notNull().default(true),
|
||||
protocol: text("protocol").notNull(),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {mode: "boolean"})
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: integer("applyRules", {mode: "boolean"})
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
enabled: integer("enabled", {mode: "boolean"}).notNull().default(true),
|
||||
stickySession: integer("stickySession", {mode: "boolean"})
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tlsServerName: text("tlsServerName"),
|
||||
setHostHeader: text("setHostHeader"),
|
||||
enableProxy: integer("enableProxy", {mode: "boolean"}).default(true),
|
||||
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
|
||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||
|
||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", { mode: "boolean" })
|
||||
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
maintenanceModeType: text("maintenanceModeType", {
|
||||
@@ -159,11 +173,11 @@ export const resources = sqliteTable("resources", {
|
||||
maintenanceTitle: text("maintenanceTitle"),
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
|
||||
postAuthPath: text("postAuthPath")
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
targetId: integer("targetId").primaryKey({autoIncrement: true}),
|
||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
@@ -178,7 +192,7 @@ export const targets = sqliteTable("targets", {
|
||||
method: text("method"),
|
||||
port: integer("port").notNull(),
|
||||
internalPort: integer("internalPort"),
|
||||
enabled: integer("enabled", {mode: "boolean"}).notNull().default(true),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
path: text("path"),
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
@@ -192,8 +206,8 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
}),
|
||||
targetId: integer("targetId")
|
||||
.notNull()
|
||||
.references(() => targets.targetId, {onDelete: "cascade"}),
|
||||
hcEnabled: integer("hcEnabled", {mode: "boolean"})
|
||||
.references(() => targets.targetId, { onDelete: "cascade" }),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
hcPath: text("hcPath"),
|
||||
@@ -210,12 +224,14 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
}).default(true),
|
||||
hcMethod: text("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcHealth: text("hcHealth")
|
||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||
hcTlsServerName: text("hcTlsServerName")
|
||||
});
|
||||
|
||||
export const exitNodes = sqliteTable("exitNodes", {
|
||||
exitNodeId: integer("exitNodeId").primaryKey({autoIncrement: true}),
|
||||
exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
address: text("address").notNull(), // this is the address of the wireguard interface in gerbil
|
||||
endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
@@ -223,7 +239,7 @@ export const exitNodes = sqliteTable("exitNodes", {
|
||||
listenPort: integer("listenPort").notNull(),
|
||||
reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control
|
||||
maxConnections: integer("maxConnections"),
|
||||
online: integer("online", {mode: "boolean"}).notNull().default(false),
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").default("gerbil"), // gerbil, remoteExitNode
|
||||
region: text("region")
|
||||
@@ -236,13 +252,13 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {onDelete: "cascade"}),
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"}),
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: text("protocol"), // only for port mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
@@ -250,9 +266,15 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alias: text("alias"),
|
||||
aliasAddress: text("aliasAddress"),
|
||||
tcpPortRangeString: text("tcpPortRangeString"),
|
||||
udpPortRangeString: text("udpPortRangeString"),
|
||||
tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"),
|
||||
udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
|
||||
disableIcmp: integer("disableIcmp", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
});
|
||||
|
||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||
@@ -292,23 +314,27 @@ export const users = sqliteTable("user", {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
passwordHash: text("passwordHash"),
|
||||
twoFactorEnabled: integer("twoFactorEnabled", {mode: "boolean"})
|
||||
twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
twoFactorSetupRequested: integer("twoFactorSetupRequested", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
twoFactorSecret: text("twoFactorSecret"),
|
||||
emailVerified: integer("emailVerified", {mode: "boolean"})
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
||||
termsVersion: text("termsVersion"),
|
||||
serverAdmin: integer("serverAdmin", {mode: "boolean"})
|
||||
marketingEmailConsent: integer("marketingEmailConsent", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
lastPasswordChange: integer("lastPasswordChange")
|
||||
lastPasswordChange: integer("lastPasswordChange"),
|
||||
locale: text("locale")
|
||||
});
|
||||
|
||||
export const securityKeys = sqliteTable("webauthnCredentials", {
|
||||
@@ -339,7 +365,7 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", {
|
||||
export const setupTokens = sqliteTable("setupTokens", {
|
||||
tokenId: text("tokenId").primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
used: integer("used", {mode: "boolean"}).notNull().default(false),
|
||||
used: integer("used", { mode: "boolean" }).notNull().default(false),
|
||||
dateCreated: text("dateCreated").notNull(),
|
||||
dateUsed: text("dateUsed")
|
||||
});
|
||||
@@ -378,9 +404,14 @@ export const clients = sqliteTable("clients", {
|
||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||
lastPing: integer("lastPing"),
|
||||
type: text("type").notNull(), // "olm"
|
||||
online: integer("online", {mode: "boolean"}).notNull().default(false),
|
||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||
// endpoint: text("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch")
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
|
||||
approvalState: text("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
|
||||
export const clientSitesAssociationsCache = sqliteTable(
|
||||
@@ -392,6 +423,9 @@ export const clientSitesAssociationsCache = sqliteTable(
|
||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isJitMode: integer("isJitMode", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
endpoint: text("endpoint"),
|
||||
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||
}
|
||||
@@ -420,14 +454,167 @@ export const olms = sqliteTable("olms", {
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const currentFingerprint = sqliteTable("currentFingerprint", {
|
||||
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
olmId: text("olmId")
|
||||
.references(() => olms.olmId, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
|
||||
firstSeen: integer("firstSeen").notNull(),
|
||||
lastSeen: integer("lastSeen").notNull(),
|
||||
lastCollectedAt: integer("lastCollectedAt").notNull(),
|
||||
|
||||
username: text("username"),
|
||||
hostname: text("hostname"),
|
||||
platform: text("platform"),
|
||||
osVersion: text("osVersion"),
|
||||
kernelVersion: text("kernelVersion"),
|
||||
arch: text("arch"),
|
||||
deviceModel: text("deviceModel"),
|
||||
serialNumber: text("serialNumber"),
|
||||
platformFingerprint: text("platformFingerprint"),
|
||||
|
||||
// Platform-agnostic checks
|
||||
|
||||
biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
diskEncrypted: integer("diskEncrypted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
firewallEnabled: integer("firewallEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tpmAvailable: integer("tpmAvailable", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// macOS-specific posture check information
|
||||
|
||||
macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosGatekeeperEnabled: integer("macosGatekeeperEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosFirewallStealthMode: integer("macosFirewallStealthMode", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Linux-specific posture check information
|
||||
|
||||
linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
linuxSELinuxEnabled: integer("linuxSELinuxEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
|
||||
export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
|
||||
snapshotId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
fingerprintId: integer("fingerprintId").references(
|
||||
() => currentFingerprint.fingerprintId,
|
||||
{
|
||||
onDelete: "set null"
|
||||
}
|
||||
),
|
||||
|
||||
username: text("username"),
|
||||
hostname: text("hostname"),
|
||||
platform: text("platform"),
|
||||
osVersion: text("osVersion"),
|
||||
kernelVersion: text("kernelVersion"),
|
||||
arch: text("arch"),
|
||||
deviceModel: text("deviceModel"),
|
||||
serialNumber: text("serialNumber"),
|
||||
platformFingerprint: text("platformFingerprint"),
|
||||
|
||||
// Platform-agnostic checks
|
||||
|
||||
biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
diskEncrypted: integer("diskEncrypted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
firewallEnabled: integer("firewallEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
tpmAvailable: integer("tpmAvailable", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Windows-specific posture check information
|
||||
|
||||
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// macOS-specific posture check information
|
||||
|
||||
macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosGatekeeperEnabled: integer("macosGatekeeperEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
macosFirewallStealthMode: integer("macosFirewallStealthMode", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Linux-specific posture check information
|
||||
|
||||
linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
linuxSELinuxEnabled: integer("linuxSELinuxEnabled", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
hash: text("hash").notNull(),
|
||||
collectedAt: integer("collectedAt").notNull()
|
||||
});
|
||||
|
||||
export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
|
||||
codeId: integer("id").primaryKey({autoIncrement: true}),
|
||||
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
codeHash: text("codeHash").notNull()
|
||||
});
|
||||
|
||||
@@ -435,7 +622,7 @@ export const sessions = sqliteTable("session", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
issuedAt: integer("issuedAt"),
|
||||
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
|
||||
@@ -447,7 +634,7 @@ export const newtSessions = sqliteTable("newtSession", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
newtId: text("newtId")
|
||||
.notNull()
|
||||
.references(() => newts.newtId, {onDelete: "cascade"}),
|
||||
.references(() => newts.newtId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
@@ -455,44 +642,42 @@ export const olmSessions = sqliteTable("clientSession", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
olmId: text("olmId")
|
||||
.notNull()
|
||||
.references(() => olms.olmId, {onDelete: "cascade"}),
|
||||
.references(() => olms.olmId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
export const userOrgs = sqliteTable("userOrgs", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId),
|
||||
isOwner: integer("isOwner", {mode: "boolean"}).notNull().default(false),
|
||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||
autoProvisioned: integer("autoProvisioned", {
|
||||
mode: "boolean"
|
||||
}).default(false)
|
||||
}).default(false),
|
||||
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||
});
|
||||
|
||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||
codeId: integer("id").primaryKey({autoIncrement: true}),
|
||||
codeId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
code: text("code").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
|
||||
tokenId: integer("id").primaryKey({autoIncrement: true}),
|
||||
tokenId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
@@ -504,115 +689,150 @@ export const actions = sqliteTable("actions", {
|
||||
});
|
||||
|
||||
export const roles = sqliteTable("roles", {
|
||||
roleId: integer("roleId").primaryKey({autoIncrement: true}),
|
||||
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
isAdmin: integer("isAdmin", {mode: "boolean"}),
|
||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description")
|
||||
description: text("description"),
|
||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||
mode: "boolean"
|
||||
}).default(false),
|
||||
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
|
||||
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
|
||||
true
|
||||
),
|
||||
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||
});
|
||||
|
||||
export const userOrgRoles = sqliteTable(
|
||||
"userOrgRoles",
|
||||
{
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||
);
|
||||
|
||||
export const roleActions = sqliteTable("roleActions", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, {onDelete: "cascade"}),
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
actionId: text("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, {onDelete: "cascade"}),
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"})
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userActions = sqliteTable("userActions", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
actionId: text("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, {onDelete: "cascade"}),
|
||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"})
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleSites = sqliteTable("roleSites", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, {onDelete: "cascade"}),
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {onDelete: "cascade"})
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userSites = sqliteTable("userSites", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {onDelete: "cascade"})
|
||||
.references(() => sites.siteId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userClients = sqliteTable("userClients", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
.references(() => clients.clientId, {onDelete: "cascade"})
|
||||
.references(() => clients.clientId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleClients = sqliteTable("roleClients", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, {onDelete: "cascade"}),
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
.references(() => clients.clientId, {onDelete: "cascade"})
|
||||
.references(() => clients.clientId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const roleResources = sqliteTable("roleResources", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, {onDelete: "cascade"}),
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"})
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userResources = sqliteTable("userResources", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, {onDelete: "cascade"}),
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"})
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const userInvites = sqliteTable("userInvites", {
|
||||
inviteId: text("inviteId").primaryKey(),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"}),
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
tokenHash: text("token").notNull(),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, {onDelete: "cascade"})
|
||||
tokenHash: text("token").notNull()
|
||||
});
|
||||
|
||||
export const userInviteRoles = sqliteTable(
|
||||
"userInviteRoles",
|
||||
{
|
||||
inviteId: text("inviteId")
|
||||
.notNull()
|
||||
.references(() => userInvites.inviteId, { onDelete: "cascade" }),
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" })
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.inviteId, t.roleId] })]
|
||||
);
|
||||
|
||||
export const resourcePincode = sqliteTable("resourcePincode", {
|
||||
pincodeId: integer("pincodeId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
pincodeHash: text("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull()
|
||||
});
|
||||
@@ -623,7 +843,7 @@ export const resourcePassword = sqliteTable("resourcePassword", {
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
passwordHash: text("passwordHash").notNull()
|
||||
});
|
||||
|
||||
@@ -633,28 +853,38 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
headerAuthHash: text("headerAuthHash").notNull()
|
||||
});
|
||||
|
||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable("resourceHeaderAuthExtendedCompatibility", {
|
||||
headerAuthExtendedCompatibilityId: integer("headerAuthExtendedCompatibilityId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
extendedCompatibilityIsActivated: integer("extendedCompatibilityIsActivated", {mode: "boolean"}).notNull().default(true)
|
||||
});
|
||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||
"resourceHeaderAuthExtendedCompatibility",
|
||||
{
|
||||
headerAuthExtendedCompatibilityId: integer(
|
||||
"headerAuthExtendedCompatibilityId"
|
||||
).primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
extendedCompatibilityIsActivated: integer(
|
||||
"extendedCompatibilityIsActivated",
|
||||
{ mode: "boolean" }
|
||||
)
|
||||
.notNull()
|
||||
.default(true)
|
||||
}
|
||||
);
|
||||
|
||||
export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
||||
accessTokenId: text("accessTokenId").primaryKey(),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"}),
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
sessionLength: integer("sessionLength").notNull(),
|
||||
expiresAt: integer("expiresAt"),
|
||||
@@ -667,13 +897,13 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||
sessionId: text("id").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
expiresAt: integer("expiresAt").notNull(),
|
||||
sessionLength: integer("sessionLength").notNull(),
|
||||
doNotExtend: integer("doNotExtend", {mode: "boolean"})
|
||||
doNotExtend: integer("doNotExtend", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
isRequestToken: integer("isRequestToken", {mode: "boolean"}),
|
||||
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
|
||||
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
@@ -705,11 +935,11 @@ export const resourceSessions = sqliteTable("resourceSessions", {
|
||||
});
|
||||
|
||||
export const resourceWhitelist = sqliteTable("resourceWhitelist", {
|
||||
whitelistId: integer("id").primaryKey({autoIncrement: true}),
|
||||
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"})
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const resourceOtp = sqliteTable("resourceOtp", {
|
||||
@@ -718,7 +948,7 @@ export const resourceOtp = sqliteTable("resourceOtp", {
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
otpHash: text("otpHash").notNull(),
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
@@ -730,11 +960,11 @@ export const versionMigrations = sqliteTable("versionMigrations", {
|
||||
});
|
||||
|
||||
export const resourceRules = sqliteTable("resourceRules", {
|
||||
ruleId: integer("ruleId").primaryKey({autoIncrement: true}),
|
||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||
resourceId: integer("resourceId")
|
||||
.notNull()
|
||||
.references(() => resources.resourceId, {onDelete: "cascade"}),
|
||||
enabled: integer("enabled", {mode: "boolean"}).notNull().default(true),
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||
match: text("match").notNull(), // CIDR, PATH, IP
|
||||
@@ -742,17 +972,17 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
});
|
||||
|
||||
export const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({autoIncrement: true}),
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
githubUsername: text("githubUsername").notNull(),
|
||||
phrase: text("phrase"),
|
||||
tier: text("tier"),
|
||||
valid: integer("valid", {mode: "boolean"}).notNull().default(false)
|
||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
// Identity Providers
|
||||
export const idp = sqliteTable("idp", {
|
||||
idpId: integer("idpId").primaryKey({autoIncrement: true}),
|
||||
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
defaultRoleMapping: text("defaultRoleMapping"),
|
||||
@@ -761,7 +991,8 @@ export const idp = sqliteTable("idp", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(false)
|
||||
.default(false),
|
||||
tags: text("tags")
|
||||
});
|
||||
|
||||
// Identity Provider OAuth Configuration
|
||||
@@ -772,7 +1003,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||
variant: text("variant").notNull().default("oidc"),
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, {onDelete: "cascade"}),
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
clientId: text("clientId").notNull(),
|
||||
clientSecret: text("clientSecret").notNull(),
|
||||
authUrl: text("authUrl").notNull(),
|
||||
@@ -800,22 +1031,22 @@ export const apiKeys = sqliteTable("apiKeys", {
|
||||
apiKeyHash: text("apiKeyHash").notNull(),
|
||||
lastChars: text("lastChars").notNull(),
|
||||
createdAt: text("dateCreated").notNull(),
|
||||
isRoot: integer("isRoot", {mode: "boolean"}).notNull().default(false)
|
||||
isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const apiKeyActions = sqliteTable("apiKeyActions", {
|
||||
apiKeyId: text("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, {onDelete: "cascade"}),
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
actionId: text("actionId")
|
||||
.notNull()
|
||||
.references(() => actions.actionId, {onDelete: "cascade"})
|
||||
.references(() => actions.actionId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
||||
apiKeyId: text("apiKeyId")
|
||||
.notNull()
|
||||
.references(() => apiKeys.apiKeyId, {onDelete: "cascade"}),
|
||||
.references(() => apiKeys.apiKeyId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
@@ -826,10 +1057,10 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
|
||||
export const idpOrg = sqliteTable("idpOrg", {
|
||||
idpId: integer("idpId")
|
||||
.notNull()
|
||||
.references(() => idp.idpId, {onDelete: "cascade"}),
|
||||
.references(() => idp.idpId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, {onDelete: "cascade"}),
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
roleMapping: text("roleMapping"),
|
||||
orgMapping: text("orgMapping")
|
||||
});
|
||||
@@ -847,19 +1078,19 @@ export const blueprints = sqliteTable("blueprints", {
|
||||
name: text("name").notNull(),
|
||||
source: text("source").notNull(),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
succeeded: integer("succeeded", {mode: "boolean"}).notNull(),
|
||||
succeeded: integer("succeeded", { mode: "boolean" }).notNull(),
|
||||
contents: text("contents").notNull(),
|
||||
message: text("message")
|
||||
});
|
||||
export const requestAuditLog = sqliteTable(
|
||||
"requestAuditLog",
|
||||
{
|
||||
id: integer("id").primaryKey({autoIncrement: true}),
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
action: integer("action", {mode: "boolean"}).notNull(),
|
||||
action: integer("action", { mode: "boolean" }).notNull(),
|
||||
reason: integer("reason").notNull(),
|
||||
actorType: text("actorType"),
|
||||
actor: text("actor"),
|
||||
@@ -876,7 +1107,7 @@ export const requestAuditLog = sqliteTable(
|
||||
host: text("host"),
|
||||
path: text("path"),
|
||||
method: text("method"),
|
||||
tls: integer("tls", {mode: "boolean"})
|
||||
tls: integer("tls", { mode: "boolean" })
|
||||
},
|
||||
(table) => [
|
||||
index("idx_requestAuditLog_timestamp").on(table.timestamp),
|
||||
@@ -902,6 +1133,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
||||
})
|
||||
});
|
||||
|
||||
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||
wsClientId: text("clientId"),
|
||||
messageType: text("messageType"),
|
||||
sentAt: integer("sentAt").notNull(),
|
||||
receivedAt: integer("receivedAt"),
|
||||
error: text("error"),
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -927,12 +1168,16 @@ export type UserSite = InferSelectModel<typeof userSites>;
|
||||
export type RoleResource = InferSelectModel<typeof roleResources>;
|
||||
export type UserResource = InferSelectModel<typeof userResources>;
|
||||
export type UserInvite = InferSelectModel<typeof userInvites>;
|
||||
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
|
||||
export type UserOrg = InferSelectModel<typeof userOrgs>;
|
||||
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
|
||||
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
|
||||
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
|
||||
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
|
||||
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
|
||||
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
|
||||
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<
|
||||
typeof resourceHeaderAuthExtendedCompatibility
|
||||
>;
|
||||
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
|
||||
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
|
||||
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
@@ -961,3 +1206,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from "react";
|
||||
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||
import { themeColors } from "./lib/theme";
|
||||
import {
|
||||
EmailContainer,
|
||||
EmailFooter,
|
||||
EmailGreeting,
|
||||
EmailHeading,
|
||||
EmailInfoSection,
|
||||
EmailLetterHead,
|
||||
EmailSection,
|
||||
EmailSignature,
|
||||
EmailText
|
||||
} from "./components/Email";
|
||||
import CopyCodeBox from "./components/CopyCodeBox";
|
||||
import ButtonLink from "./components/ButtonLink";
|
||||
|
||||
type EnterpriseEditionKeyGeneratedProps = {
|
||||
keyValue: string;
|
||||
personalUseOnly: boolean;
|
||||
users: number;
|
||||
sites: number;
|
||||
modifySubscriptionLink?: string;
|
||||
};
|
||||
|
||||
export const EnterpriseEditionKeyGenerated = ({
|
||||
keyValue,
|
||||
personalUseOnly,
|
||||
users,
|
||||
sites,
|
||||
modifySubscriptionLink
|
||||
}: EnterpriseEditionKeyGeneratedProps) => {
|
||||
const previewText = personalUseOnly
|
||||
? "Your Enterprise Edition key for personal use is ready"
|
||||
: "Thank you for your purchase — your Enterprise Edition key is ready";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={themeColors}>
|
||||
<Body className="font-sans bg-gray-50">
|
||||
<EmailContainer>
|
||||
<EmailLetterHead />
|
||||
|
||||
<EmailGreeting>Hi there,</EmailGreeting>
|
||||
|
||||
{personalUseOnly ? (
|
||||
<EmailText>
|
||||
Your Enterprise Edition license key has been
|
||||
generated. Qualifying users can use the
|
||||
Enterprise Edition for free for{" "}
|
||||
<strong>personal use only</strong>.
|
||||
</EmailText>
|
||||
) : (
|
||||
<>
|
||||
<EmailText>
|
||||
Thank you for your purchase. Your Enterprise
|
||||
Edition license key is ready. Below are the
|
||||
terms of your license.
|
||||
</EmailText>
|
||||
<EmailInfoSection
|
||||
title="License details"
|
||||
items={[
|
||||
{
|
||||
label: "Licensed users",
|
||||
value: users
|
||||
},
|
||||
{
|
||||
label: "Licensed sites",
|
||||
value: sites
|
||||
}
|
||||
]}
|
||||
/>
|
||||
{modifySubscriptionLink && (
|
||||
<EmailSection>
|
||||
<ButtonLink
|
||||
href={modifySubscriptionLink}
|
||||
>
|
||||
Modify subscription
|
||||
</ButtonLink>
|
||||
</EmailSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<EmailSection>
|
||||
<EmailText>Your license key:</EmailText>
|
||||
<CopyCodeBox
|
||||
text={keyValue}
|
||||
hint="Copy this key and use it when activating Enterprise Edition on your Pangolin host."
|
||||
/>
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you need to purchase additional license keys or
|
||||
modify your existing license, please reach out to
|
||||
our support team at{" "}
|
||||
<a
|
||||
href="mailto:support@pangolin.net"
|
||||
className="text-primary font-medium"
|
||||
>
|
||||
support@pangolin.net
|
||||
</a>
|
||||
.
|
||||
</EmailText>
|
||||
|
||||
<EmailFooter>
|
||||
<EmailSignature />
|
||||
</EmailFooter>
|
||||
</EmailContainer>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnterpriseEditionKeyGenerated;
|
||||