Compare commits
1640 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0caa17ef9 | |||
| 71348f45b2 | |||
| 664b7d52af | |||
| fb6f9c91c6 | |||
| 051c873fdf | |||
| 12224458e7 | |||
| b6719d2a3e | |||
| 7843b56f39 | |||
| 19be6c4b3b | |||
| 4a283710ad | |||
| 1af7c5ee73 | |||
| 77dd47fa8a | |||
| ccfe91f08d | |||
| 624fb8322f | |||
| 6bc3242a29 | |||
| 33f528ef79 | |||
| 6c1afc4453 | |||
| 2a14447f8c | |||
| ad50f7473a | |||
| 5f1218f7b1 | |||
| e377afe9f6 | |||
| 21eb4d2876 | |||
| bb824a7070 | |||
| f98de98288 | |||
| 350de6ad2e | |||
| dc8e724482 | |||
| fb5b564c00 | |||
| 18270381c1 | |||
| b32dc30050 | |||
| 667804043c | |||
| 3fd3d90c10 | |||
| 6e3619fb1a | |||
| 987c0e992b | |||
| bdd7f40688 | |||
| 809bc662e0 | |||
| a74e1e765a | |||
| c356af3eda | |||
| a4c7121b93 | |||
| 2a3c00045f | |||
| 8e938a2723 | |||
| 197f8f7ba5 | |||
| 10b528642d | |||
| 60d6fff085 | |||
| c664b3da91 | |||
| 492282e758 | |||
| 47f4aefc25 | |||
| 7c0ff9ede7 | |||
| 44e81ea979 | |||
| 56dc10330a | |||
| 6c6e5c0fdf | |||
| 4be73558d5 | |||
| 87ff7650a1 | |||
| ae5af013b4 | |||
| 5adfe65aba | |||
| d3ae06deeb | |||
| ee4a72bcb7 | |||
| f941c23927 | |||
| 3ae6a08b71 | |||
| 620eeaed4c | |||
| 9b08228398 | |||
| eb102a0d23 | |||
| 18317e0028 | |||
| 2ec93c6710 | |||
| 5933eeb05f | |||
| 3fae2d687f | |||
| 285d7c6030 | |||
| 0fd57f5843 | |||
| a296460124 | |||
| 35104f7b29 | |||
| 51375ed8b7 | |||
| 1bb4f604e9 | |||
| 8fd990df93 | |||
| 1f390cabc6 | |||
| 7f1f8c54c1 | |||
| b1515db229 | |||
| d78c226734 | |||
| 52865bc1f5 | |||
| efbb6c9814 | |||
| 1e3dea3dad | |||
| 25f84482ba | |||
| 3f2a116fdb | |||
| dce002c6cc | |||
| dcfbbdc79d | |||
| f8cac37eef | |||
| cb37713c4c | |||
| 7ae4082d7e | |||
| aa8d339d35 | |||
| 7a61b182e0 | |||
| 91b697b80b | |||
| 7d10b63dc5 | |||
| 92916cd9db | |||
| 075d112861 | |||
| 7e9407fbc7 | |||
| 313f777e44 | |||
| 3ba9eb7ad7 | |||
| 328a2d52da | |||
| 697bea814e | |||
| 83cda218b7 | |||
| 76435440af | |||
| 0a8b5e46ca | |||
| a74b0934fc | |||
| c05d2aeafb | |||
| 87a3e5ceea | |||
| 555b36e13e | |||
| 021866011f | |||
| 749abdcb56 | |||
| 7d6f3a2925 | |||
| af515f1072 | |||
| c90c67eab3 | |||
| 51c9507d08 | |||
| 11daf4f927 | |||
| 58195b5959 | |||
| b51aecf45a | |||
| eb8ad6a181 | |||
| 4edd2e4d32 | |||
| 813c3abe54 | |||
| ca7ea72ff1 | |||
| 53b1d8a9f3 | |||
| a3dfb30a42 | |||
| 111d4b1b8c | |||
| 32d76ee2ac | |||
| 98ad6fb31b | |||
| 3a18a3994f | |||
| 6d35b20880 | |||
| 173d4a536f | |||
| ea740e12d1 | |||
| d7bff686c0 | |||
| 193f6da8d1 | |||
| 954dc8d1d9 | |||
| 234819f8f2 | |||
| b34a8d116b | |||
| 7375b9efae | |||
| c8f170d197 | |||
| 4989d1e31a | |||
| c568251d8d | |||
| db2de4af89 | |||
| 574ae8f5f9 | |||
| 125091d719 | |||
| 5d37f44241 | |||
| f0f5e9219b | |||
| ee75a09f8c | |||
| c49c6f5837 | |||
| 03118f1ede | |||
| 0ff5ea4f6a | |||
| 978fa8bfc6 | |||
| 36c7c5dc34 | |||
| 8f3628d4ec | |||
| fcd3c72cac | |||
| 3f0be4a39d | |||
| f9ff3a5715 | |||
| ed71c90d73 | |||
| e580c7db7c | |||
| 4a9b0117a2 | |||
| 9b10292e02 | |||
| d9a9ae14fd | |||
| db716f781e | |||
| 46f341f7fd | |||
| 9bb413bb1e | |||
| 2878d5690c | |||
| 1143404a65 | |||
| e92bb9043b | |||
| a2f5d830d6 | |||
| 77e3422e88 | |||
| 9a551c91e5 | |||
| c35ac493ed | |||
| e100645a00 | |||
| 96bfc66a94 | |||
| e21c9eec31 | |||
| a9f32102b5 | |||
| 7291628f86 | |||
| 5fbb205044 | |||
| 02ab24d01e | |||
| 3cfbd66a80 | |||
| 048e4fc73c | |||
| 860fa47b7c | |||
| 71d9d8f010 | |||
| cc58e28e54 | |||
| 653dd920ad | |||
| 49b4fcf063 | |||
| dd78c2cc08 | |||
| 47ae017f94 | |||
| 4b61e12ca6 | |||
| c6bd657ee6 | |||
| c33fa8782b | |||
| 93cba1d098 | |||
| d3a1f9798d | |||
| 115c3cbf07 | |||
| bed5817da3 | |||
| 83035753af | |||
| 379b53bcca | |||
| 355294a2d4 | |||
| 833b27ab4a | |||
| 8a04f13dd4 | |||
| 49020fa6ea | |||
| ac3402a8b3 | |||
| 9369e60695 | |||
| 295e38d2af | |||
| 923371e5b4 | |||
| 81430ba3d3 | |||
| 11595f5f96 | |||
| b08e875b37 | |||
| 0016b8fce7 | |||
| f5b10df7cf | |||
| 1782f31075 | |||
| 21032bc22b | |||
| e4aaadc9f9 | |||
| f7aca85417 | |||
| c42df737b0 | |||
| 75c6af3b11 | |||
| e734cc93a1 | |||
| e3ccc4f8d4 | |||
| 896c362dce | |||
| 864ab97293 | |||
| 899c47e9a3 | |||
| bc2f291352 | |||
| 98f5e39a7f | |||
| e0a66e79bb | |||
| c781be4e70 | |||
| 48c4b44f72 | |||
| 68a84181d9 | |||
| b60390d805 | |||
| b33e776072 | |||
| 2187f23588 | |||
| 732fd4eba1 | |||
| 495c772d6b | |||
| 02e4fe8b48 | |||
| 6d45486bb5 | |||
| 4c1f7f6243 | |||
| 1e3a9fb921 | |||
| 8b8e7913dc | |||
| 88515a7dad | |||
| e012063f5b | |||
| e76377d3c7 | |||
| a357f42c48 | |||
| 2becb15916 | |||
| 186eeed784 | |||
| 216c932cb9 | |||
| 52de5eb539 | |||
| eec3119297 | |||
| f2e7f83b3d | |||
| 93dea0525e | |||
| 7e6e0a8a9c | |||
| 211d3a53f5 | |||
| 187936e5dd | |||
| b180d064d1 | |||
| ed46afd81a | |||
| 0050fad70d | |||
| 3dc9c100e9 | |||
| 969e7c9296 | |||
| 591caab45a | |||
| 02e97d6ae4 | |||
| 996160fadc | |||
| dc1923ab1f | |||
| fe5831eb48 | |||
| ca79abc9d4 | |||
| 9eafa067b9 | |||
| 403b8a12e4 | |||
| 3f305e4d5c | |||
| 5e5e172d39 | |||
| bc7a883f6c | |||
| 12056aebc6 | |||
| 6689a8d93e | |||
| 297cb9c8f2 | |||
| d699455b38 | |||
| 9e7b4afaec | |||
| e91c344e64 | |||
| 07f628b928 | |||
| 184e1425a4 | |||
| 22f2990f56 | |||
| 6c28c5f383 | |||
| e9f9cf54f4 | |||
| 751d1b083d | |||
| a4d77a4fd3 | |||
| f72252552f | |||
| c5d68675c9 | |||
| d04740fede | |||
| 36b8ef5fba | |||
| 6cca5e0472 | |||
| 4048fa274a | |||
| b4d2974e19 | |||
| fb896d6c0f | |||
| 82b86263dc | |||
| 6564bfe8ae | |||
| 75ce7e91d7 | |||
| 656eea5bb1 | |||
| aba27a7bbf | |||
| 3d7e322bf9 | |||
| 1f3fff4a9d | |||
| 39e06f2b6d | |||
| b775c5b674 | |||
| 796bf37141 | |||
| c5b0e1e876 | |||
| 2e8bd7a8c7 | |||
| 790daba796 | |||
| bcf6b86b84 | |||
| 2e9bd50172 | |||
| 4677a0d501 | |||
| c673dce484 | |||
| e1dd8965dc | |||
| 346a0bdc98 | |||
| 8c1169738e | |||
| bc80f91a45 | |||
| 3d062389e9 | |||
| 7afddb5eb5 | |||
| 425a99e5ee | |||
| a5e9339af9 | |||
| b4463f0e1a | |||
| b7c0669c38 | |||
| 835a30cffe | |||
| 83e20c2dfa | |||
| f85d41945a | |||
| 149eb17b27 | |||
| 18b90da6ab | |||
| 72d469b19c | |||
| 6526d7f225 | |||
| 36c0edc62e | |||
| aad26b9ae4 | |||
| 80dcdfe251 | |||
| 1696fc37a8 | |||
| 7759d87835 | |||
| c085de1e9e | |||
| e99cd52e99 | |||
| e38359c74f | |||
| 2cfd7e867b | |||
| ec5a2b0cbe | |||
| 2bf426bc22 | |||
| ed8545f8a2 | |||
| 9811492a0b | |||
| 973925b35d | |||
| 0b30cfc341 | |||
| a7e44944fb | |||
| 9b25ebd6cd | |||
| 33b775e263 | |||
| b0edc6302e | |||
| 1a644b131d | |||
| f7689b7a5a | |||
| 1073011a2a | |||
| 6fa0009ebf | |||
| 42c0abedb7 | |||
| 56e59a93f3 | |||
| 9d581f3897 | |||
| e5ac6ec7cd | |||
| 694fe98131 | |||
| 730078597e | |||
| 32ac8db803 | |||
| aad2ed2719 | |||
| a790fed297 | |||
| deb2d5ce2a | |||
| 093097c619 | |||
| 2cdeb7c104 | |||
| 4dbb04bfb8 | |||
| fa8b921635 | |||
| 33dd10c670 | |||
| ba24e1c4f5 | |||
| f079714caf | |||
| 152d2fb1d6 | |||
| d374b4f66e | |||
| efd2792197 | |||
| efe22c889c | |||
| 7f2b3eb481 | |||
| 81be4a35d9 | |||
| e84da6a8df | |||
| 3ef3ede7df | |||
| 1e521b0b54 | |||
| 59ea701304 | |||
| 7d7c54107d | |||
| f0f6673d69 | |||
| 71561d0e65 | |||
| af87edf3a6 | |||
| 13caad18c7 | |||
| 47522b7e3a | |||
| c8c8d74452 | |||
| e7098963d6 | |||
| f015fb592b | |||
| c099167905 | |||
| b0e274f5a9 | |||
| 146c287aba | |||
| 95ee56217c | |||
| 7625cc208d | |||
| f2dfd939bc | |||
| 699bc8ddb4 | |||
| e658e007e1 | |||
| 3103265450 | |||
| 97789d9e2e | |||
| a1aa3d96d4 | |||
| ae39bc0ecd | |||
| e4d81aa610 | |||
| f59b524266 | |||
| 58c499acae | |||
| f22301a1eb | |||
| d6264fb39a | |||
| 08b5a9b34b | |||
| 263976bf41 | |||
| 0e4cd3a5ba | |||
| 192542629f | |||
| aeed271f3e | |||
| d41e9bbe68 | |||
| 904a5520b8 | |||
| 0f43ae4871 | |||
| bd0cf98319 | |||
| fc7d6bbaf9 | |||
| 1b89c328de | |||
| e7f147d4ca | |||
| 41dbc7d5ed | |||
| 9f297317f2 | |||
| e0a8721207 | |||
| a69b310109 | |||
| 864a6a39cd | |||
| 1882d4df88 | |||
| ef3f71e9b5 | |||
| 9981c4c035 | |||
| bc267b7107 | |||
| 5cbb767e5f | |||
| ac79621cae | |||
| f47c94d05b | |||
| 10773432bb | |||
| d5a56fb71d | |||
| b5518d029e | |||
| 9a9ae649ef | |||
| aa6dc67015 | |||
| 0da96b06ea | |||
| 6649f15498 | |||
| 49854f31a5 | |||
| 762c79511b | |||
| 6e29ebd649 | |||
| e128b8c282 | |||
| 832e382888 | |||
| 8a68b899f5 | |||
| 71f45b8a80 | |||
| ecf008a8d9 | |||
| b3880e5c02 | |||
| 58004a8ec9 | |||
| b0ff7d2707 | |||
| 262f7bd090 | |||
| b1558b09b1 | |||
| a33de8268b | |||
| c92d5096c4 | |||
| 9867d3c876 | |||
| 70bddba55b | |||
| 23181f4019 | |||
| 19c1c2042b | |||
| 9cc3190e3a | |||
| 72f179578b | |||
| 4c873e7c48 | |||
| 56fcb80b23 | |||
| 41c68148a9 | |||
| 5d38059b95 | |||
| df0198df9c | |||
| 26713b53f6 | |||
| 2bc6b28978 | |||
| f1ed4da8a4 | |||
| adeefb9dbd | |||
| bf1cc705a5 | |||
| 0b82dae01e | |||
| 75afe6ece2 | |||
| 70e2fe1e4e | |||
| 17e03457e1 | |||
| 4d8cb7e231 | |||
| 9561d23f1e | |||
| 0275f44056 | |||
| a2e1c7b751 | |||
| 9e2ec72ced | |||
| 02fe1f3abd | |||
| c30fe5b574 | |||
| a0b9ea76a3 | |||
| eb3a3eac98 | |||
| 02d2e09709 | |||
| 6e8283957c | |||
| bb9b94a983 | |||
| 36f807ddbc | |||
| cb31546c6b | |||
| 2d48adc9f9 | |||
| 113b7a0b84 | |||
| ca279ee3e0 | |||
| a5808200f0 | |||
| aa9de4d2ac | |||
| 50821e972d | |||
| e5b9dfee66 | |||
| 8e09070bc1 | |||
| 3d9c413bbb | |||
| 86670c1d60 | |||
| 5f2ee77a73 | |||
| 714e3a8fb1 | |||
| 672555f051 | |||
| 7853f2b096 | |||
| 61a7dda29a | |||
| f9bcb25eaa | |||
| 6df804ab32 | |||
| 9e50735800 | |||
| a30119f5e8 | |||
| 3c9f0946d2 | |||
| 21861a6dcd | |||
| 6135f2b727 | |||
| 3a616cc804 | |||
| d5eb67a8fc | |||
| 3ac7ef23ae | |||
| 08c5ec2be7 | |||
| a88b79e066 | |||
| a48ef77ee5 | |||
| 55f3807491 | |||
| dbfb8e83e8 | |||
| 515afc14a5 | |||
| 903e8c0fa1 | |||
| 7f9c760380 | |||
| 530a1a5350 | |||
| 3fb36c1434 | |||
| 5d20956a0e | |||
| 29f91e8276 | |||
| 4d9e38d022 | |||
| d7d31b78f0 | |||
| e8ed8fc7e9 | |||
| 5ee406c92e | |||
| 89f4f4ba6f | |||
| 4e5fd2f2b0 | |||
| 22a5743817 | |||
| 40e05f903a | |||
| 6c1e1bc0ca | |||
| cebbf9ca65 | |||
| e6646e5833 | |||
| 00f7510195 | |||
| 8d1f5e1096 | |||
| e497fcc3b9 | |||
| a7010b2788 | |||
| abf58a70ee | |||
| d4c602cf91 | |||
| d1b0bbb6d9 | |||
| ae0be3a4bc | |||
| a8f3f71021 | |||
| 933ca71c16 | |||
| 591cb9cdc1 | |||
| 34b18bdb53 | |||
| 7d475f5e91 | |||
| 39c35fa539 | |||
| bada1fdb97 | |||
| d9303f87c8 | |||
| e1bc0b7efd | |||
| 5ef068c8dc | |||
| 14680df160 | |||
| 94c01a23a9 | |||
| 609fb357bb | |||
| cf9a17cc2e | |||
| 538b57941c | |||
| f4bee6406a | |||
| 0b2693a317 | |||
| 1580b7abff | |||
| 3be2d928f6 | |||
| e5398d441e | |||
| d38f9ac2bb | |||
| 8d018fe47d | |||
| 34d5c9535d | |||
| dc60ef712f | |||
| dc7eb630c3 | |||
| d4138e2141 | |||
| bbcd352817 | |||
| 21d9199978 | |||
| 3e7b9d7bff | |||
| 54acdbe282 | |||
| 11bbd028fc | |||
| 7ae99731f4 | |||
| 17b4fd9a44 | |||
| caa398ae54 | |||
| 035e324e5a | |||
| 272a5737b0 | |||
| be50fd4586 | |||
| 8301511efe | |||
| c527ce6562 | |||
| 80bee900aa | |||
| 54209c2390 | |||
| 3b0ee8d9d4 | |||
| 7a0a877dc0 | |||
| b6ed762724 | |||
| d1d58cf455 | |||
| e22f30c694 | |||
| d71c112959 | |||
| 199e960f6d | |||
| 843b9a0b16 | |||
| b9aedc65fe | |||
| 11f5cfd4f8 | |||
| 2ff2e32e9c | |||
| facb1e4cdf | |||
| 6d129c0738 | |||
| 67a3d226f5 | |||
| 19a3773880 | |||
| 09d5d9082e | |||
| bb5547a157 | |||
| 8eab3b1ee2 | |||
| 30b46ebb6e | |||
| 91d5f89ab9 | |||
| 703050f949 | |||
| 97af17a993 | |||
| 09a8c457ac | |||
| 80317aca83 | |||
| bd10519598 | |||
| 15c7c47b3c | |||
| 0a7e8c3cc2 | |||
| fc2668fca8 | |||
| 8f2c20a2bc | |||
| 462ae7ee8c | |||
| 808abd2038 | |||
| 3de25e51ff | |||
| ef25a9dbe0 | |||
| e21c3afb13 | |||
| cb7962c25d | |||
| f667ee4feb | |||
| 810119e4ee | |||
| d082de3d88 | |||
| 1e7863ce4f | |||
| a942871f3d | |||
| 2fe85ebda2 | |||
| da1205d38e | |||
| 92775a51ca | |||
| ea60aa701e | |||
| b1f7ee02b0 | |||
| affa3bbb6a | |||
| dbb1e7b274 | |||
| b2d1a66279 | |||
| 202466792c | |||
| 4dcf684868 | |||
| 2b8f50af6f | |||
| a8c255ef71 | |||
| d9608bd408 | |||
| be193731c1 | |||
| 0c3edc7560 | |||
| 813a3f07dc | |||
| 417438c209 | |||
| beaa5735ce | |||
| 5cb316f4e9 | |||
| 83de8576bf | |||
| 6b6c9cf4d8 | |||
| e9d424eb80 | |||
| 05617c63c0 | |||
| 8cceed91cf | |||
| 633159fb77 | |||
| 4c8bd4db0a | |||
| d742300e82 | |||
| a521db91a2 | |||
| d4549f1c33 | |||
| 71da22328c | |||
| 0b5b732a48 | |||
| a82bae8f93 | |||
| e59176149b | |||
| 2c3151da9b | |||
| f60b8795ad | |||
| 4054976388 | |||
| 11c30b8b27 | |||
| 3d13e9105c | |||
| 289be30e6b | |||
| d4c52bbf2f | |||
| 390c822bb4 | |||
| a74c0c227c | |||
| 05bf77da29 | |||
| bb4deb1ae9 | |||
| 2bfc1901a6 | |||
| 5da186b528 | |||
| 600a96c13b | |||
| e4e0da3723 | |||
| 4087d7fb6b | |||
| 9edb86fd73 | |||
| 87f50bf0cc | |||
| 440ebfe08e | |||
| b399d2a291 | |||
| 2c66da1b19 | |||
| 811119a9a6 | |||
| b53f80d317 | |||
| 1b1fba60f1 | |||
| ab19955502 | |||
| 1db9dcec81 | |||
| a89509c8bd | |||
| f0546eb622 | |||
| 12f9ac94fd | |||
| 1717a99cee | |||
| b93d26f09f | |||
| fc54ad49b5 | |||
| f87e136f6b | |||
| 1bf3d2cdd6 | |||
| 5fc5a3ebca | |||
| 49c2d3163e | |||
| e40f325703 | |||
| 8f377a4fb2 | |||
| 45b9e13a13 | |||
| 3699f8f9cb | |||
| 5a2388a1e6 | |||
| 86e6ebc8af | |||
| 005f050a81 | |||
| c82678852e | |||
| 2e3ab10f5e | |||
| 4985ed02d3 | |||
| a2fea7f714 | |||
| ddba2aff21 | |||
| da9e668fb8 | |||
| 9c2d14d8c6 | |||
| c383e74df4 | |||
| 40101f8bb0 | |||
| b20ed9efa9 | |||
| 56266e3b62 | |||
| 47eff8c948 | |||
| 15f36096ef | |||
| 064252586d | |||
| 6181d46a1e | |||
| 9747731668 | |||
| 2a478eef6f | |||
| 4ab101f8a9 | |||
| e35878ee55 | |||
| 807613f28c | |||
| 663244fa3a | |||
| bcc128aeb6 | |||
| ba33cb9895 | |||
| 69e7fedcfc | |||
| 023110b341 | |||
| 7fb95e1726 | |||
| db0a7cc1ce | |||
| 61fc2e5ea7 | |||
| 0871a211ec | |||
| 5a1d5cb66e | |||
| 5a7ca5b542 | |||
| 87e1a509ce | |||
| 75f481bc3d | |||
| 97cdb2eb5a | |||
| 297fd2caf3 | |||
| 22dd4220fe | |||
| 108cb6216c | |||
| e3ef592778 | |||
| 3c37e10638 | |||
| 561f75b6b1 | |||
| e98bcb83ac | |||
| 80284863bb | |||
| bc759c5c9e | |||
| f4854a3a74 | |||
| 376dd465b3 | |||
| 296439fd67 | |||
| 31f675f38c | |||
| 9f68be2a9b | |||
| fed4ec42c4 | |||
| f0efa4203b | |||
| cfbbdedaf5 | |||
| 686789ee4c | |||
| 3fda190ff6 | |||
| 0033f40f4d | |||
| af95052706 | |||
| 9bb2d6cdc8 | |||
| 29563a13a4 | |||
| b41c1f5b27 | |||
| e5652cdb8a | |||
| 7c2ea153c5 | |||
| ccabddc225 | |||
| 42d98fa83b | |||
| 2f2b7f43c1 | |||
| 528bbeca26 | |||
| d60c15b0ae | |||
| ff89a64453 | |||
| 4718c489d3 | |||
| d5d99a4804 | |||
| 8d29602929 | |||
| 9c18936be7 | |||
| cf07cceb5d | |||
| faee9e6330 | |||
| 31725eb3cc | |||
| 04d4e298e8 | |||
| c9cc9581b1 | |||
| 6c2a8bf6de | |||
| 697be01411 | |||
| eac7c67dcc | |||
| 633d9031af | |||
| 05dc558c4a | |||
| 1bd6f240cc | |||
| 7506c0420d | |||
| 5572822c4a | |||
| ea3f1c341b | |||
| 35dffe71cb | |||
| 5428bf4ed0 | |||
| 9a89579e08 | |||
| 784588cebc | |||
| 2e628fe0e4 | |||
| 7590e8d8a1 | |||
| 053ff1e799 | |||
| 278375f7be | |||
| c5ffca499e | |||
| 65c383520b | |||
| 9ae54f445d | |||
| 8183d19400 | |||
| 7425daad3f | |||
| 39d61a35eb | |||
| f3fe11c136 | |||
| 66b1b385a3 | |||
| 822a07d48e | |||
| 3c13b1ea15 | |||
| fee635b861 | |||
| 7e4dea918a | |||
| 56187d61d5 | |||
| 36460d4cc0 | |||
| 88a9b92dc3 | |||
| dd26518d6f | |||
| 60339706bb | |||
| 4b1b3d3d5b | |||
| cf21bacd9c | |||
| e54bd25516 | |||
| 80d257b94b | |||
| e0d0c5dcbf | |||
| 0f02d1bc02 | |||
| f8591f27c5 | |||
| 877985deb3 | |||
| be3877a3ce | |||
| 79de64dc07 | |||
| d0defa380a | |||
| 4eba51de72 | |||
| a48032adb3 | |||
| 6fe4eee336 | |||
| 242123b875 | |||
| 2b38658ea6 | |||
| b18a41e4aa | |||
| d303fa05cb | |||
| 75b87ffba7 | |||
| 62fc2edae9 | |||
| 80b66cf9b9 | |||
| 034bcbd271 | |||
| bc63747efe | |||
| bb7729df00 | |||
| 2a8ceeec1b | |||
| 91ef0d0153 | |||
| e104489257 | |||
| b8101402cd | |||
| 7731849a2f | |||
| c11d24e10a | |||
| a9b7cce49b | |||
| d78223b94f | |||
| 963e9da7dd | |||
| 2cbc88fa05 | |||
| 65bad456cb | |||
| f48a4f7bc0 | |||
| ce3c2f7583 | |||
| 51c357e6c7 | |||
| 7ae29612d4 | |||
| da794adb7d | |||
| 8004ae6870 | |||
| 1bff7bbc2f | |||
| 50db5695fc | |||
| babd90ae71 | |||
| f7050ef989 | |||
| b2778a2c49 | |||
| 096940a152 | |||
| 73eb07de71 | |||
| 74ef844e27 | |||
| c58968536d | |||
| 228efacfe0 | |||
| 1262030abb | |||
| b07fe6d18b | |||
| 63c3ee623b | |||
| 18ec6c8d92 | |||
| d8acccbde4 | |||
| 37eaf34e4d | |||
| cfb63f9742 | |||
| c76b4555e1 | |||
| c25bfbad27 | |||
| 44782f8963 | |||
| e6f7cd6da9 | |||
| 19faa3a29c | |||
| c284dc2e83 | |||
| 1b634955d8 | |||
| be888c3fc1 | |||
| 3f2bb42221 | |||
| 5dc3ae4c7f | |||
| ffb6c64de0 | |||
| 2cbc6fb128 | |||
| 75084028d7 | |||
| f44a7c55dd | |||
| 72fa1d6a14 | |||
| c3820a4e70 | |||
| 6b56c00782 | |||
| 8f0e17774f | |||
| 60c1b572ba | |||
| 604dee9aa5 | |||
| ee42846c90 | |||
| 22ac711dc6 | |||
| d09668b20b | |||
| 16abe98fd9 | |||
| d240201361 | |||
| b7081aff11 | |||
| a55fb21e53 | |||
| e5e7b79712 | |||
| de48a0529e | |||
| 3f37408dae | |||
| a2882857ff | |||
| 476d92b3ac | |||
| bf604f25e9 | |||
| 34a0d2a68b | |||
| 62c7e0a13e | |||
| 753358a17d | |||
| c859393418 | |||
| d747b45f0b | |||
| a24091257a | |||
| 1c60041390 | |||
| 2ab5540085 | |||
| 95c3f74a33 | |||
| 4e7328a1cc | |||
| 6380079239 | |||
| b6aba13d3a | |||
| c0a9db92ef | |||
| 16c0f4eef4 | |||
| a08c6d70fe | |||
| a6568692b7 | |||
| a1196d3da6 | |||
| 70bc4c0b30 | |||
| a0fef89031 | |||
| ea1badf4e0 | |||
| f15654ed11 | |||
| 4435a669a6 | |||
| 0b41fe3d49 | |||
| 90eceb457a | |||
| f39cbc9bf4 | |||
| 50da863bb7 | |||
| c6ddd5c402 | |||
| 0fb5ace9c7 | |||
| cedccd8cdb | |||
| 3bc5ab2136 | |||
| ce77268c82 | |||
| abc0a41d9e | |||
| b9db0a4490 | |||
| 39f40e5160 | |||
| a68b57067c | |||
| 3fd5c98def | |||
| 5a8a48f9bf | |||
| 471ae98204 | |||
| d985bfd3a6 | |||
| aab51a999c | |||
| ae4f5aa58d | |||
| 70d5f55437 | |||
| 08df4b93aa | |||
| 61f0bc95c7 | |||
| 5b0f79a8bc | |||
| 86ff272095 | |||
| ef0575cc36 | |||
| 30a6889ba8 | |||
| ce43e717d5 | |||
| 1f0361e687 | |||
| 07ae57ea72 | |||
| fc982232d6 | |||
| afaf5f976e | |||
| a8ca28acb2 | |||
| b136bd2246 | |||
| d9952b0762 | |||
| 935593885a | |||
| 3fcfd3304f | |||
| 6e271028f3 | |||
| 820f66e58f | |||
| b0fdc10e06 | |||
| b82b41ed26 | |||
| 444d293a29 | |||
| 3e977ba00d | |||
| a724b07846 | |||
| 5f0bc71bcd | |||
| aea7827c1a | |||
| 7a275c86c2 | |||
| 4b703b5c11 | |||
| d865c4c55b | |||
| 5baf0c3c09 | |||
| 1b6e9e8cfe | |||
| cfe33eb974 | |||
| 71273e1b1c | |||
| 02f6e2a8c3 | |||
| 3cc244a1d3 | |||
| 1d9c4dd9e2 | |||
| b9dd0c8e43 | |||
| cd052976eb | |||
| cc498f0e33 | |||
| 1a942937e6 | |||
| d81d1a6b7f | |||
| f64d04e827 | |||
| 540aee3fe2 | |||
| 10542d7282 | |||
| b1d52ad1a3 | |||
| ce2fbef805 | |||
| e312b31e02 | |||
| bc156c715d | |||
| 9a4c1f23c6 | |||
| fe55956079 | |||
| 4cd0b9a0bb | |||
| ab4d567af9 | |||
| 6921447fab | |||
| d47449b082 | |||
| 665806dfe8 | |||
| e248571268 | |||
| fcf03854ff | |||
| dd1fba4e45 | |||
| a1ab8d8f35 | |||
| c789e967db | |||
| d870b9ff49 | |||
| 9c09019ddb | |||
| 9d88683fc5 | |||
| dd2c9f2a02 | |||
| bdb38db5bc | |||
| 96a54fc9cc | |||
| 3a485f74f1 | |||
| 92b0340324 | |||
| 9257ac01c7 | |||
| 4d1d0d9fcb | |||
| f186e7e99e | |||
| 1aa6e3511f | |||
| fb6f5b3953 | |||
| c85a7f6ac5 | |||
| dd54be523f | |||
| d57f064d4c | |||
| 34799b7de2 | |||
| 20a66bba6f | |||
| cdb43d9658 | |||
| 6581ccafa3 | |||
| a3a45b4239 | |||
| d6634b6e8a | |||
| 1089cfbacc | |||
| 1907a3c93b | |||
| 38203e522b | |||
| 407ba567a0 | |||
| f28571629f | |||
| 5a575c916b | |||
| 9a7e534b10 | |||
| 42974d1739 | |||
| 780e8babe4 | |||
| 2c7b8006cf | |||
| 35066c1388 | |||
| 135a5d38af | |||
| 1b7c1ffa70 | |||
| 641f643d2d | |||
| b4ecfceb5e | |||
| 08a84d4bb1 | |||
| 4dbad7ab24 | |||
| 859c0c9477 | |||
| d294bf8534 | |||
| 3c8fea382f | |||
| b81bfcfcee | |||
| 56c415ca05 | |||
| 74fdcceace | |||
| 7dec8ba998 | |||
| c9dc6affe7 | |||
| 8fe45ba78c | |||
| 934886caea | |||
| fae258b145 | |||
| 9f224f655f | |||
| aea7df7dc2 | |||
| 3b675f7de1 | |||
| 8daf7c2872 | |||
| c394490473 | |||
| 92d611df9a | |||
| 3b6b78b3e1 | |||
| aa47f522ef | |||
| 8658198a93 | |||
| 4b770d1385 | |||
| cd4d7372a0 | |||
| dc8243cb51 | |||
| 7b1f8d98f3 | |||
| dd8bcbb3e3 | |||
| d1af7a153f | |||
| 13efa47db7 | |||
| 69bd61c308 | |||
| 7b7ff51289 | |||
| 772ac8af73 | |||
| 8ee520dbb5 | |||
| 8e5d9e94a9 | |||
| c9cb28af45 | |||
| a994f8ff07 | |||
| ea8eaf9736 | |||
| b78db3daef | |||
| 7cf3f8df92 | |||
| f2b5cff3f9 | |||
| 6de9ab8f05 | |||
| ad0e800d8d | |||
| 13b691fd7d | |||
| 65470fb64b | |||
| f23142336b | |||
| 2da4987cd3 | |||
| 253ba554a2 | |||
| 95ce91d94b | |||
| a4548fd874 | |||
| eb03fb7060 | |||
| add9b8dfb0 | |||
| 2adb7b64cb | |||
| 84fef5f1d6 | |||
| def1e9c851 | |||
| 67b08ca61e | |||
| 614df75880 | |||
| 676cf37ee2 | |||
| 6b96e3dce6 | |||
| b67037e2ea | |||
| 5a5b77cf62 | |||
| d2793dfad7 | |||
| ff507f1275 | |||
| 6b04bcb383 | |||
| b2f1115ef8 | |||
| 567ef23ac4 | |||
| 6affebc666 | |||
| 889f78ddb8 | |||
| 9d3f96cf83 | |||
| e5d0673bbf | |||
| 0907c0346f | |||
| 6420a90d08 | |||
| 7fa1180d10 | |||
| 769d36e289 | |||
| a7a41b820e | |||
| 33fdc9a94f | |||
| 8b50f1fb65 | |||
| 2d78a4b628 | |||
| c86026c941 | |||
| db014e3446 | |||
| feb8045643 | |||
| d485a09318 | |||
| 9cff5f66b1 | |||
| 527d4cc777 | |||
| 01361884eb | |||
| 6c4cbcab5d | |||
| aac25f0a53 | |||
| 89f3f3c8cd | |||
| 4a3c201741 | |||
| f5ab837cce | |||
| 00ec7a5c66 | |||
| 03df4d03ed | |||
| 8488edd707 | |||
| 920dbba2a3 | |||
| 71e065a8bc | |||
| e125c762b2 | |||
| 4f01f7c072 | |||
| 9a5ee9d489 | |||
| 665da931f1 | |||
| 7595cf7ac7 | |||
| aa6232d0fc | |||
| beaf5dc843 | |||
| 7158855052 | |||
| 765b2d795f | |||
| 66f00fcf94 | |||
| e408e735be | |||
| e826d0dea6 | |||
| bc6fd0b399 | |||
| d00b737412 | |||
| 1f43713986 | |||
| cc5bec1d83 | |||
| 40125c717c | |||
| 2b402f8fec | |||
| 8e9071a336 | |||
| 18bcf40174 | |||
| 42e9b913f1 | |||
| fcb73f78ea | |||
| a21569bd00 | |||
| 565727ad36 | |||
| 00dce19997 | |||
| 29717e19db | |||
| 97aeee541a | |||
| 44c16d69af | |||
| b70a2bee58 | |||
| f2f56dc6c2 | |||
| 128db20755 | |||
| 12cbd40596 | |||
| ffd0d17b58 | |||
| 33fad57bf7 | |||
| 8bcc130947 | |||
| 19feaf4bf2 | |||
| 88ea4391e0 | |||
| fba37b7ad0 | |||
| 6c1798a8c5 | |||
| b6d688f15e | |||
| 8a57d8dd9c | |||
| 8e0e32c2be | |||
| 6b3a0a2113 | |||
| 4d6ed7eec5 | |||
| 1625dd1add | |||
| 605dd2f3c9 | |||
| 51bb149fd5 | |||
| 2ae4c29418 | |||
| ba71016f87 | |||
| 85c2bd807e | |||
| 517e1d15c8 | |||
| 3d6d5f176a | |||
| 5dd19edb56 | |||
| c6a52ffc75 | |||
| 09b2671759 | |||
| d11a244caa | |||
| 35d16ac683 | |||
| bf79768e05 | |||
| 08a2923cfc | |||
| b99e9a6468 | |||
| cb2ee9c489 | |||
| c1d933259a | |||
| 3cf6abdf27 | |||
| 0f2132e565 | |||
| 5cc88dc73f | |||
| ebe1c7a297 | |||
| 0943cf5d4c | |||
| 3b82ac568f | |||
| b695f34dc8 | |||
| 6df4bba3b6 | |||
| 9f83c0a0e8 | |||
| f617f93a94 | |||
| 51629247a5 | |||
| 0ab1854125 | |||
| b071fa2c9f | |||
| 8e2a79a0f5 | |||
| 71756812b6 | |||
| 76cd716caa | |||
| b0d1291cff | |||
| 9617eb2bd7 | |||
| c1ef5b4fbe | |||
| 8e14bdec95 | |||
| b26dfaf57f | |||
| 1a1c19b24e | |||
| 9d214b18af | |||
| e67b50b356 | |||
| 616caf76cb | |||
| 9a1db4948b | |||
| 1215aa8122 | |||
| d318a756a8 | |||
| b3c1e49c0c | |||
| dc12b00502 | |||
| 5b814e37c4 | |||
| 8483616b04 | |||
| ffe198839a | |||
| db5d1d4a16 | |||
| ad7dcddf24 | |||
| 94408aad21 | |||
| b84a7996a9 | |||
| a9b0bd8b47 | |||
| a32acf7c69 | |||
| 1e27acbf88 | |||
| 4012cc658d | |||
| 84d7a87609 | |||
| 9a92be532a | |||
| 18ac542e30 | |||
| 322475fb5c | |||
| 2f124bffc4 | |||
| 86367383e7 | |||
| d22ba3566d | |||
| c74b423bae | |||
| f8a757c55f | |||
| 6aea3f1643 | |||
| 073dc34522 | |||
| 3f5970a1f9 | |||
| e2f2608358 | |||
| 6d17bb04c4 | |||
| 957e7ba127 | |||
| def710cba8 | |||
| 44da854575 | |||
| d3d2474855 | |||
| d7d37c6f6e | |||
| 3c80b9a229 | |||
| a998a35482 | |||
| 20e0e5ebd0 | |||
| 4d831effe1 | |||
| 80f4dd0e60 | |||
| eafa3076d8 | |||
| fef3cd8354 | |||
| 36ada0705e | |||
| 8ae3c06df7 | |||
| ba127a8536 | |||
| 5c024f3a3a | |||
| 4fdb8583f6 | |||
| 2946df3b8e | |||
| c3b0c4e5e9 | |||
| a79d0f1677 | |||
| bfd7a7f561 | |||
| a5332bb0cc | |||
| b3963cc34b | |||
| ddb132f9fa | |||
| 64c901d91f | |||
| cd9e56fdb7 | |||
| 1b6b112e92 | |||
| 0ff0e83c9f | |||
| 6d491b7bb9 | |||
| cdc50ed47a | |||
| 06cc13c637 | |||
| 464d4990df | |||
| e2441ce284 | |||
| 0b6a3234a5 | |||
| ae8599c723 | |||
| 938e9b0d49 | |||
| 05e4ad3200 | |||
| cb90672573 | |||
| 9eb55ba68c | |||
| e19b6ebc82 | |||
| 5a6de12f74 | |||
| 6e6c91a27c | |||
| cf12ab1ac3 | |||
| aa7004b2ff | |||
| eca87b66f0 | |||
| cc8c89eeae | |||
| 6d14a4df49 | |||
| 6ea4aa1920 | |||
| f12451b8f9 | |||
| 0d4bb65a92 | |||
| d47ad9ac40 | |||
| 94949aa3fd | |||
| df098f55ba | |||
| f81ae24ba7 | |||
| facbb8f0a4 | |||
| 36fbd8818c | |||
| df1e28aabd | |||
| 91883397e6 | |||
| fd1813f3a7 | |||
| ddabfb5ca1 | |||
| ec0666a612 | |||
| bbf42c5802 | |||
| 6aa1d3b094 | |||
| 0d820df797 | |||
| f1ec1a2fb1 | |||
| 32fcf90467 | |||
| 5a53f88fd6 | |||
| 51971c7ef2 | |||
| 491096109a | |||
| 802a41b1bd | |||
| f59fbabede | |||
| 5a7d54058e | |||
| 5ef4490692 | |||
| 817e848d08 | |||
| 166c8326c5 | |||
| 673f1e93f4 | |||
| 4c1e1daf07 | |||
| 7c54df7ed1 | |||
| 9d77fcc457 | |||
| 454449ec8a | |||
| fe67e8e384 | |||
| 715b957660 | |||
| f1e4bf8d36 | |||
| 76aea311a4 | |||
| 3539b9ddb4 | |||
| 1a3cf2094b | |||
| 4530aac4f3 | |||
| 09cb20a084 | |||
| 6d4afd0953 | |||
| d1fb2e19d3 | |||
| dee0ca6864 | |||
| 2934bbdd20 | |||
| 2b46e8eaba | |||
| ed73d089d0 | |||
| 3b89104a59 | |||
| 5bf8b336c5 | |||
| 21a144753d | |||
| c1b8dfc863 | |||
| 5efcd4479a | |||
| e4e8b33e9f | |||
| 35ad235f49 | |||
| 834672c846 | |||
| af13790c93 | |||
| b8180d848a | |||
| fef7563e14 | |||
| 6337cf4359 | |||
| 87bcd8ec1b | |||
| b3cfe82dff | |||
| d65128671c | |||
| 41fdd5de74 | |||
| 2704202ba9 | |||
| 72ef0ae020 | |||
| 1442faa740 | |||
| 6aa589e612 | |||
| 4b1a8e14c4 | |||
| 1a0db10b1a | |||
| b7634086db | |||
| 73e9e830c3 | |||
| a6469e67a8 | |||
| 23ca3efbf4 | |||
| 0f9100fd3a | |||
| c47c411161 | |||
| e88e262abe | |||
| 832d45e32b | |||
| 69e3ac3cd4 | |||
| 50865f4265 | |||
| 0d1a8d9695 | |||
| 5d8486dd7f | |||
| 3c25932787 | |||
| 1d0e1eb126 | |||
| 57c0dc8618 | |||
| 526a147570 | |||
| 0938997548 | |||
| 0876b482f8 | |||
| d558c31f88 | |||
| 6010515da0 | |||
| 868bcd8e34 | |||
| 20c4904965 | |||
| 5a5536b38c | |||
| 53e2296de8 | |||
| d2423919e9 | |||
| 2250fcd177 | |||
| 2a33256d17 | |||
| 117aa750f8 | |||
| 15f161274f | |||
| 09779aca3e | |||
| 1d1f7cecf4 | |||
| dc00668cbe | |||
| 57701e13eb | |||
| 46545cb003 | |||
| a163cc3678 | |||
| 1dfb3408e8 | |||
| 67fb2beba1 | |||
| 6cacc9b83f | |||
| 1f1791feb7 | |||
| 1ba75092f9 | |||
| 08a08e73b3 | |||
| c500979099 | |||
| 2d9c082607 | |||
| 7968c4357b | |||
| 25c08e7279 | |||
| 81ed391efb | |||
| f3bee70c23 | |||
| 15a9eb28d9 | |||
| a0a093ed0b | |||
| 9cec711427 | |||
| 82745c701a | |||
| 68e775659b | |||
| 1c5e3000b6 | |||
| 3b93fd99a1 | |||
| e4fd2b656d | |||
| 159e91a07c | |||
| 530b5082bd | |||
| 3322f1ccb4 | |||
| 1b17fba19f | |||
| 987b5d580e | |||
| cb75ffc3b7 | |||
| 540f0a754d | |||
| 0f9a6fd968 | |||
| 82112abc34 | |||
| 75b5afd544 | |||
| 00e1675f7b | |||
| 2ddbdf977b | |||
| 4c8f0cc9ec | |||
| 18d380ce30 | |||
| e822b681cd | |||
| dd1f7ba544 | |||
| 8c2e6965f1 | |||
| b414f04cce | |||
| 9c71922dda | |||
| 6e4a28f227 | |||
| 64d8f035a2 | |||
| 0a5780a3b3 | |||
| d58b96f4b1 | |||
| f778f5c941 | |||
| 6422208f69 | |||
| c3ebc423b5 | |||
| 68d7b0a416 | |||
| 43546c84eb | |||
| eac36ee442 | |||
| 92f992728f | |||
| 78ad2d17c7 | |||
| 9a88394efe | |||
| 173562654b | |||
| b29bb7384d | |||
| 5a8de8210b | |||
| d5181454f4 | |||
| 0e0666cacf | |||
| e1583a58aa | |||
| 02ba2393b9 | |||
| 8f7e5ab1ed | |||
| 4334480675 | |||
| 6aa406927a | |||
| 5b50024712 | |||
| 7d922ac95f | |||
| 795a3d351e | |||
| 4b4c86b4b7 | |||
| 013af49137 | |||
| a6ae9290f2 | |||
| de70d72e0d | |||
| daf260cf61 | |||
| 92a06e0ea3 | |||
| c16d2ff2ed | |||
| 73a4d7d351 | |||
| 4e07e9c52c | |||
| 743621eb25 | |||
| e9df995e76 | |||
| 943923ff4b | |||
| 3f17f1a468 | |||
| 436996a43d | |||
| d42b6076d2 | |||
| 89cc99f915 | |||
| 1860b4b862 | |||
| efb1d69ac9 | |||
| 0601b55f22 | |||
| 107986d848 | |||
| b6c8fbe43b | |||
| 4208a9f372 | |||
| 3c82a228fb | |||
| a4aa29e48a | |||
| 0f82ba6627 | |||
| 1df5d9fac8 | |||
| 5189583d73 | |||
| b794d2aa40 | |||
| c69059b227 | |||
| b27b62d4c8 | |||
| ee8290d68c | |||
| 82e8e79b16 | |||
| 2d428d2fa0 | |||
| 0005c11a0a | |||
| 559cbeb7d5 | |||
| f91d914ec6 | |||
| e975f56445 | |||
| ce746a2a21 | |||
| 7120ab4b22 | |||
| 12e777b32e | |||
| 9378103ddd | |||
| ec794d5de2 | |||
| 12b18a3e8c | |||
| 91e8a13e59 | |||
| 931ba0f540 | |||
| b6caeda0a5 | |||
| 77d17af15b | |||
| d321d7275c | |||
| 3855486a00 | |||
| ab494521b1 | |||
| 549e1ead1d | |||
| a0759a79a1 | |||
| 14e1a119d3 | |||
| 6e066d38b0 | |||
| 21f72639b6 | |||
| 8a0c2031d4 | |||
| 56d3a466e5 | |||
| 563e505cc1 | |||
| c44c02b8ba | |||
| b9ab35a05b | |||
| e253195fdd | |||
| 2fd519e102 | |||
| a63c1ec364 | |||
| e61ef2ca2a | |||
| 39b09b7f3f | |||
| 840cc214e3 | |||
| 72524db52d | |||
| ab8fc11ab3 | |||
| 1831ca4e75 | |||
| 0611ceb5c3 | |||
| c4b3656fad | |||
| 54c1dd3bae | |||
| a8f4d2b7d1 | |||
| 9f67134ce2 | |||
| 51f1693dbd | |||
| 0d04cc365f | |||
| 09baf2f32e | |||
| 3253d60900 | |||
| b33a6e6fac | |||
| fc2c13a686 | |||
| f4602a120e | |||
| 7ccceeea0d | |||
| f81f78f294 | |||
| 6cab223f12 | |||
| 7b05c02508 | |||
| 5922bfb1a0 | |||
| 43f2e32231 | |||
| 20ebdc6289 | |||
| a80ae49a33 | |||
| 660197eef1 | |||
| 81274960f6 | |||
| 4786fc3a31 | |||
| f286d66cbc | |||
| f3eb823bc3 | |||
| 61c13db090 | |||
| ccbd793f52 | |||
| d13e6896a8 | |||
| 83a36ead10 | |||
| b61b74b0b5 | |||
| 01b068c50f | |||
| fee44ce960 | |||
| 1906504a86 | |||
| 36bcba332c | |||
| 304ab1964c | |||
| b286096c7b | |||
| a22a4b6e74 | |||
| 9a680d2374 | |||
| f80e212b07 | |||
| 8a39b3fd45 | |||
| 61ec938b00 | |||
| 6686de6788 | |||
| 79636cbb30 | |||
| 90d6178a0b | |||
| 2fa1bc6cdc | |||
| c5f6d822ca | |||
| 4de4bf9625 | |||
| 5d956080f2 | |||
| f8e18de2fc | |||
| 884482ec35 | |||
| 9b43948fa4 | |||
| bcd6cd99cc | |||
| 37ceba6b81 | |||
| dfe42e9016 | |||
| 38aa2dace8 | |||
| 136c3eff0c | |||
| 642999c8b1 | |||
| c5fc49b4fa | |||
| cd5a38b1eb | |||
| 595842c2c9 | |||
| 82d5276ade | |||
| 51eb782831 | |||
| de2980e1bc | |||
| 8a3c0d9a08 | |||
| 1a5e9f1005 | |||
| f42c013f33 | |||
| 42c9bda939 | |||
| cbce9fae3a | |||
| e44b15ecd5 | |||
| 7f6ca31757 | |||
| a1eb248474 | |||
| be2b1fd1ce | |||
| 20b65f549e | |||
| 1dc8be373c | |||
| 22b2e6b3d4 | |||
| 89e7107a47 | |||
| 0a69131c38 | |||
| 590f2c29b3 | |||
| 0ddcce6fe1 | |||
| 8a54fb7f23 | |||
| 5c280b024e | |||
| 033cc62ce7 | |||
| 4c69b7a64e | |||
| e7ab9b3f37 | |||
| 3143662f82 | |||
| 18964ba2a3 | |||
| f862404c5c | |||
| c292578f80 | |||
| 7b02d4104d | |||
| 2ef5d90e13 | |||
| d6a8021613 | |||
| c5231d37f6 | |||
| 4d803a40c9 | |||
| 1d709b551a | |||
| 335411de4c | |||
| 0e4abdf4b6 | |||
| 267b40b73c | |||
| ba9a0c5e3c | |||
| 9e0b7ff0d7 | |||
| 003bf7fdf3 | |||
| c3fdda026b | |||
| a53363d064 | |||
| ee21e1faa7 | |||
| e409a34a09 | |||
| 7177ab7f77 | |||
| 801f6fb661 | |||
| 805d82b8d9 | |||
| bd6d790495 | |||
| 2305163474 | |||
| dda53dcb16 | |||
| 2c3e768867 | |||
| 8d682ed9ad | |||
| 47fe497ca1 | |||
| 4d5f364663 | |||
| c3db8b972f | |||
| cfced63ba1 | |||
| 51aa55f963 | |||
| e7df24841e | |||
| e6fd4c32c4 | |||
| f6590aedbd | |||
| 3cb9e02533 | |||
| 4d792350ef |
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: crud-endpoints
|
||||||
|
description: Use whenever asked to add, create, or scaffold a CRUD endpoint, router, or entity in this repo's server (create/list/get/update/delete handlers, new `server/routers/<entity>/` or `server/private/routers/<entity>/` folder). Points to the established file layout, middleware, ActionsEnum, and route-registration conventions before writing any code.
|
||||||
|
---
|
||||||
|
|
||||||
|
Before writing any router/handler/middleware code for a new entity, read
|
||||||
|
`docs/crud-endpoints.md` in full. It documents, with real examples from
|
||||||
|
`server/routers/aiProvider/` (public) and `server/private/routers/alertRule/`
|
||||||
|
(enterprise-only), how this repo structures CRUD endpoints:
|
||||||
|
|
||||||
|
- Directory/file layout per entity (`index.ts`, `types.ts`, `validation.ts`,
|
||||||
|
one file per operation).
|
||||||
|
- The standard handler anatomy (zod parsing, OpenAPI registry, response
|
||||||
|
envelope, error handling).
|
||||||
|
- Where access-control middleware (`verify<Entity>Access`) lives and when
|
||||||
|
it's needed vs. plain `verifyOrgAccess`.
|
||||||
|
- How to wire up `ActionsEnum` entries, `verifyUserHasAction`, and
|
||||||
|
`logActionAudit`.
|
||||||
|
- Which of the four router files (`server/routers/external.ts`,
|
||||||
|
`server/routers/internal.ts`, `server/private/routers/external.ts`,
|
||||||
|
`server/private/routers/internal.ts`) to register routes in, and the
|
||||||
|
middleware chain template per HTTP verb.
|
||||||
|
- The repo's non-standard verb convention: **`PUT` = create, `POST` =
|
||||||
|
update** (backwards from typical REST) — don't "fix" this to standard
|
||||||
|
REST verbs, match the existing convention.
|
||||||
|
- The `#dynamic` import alias, for the rare case of a hook needing different
|
||||||
|
implementations in OSS vs. enterprise builds.
|
||||||
|
|
||||||
|
Follow that doc's checklist (§8) step by step rather than improvising a
|
||||||
|
structure. If the doc and the actual code in `aiProvider`/`alertRule` ever
|
||||||
|
disagree, trust the code and flag the doc as stale.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When writing TypeScript:
|
||||||
|
|
||||||
|
Prefer to use types instead of interfaces.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating forms, use React form for validation and use Zod schemas.
|
||||||
@@ -34,3 +34,5 @@ build.ts
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
|
allowedDevOrigins.json
|
||||||
|
scratch/
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: [fosrl]
|
|
||||||
@@ -14,12 +14,13 @@ body:
|
|||||||
label: Environment
|
label: Environment
|
||||||
description: Please fill out the relevant details below for your environment.
|
description: Please fill out the relevant details below for your environment.
|
||||||
value: |
|
value: |
|
||||||
- OS Type & Version: (e.g., Ubuntu 22.04)
|
- OS Type & Version:
|
||||||
- Pangolin Version:
|
- Pangolin Version:
|
||||||
|
- Edition (Community or Enterprise):
|
||||||
- Gerbil Version:
|
- Gerbil Version:
|
||||||
- Traefik Version:
|
- Traefik Version:
|
||||||
- Newt Version:
|
- Newt Version:
|
||||||
- Olm Version: (if applicable)
|
- Client Version:
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,42 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
dev-patch-updates:
|
npm-dependencies:
|
||||||
dependency-type: "development"
|
patterns:
|
||||||
update-types:
|
- "*"
|
||||||
- "patch"
|
|
||||||
dev-minor-updates:
|
|
||||||
dependency-type: "development"
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
prod-patch-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
|
||||||
- "patch"
|
|
||||||
prod-minor-updates:
|
|
||||||
dependency-type: "production"
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
patch-updates:
|
docker-dependencies:
|
||||||
update-types:
|
patterns:
|
||||||
- "patch"
|
- "*"
|
||||||
minor-updates:
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
|
groups:
|
||||||
|
github-actions-dependencies:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/install"
|
directory: "/install"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
open-pull-requests-limit: 1
|
||||||
groups:
|
groups:
|
||||||
patch-updates:
|
go-install-dependencies:
|
||||||
update-types:
|
patterns:
|
||||||
- "patch"
|
- "*"
|
||||||
minor-updates:
|
|
||||||
update-types:
|
|
||||||
- "minor"
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -201,10 +201,10 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
@@ -264,7 +264,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||||
with:
|
with:
|
||||||
go-version: 1.25
|
go-version: 1.25
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -415,7 +415,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
# cosign is used to sign container images using keyless (OIDC) signing
|
# cosign is used to sign container images using keyless (OIDC) signing
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
with:
|
||||||
|
cosign-release: v3.0.6
|
||||||
|
|
||||||
- name: Sign (GHCR, keyless)
|
- name: Sign (GHCR, keyless)
|
||||||
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
name: Restart Runners
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 0 */7 * *'
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ec2-maintenance-prod:
|
|
||||||
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 instance
|
|
||||||
run: |
|
|
||||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
|
||||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
|
||||||
echo "EC2 instances started"
|
|
||||||
|
|
||||||
- name: Wait
|
|
||||||
run: sleep 600
|
|
||||||
|
|
||||||
- name: Stop EC2 instance
|
|
||||||
run: |
|
|
||||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
|
||||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
|
||||||
echo "EC2 instances stopped"
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite*
|
||||||
!Dockerfile.sqlite
|
!Dockerfile.sqlite
|
||||||
*.sqlite3
|
*.sqlite3*
|
||||||
*.log
|
*.log
|
||||||
.machinelogs*.json
|
.machinelogs*.json
|
||||||
*-audit.json
|
*-audit.json
|
||||||
@@ -54,3 +54,5 @@ hydrateSaas.ts
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
server/setup/migrations.ts
|
server/setup/migrations.ts
|
||||||
|
solo.yml
|
||||||
|
allowedDevOrigins.json
|
||||||
@@ -18,5 +18,8 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"cSpell.words": [
|
||||||
|
"nessicary"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# FROM node:24-slim AS base
|
# FROM node:24.18.1-slim AS base
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS base
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ FROM base AS builder
|
|||||||
|
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24-slim AS runner
|
# FROM node:24.18.1-slim AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:24-slim AS runner
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26.7.0-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -63,11 +63,26 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||||
|
|
||||||
|
* Lightweight user-space connector runs anywhere
|
||||||
|
* Punches through any firewall
|
||||||
|
* Doesn't require open ports or a public IP
|
||||||
|
* Strict network segmentation
|
||||||
|
* WireGuard-based
|
||||||
|
* Get alerts when a device or network resource goes down
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||||
|
|
||||||
### Browser-based reverse proxy access
|
### Browser-based reverse proxy access
|
||||||
|
|
||||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||||
|
|
||||||
|
* Expose a web panel anywhere
|
||||||
|
* Access via any web browser
|
||||||
|
* Single sign-on across all resources
|
||||||
|
* HTTPS resources
|
||||||
|
* Remote desktop in the browser with VNC and RDP
|
||||||
|
* In-browser SSH terminal with privileged access management (PAM)
|
||||||
|
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
|
||||||
|
|
||||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||||
|
|
||||||
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
|
|||||||
|
|
||||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||||
|
|
||||||
|
* Peer-to-peer with intelligent NAT traversal
|
||||||
|
* Hosts/IPs and port ranges
|
||||||
|
* Network ranges/CIDRs
|
||||||
|
* Friendly DNS aliases for network addresses
|
||||||
|
* Privileged access management (PAM) with SSH resources
|
||||||
|
* Private HTTPS resources only accessible on the private network
|
||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|
||||||
|
* Bring your existing identity provider (IdP) or use Pangolin identities
|
||||||
|
* Sync users and roles from your IdP
|
||||||
|
* User- and role-based access control
|
||||||
|
* Full network audit and access logs
|
||||||
|
|
||||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||||
|
|
||||||
|
### Find and launch resources from a personalized home page
|
||||||
|
|
||||||
|
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
|
||||||
|
|
||||||
|
* Single place for admins and non-admins to see accessible resources
|
||||||
|
* Create reusable views for common access patterns
|
||||||
|
|
||||||
|
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
Download the Pangolin client for your platform:
|
Download the Pangolin client for your platform:
|
||||||
@@ -107,7 +143,7 @@ the docs to illustrate some basic ideas.
|
|||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable 2FA for a user by email address.
|
||||||
|
*/
|
||||||
|
type DisableUser2faArgs = {
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
|
||||||
|
command: "disable-user-2fa",
|
||||||
|
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs.option("email", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
describe: "User email address"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (argv: { email: string }) => {
|
||||||
|
try {
|
||||||
|
const { email } = argv;
|
||||||
|
console.log(`Looking for user with email: ${email}`);
|
||||||
|
|
||||||
|
// Find the user by email
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`User with email '${email}' not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.twoFactorEnabled) {
|
||||||
|
console.log(`2FA is already disabled for user '${email}'.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user: disable 2FA and clear secret
|
||||||
|
await db.update(users)
|
||||||
|
.set({
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
twoFactorSecret: null,
|
||||||
|
twoFactorSetupRequested: false
|
||||||
|
})
|
||||||
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
|
console.log(`2FA disabled for user '${email}'.`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error disabling 2FA:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CommandModule } from "yargs";
|
import { CommandModule } from "yargs";
|
||||||
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions } from "@server/db";
|
import { db, idpOidcConfig, licenseKey, certificates, eventStreamingDestinations, alertWebhookActions, aiProviders, virtualApiKeys } from "@server/db";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -132,12 +132,16 @@ export const rotateServerSecret: CommandModule<
|
|||||||
const certs = await db.select().from(certificates);
|
const certs = await db.select().from(certificates);
|
||||||
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
const streamingDestinations = await db.select().from(eventStreamingDestinations);
|
||||||
const webhookActions = await db.select().from(alertWebhookActions);
|
const webhookActions = await db.select().from(alertWebhookActions);
|
||||||
|
const providers = await db.select().from(aiProviders);
|
||||||
|
const virtualKeys = await db.select().from(virtualApiKeys);
|
||||||
|
|
||||||
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`);
|
||||||
console.log(`Found ${licenseKeys.length} license key(s)`);
|
console.log(`Found ${licenseKeys.length} license key(s)`);
|
||||||
console.log(`Found ${certs.length} certificate(s)`);
|
console.log(`Found ${certs.length} certificate(s)`);
|
||||||
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
console.log(`Found ${streamingDestinations.length} event streaming destination(s)`);
|
||||||
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
console.log(`Found ${webhookActions.length} alert webhook action(s)`);
|
||||||
|
console.log(`Found ${providers.length} AI provider(s)`);
|
||||||
|
console.log(`Found ${virtualKeys.length} virtual API key(s)`);
|
||||||
|
|
||||||
// Prepare all decrypted and re-encrypted values
|
// Prepare all decrypted and re-encrypted values
|
||||||
console.log("\nDecrypting and re-encrypting values...");
|
console.log("\nDecrypting and re-encrypting values...");
|
||||||
@@ -171,11 +175,24 @@ export const rotateServerSecret: CommandModule<
|
|||||||
encryptedConfig: string;
|
encryptedConfig: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AiProviderUpdate = {
|
||||||
|
providerId: number;
|
||||||
|
encryptedApiKey: string | null;
|
||||||
|
encryptedHeaders: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VirtualApiKeyUpdate = {
|
||||||
|
virtualApiKeyId: string;
|
||||||
|
encryptedToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
const idpUpdates: IdpUpdate[] = [];
|
const idpUpdates: IdpUpdate[] = [];
|
||||||
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
|
||||||
const certUpdates: CertUpdate[] = [];
|
const certUpdates: CertUpdate[] = [];
|
||||||
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
|
||||||
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
const webhookActionUpdates: WebhookActionUpdate[] = [];
|
||||||
|
const aiProviderUpdates: AiProviderUpdate[] = [];
|
||||||
|
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
|
||||||
|
|
||||||
// Process idpOidcConfig entries
|
// Process idpOidcConfig entries
|
||||||
for (const idpConfig of idpConfigs) {
|
for (const idpConfig of idpConfigs) {
|
||||||
@@ -306,6 +323,60 @@ export const rotateServerSecret: CommandModule<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process aiProviders entries (apiKey + headers)
|
||||||
|
for (const provider of providers) {
|
||||||
|
try {
|
||||||
|
if (!provider.apiKey && !provider.headers) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedApiKey = provider.apiKey
|
||||||
|
? encrypt(decrypt(provider.apiKey, oldSecret), newSecret)
|
||||||
|
: null;
|
||||||
|
const encryptedHeaders = provider.headers
|
||||||
|
? encrypt(
|
||||||
|
decrypt(provider.headers, oldSecret),
|
||||||
|
newSecret
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
aiProviderUpdates.push({
|
||||||
|
providerId: provider.providerId,
|
||||||
|
encryptedApiKey,
|
||||||
|
encryptedHeaders
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing AI provider ${provider.providerId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process virtualApiKeys entries (token)
|
||||||
|
for (const key of virtualKeys) {
|
||||||
|
try {
|
||||||
|
if (!key.token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualApiKeyUpdates.push({
|
||||||
|
virtualApiKeyId: key.virtualApiKeyId,
|
||||||
|
encryptedToken: encrypt(
|
||||||
|
decrypt(key.token, oldSecret),
|
||||||
|
newSecret
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error processing virtual API key ${key.virtualApiKeyId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Perform all database updates in a single transaction
|
// Perform all database updates in a single transaction
|
||||||
console.log("\nUpdating database in transaction...");
|
console.log("\nUpdating database in transaction...");
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
@@ -376,6 +447,32 @@ export const rotateServerSecret: CommandModule<
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update AI provider entries
|
||||||
|
for (const update of aiProviderUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(aiProviders)
|
||||||
|
.set({
|
||||||
|
apiKey: update.encryptedApiKey,
|
||||||
|
headers: update.encryptedHeaders
|
||||||
|
})
|
||||||
|
.where(eq(aiProviders.providerId, update.providerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update virtual API key entries
|
||||||
|
for (const update of virtualApiKeyUpdates) {
|
||||||
|
await trx
|
||||||
|
.update(virtualApiKeys)
|
||||||
|
.set({
|
||||||
|
token: update.encryptedToken
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
virtualApiKeys.virtualApiKeyId,
|
||||||
|
update.virtualApiKeyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`);
|
||||||
@@ -383,6 +480,8 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
console.log(`Rotated ${certUpdates.length} certificate(s)`);
|
||||||
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(s)`);
|
||||||
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
console.log(`Rotated ${webhookActionUpdates.length} alert webhook action(s)`);
|
||||||
|
console.log(`Rotated ${aiProviderUpdates.length} AI provider(s)`);
|
||||||
|
console.log(`Rotated ${virtualApiKeyUpdates.length} virtual API key(s)`);
|
||||||
|
|
||||||
// Update config file with new secret
|
// Update config file with new secret
|
||||||
console.log("\nUpdating config file...");
|
console.log("\nUpdating config file...");
|
||||||
@@ -402,6 +501,7 @@ export const rotateServerSecret: CommandModule<
|
|||||||
console.log(` - Certificates: ${certUpdates.length}`);
|
console.log(` - Certificates: ${certUpdates.length}`);
|
||||||
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
|
||||||
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
|
||||||
|
console.log(` - AI providers: ${aiProviderUpdates.length}`);
|
||||||
console.log(
|
console.log(
|
||||||
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
`\n IMPORTANT: Restart the server for the new secret to take effect.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CommandModule } from "yargs";
|
||||||
|
import { db, users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
type SetServerAdminArgs = {
|
||||||
|
email: string;
|
||||||
|
remove: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||||
|
command: "set-server-admin",
|
||||||
|
describe: "Add or remove server admin by email address",
|
||||||
|
builder: (yargs) => {
|
||||||
|
return yargs
|
||||||
|
.option("email", {
|
||||||
|
type: "string",
|
||||||
|
demandOption: true,
|
||||||
|
describe: "User email address"
|
||||||
|
})
|
||||||
|
.option("remove", {
|
||||||
|
type: "boolean",
|
||||||
|
default: false,
|
||||||
|
describe: "Remove server admin status from the user"
|
||||||
|
});
|
||||||
|
},
|
||||||
|
handler: async (argv: SetServerAdminArgs) => {
|
||||||
|
try {
|
||||||
|
const email = argv.email.trim().toLowerCase();
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error(`User with email '${email}' not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argv.remove) {
|
||||||
|
if (!user.serverAdmin) {
|
||||||
|
console.log(`User '${email}' is not a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverAdmins = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.serverAdmin, true));
|
||||||
|
|
||||||
|
if (serverAdmins.length <= 1) {
|
||||||
|
console.error(
|
||||||
|
"Cannot remove server admin: at least one server admin must exist"
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ serverAdmin: false })
|
||||||
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
|
console.log(`Server admin status removed from user '${email}'`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.serverAdmin) {
|
||||||
|
console.log(`User '${email}' is already a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({ serverAdmin: true })
|
||||||
|
.where(eq(users.userId, user.userId));
|
||||||
|
|
||||||
|
console.log(`User '${email}' has been marked as a server admin`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@ import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
|||||||
import { deleteClient } from "./commands/deleteClient";
|
import { deleteClient } from "./commands/deleteClient";
|
||||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||||
import { clearCertificates } from "./commands/clearCertificates";
|
import { clearCertificates } from "./commands/clearCertificates";
|
||||||
|
import { disableUser2fa } from "./commands/disableUser2fa";
|
||||||
|
import { setServerAdmin } from "./commands/setServerAdmin";
|
||||||
|
|
||||||
yargs(hideBin(process.argv))
|
yargs(hideBin(process.argv))
|
||||||
.scriptName("pangctl")
|
.scriptName("pangctl")
|
||||||
@@ -21,5 +23,7 @@ yargs(hideBin(process.argv))
|
|||||||
.command(deleteClient)
|
.command(deleteClient)
|
||||||
.command(generateOrgCaKeys)
|
.command(generateOrgCaKeys)
|
||||||
.command(clearCertificates)
|
.command(clearCertificates)
|
||||||
|
.command(disableUser2fa)
|
||||||
|
.command(setServerAdmin)
|
||||||
.demandCommand()
|
.demandCommand()
|
||||||
.help().argv;
|
.help().argv;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.6
|
image: traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
@@ -8,7 +8,7 @@ services:
|
|||||||
POSTGRES_USER: postgres # Default user
|
POSTGRES_USER: postgres # Default user
|
||||||
POSTGRES_PASSWORD: password # Default password (change for production!)
|
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/postgres:/var/lib/postgresql/data
|
- ${HOME}/.local/share/pangolin-dev/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432" # Map host port 5432 to container port 5432
|
- "5432:5432" # Map host port 5432 to container port 5432
|
||||||
restart: no
|
restart: no
|
||||||
@@ -1,54 +1,47 @@
|
|||||||
api:
|
api:
|
||||||
insecure: true
|
insecure: true
|
||||||
dashboard: true
|
dashboard: true
|
||||||
|
|
||||||
providers:
|
providers:
|
||||||
http:
|
http:
|
||||||
endpoint: "http://pangolin:3001/api/v1/traefik-config"
|
endpoint: http://pangolin:3001/api/v1/traefik-config
|
||||||
pollInterval: "5s"
|
pollInterval: 5s
|
||||||
file:
|
file:
|
||||||
filename: "/etc/traefik/dynamic_config.yml"
|
filename: /etc/traefik/dynamic_config.yml
|
||||||
|
|
||||||
experimental:
|
experimental:
|
||||||
plugins:
|
plugins:
|
||||||
badger:
|
badger:
|
||||||
moduleName: "github.com/fosrl/badger"
|
moduleName: github.com/fosrl/badger
|
||||||
version: "{{.BadgerVersion}}"
|
version: v1.4.1
|
||||||
|
|
||||||
log:
|
log:
|
||||||
level: "INFO"
|
level: INFO
|
||||||
format: "common"
|
format: common
|
||||||
maxSize: 100
|
maxSize: 100
|
||||||
maxBackups: 3
|
maxBackups: 3
|
||||||
maxAge: 3
|
maxAge: 3
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
certificatesResolvers:
|
certificatesResolvers:
|
||||||
letsencrypt:
|
letsencrypt:
|
||||||
acme:
|
acme:
|
||||||
httpChallenge:
|
httpChallenge:
|
||||||
entryPoint: web
|
entryPoint: web
|
||||||
email: "{{.LetsEncryptEmail}}"
|
email: '{{.LetsEncryptEmail}}'
|
||||||
storage: "/letsencrypt/acme.json"
|
storage: /letsencrypt/acme.json
|
||||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
caServer: https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
entryPoints:
|
entryPoints:
|
||||||
web:
|
web:
|
||||||
address: ":80"
|
address: ':80'
|
||||||
websecure:
|
websecure:
|
||||||
address: ":443"
|
address: ':443'
|
||||||
transport:
|
transport:
|
||||||
respondingTimeouts:
|
respondingTimeouts:
|
||||||
readTimeout: "30m"
|
readTimeout: 30m
|
||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: letsencrypt
|
||||||
encodedCharacters:
|
encodedCharacters:
|
||||||
allowEncodedSlash: true
|
allowEncodedSlash: true
|
||||||
allowEncodedQuestionMark: true
|
allowEncodedQuestionMark: true
|
||||||
|
|
||||||
serversTransport:
|
serversTransport:
|
||||||
insecureSkipVerify: true
|
insecureSkipVerify: true
|
||||||
|
|
||||||
ping:
|
ping:
|
||||||
entryPoint: "web"
|
entryPoint: web
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# AI Gateway Provider Selection
|
||||||
|
|
||||||
|
How the AI gateway picks which attached provider handles a request when an
|
||||||
|
inference resource has more than one AI provider.
|
||||||
|
|
||||||
|
**Code:**
|
||||||
|
|
||||||
|
- Route → capability binding: `server/routers/aiGateway/createAiGatewayRouter.ts`
|
||||||
|
- Request pipeline: `server/routers/aiGateway/pipeline.ts` (`selectProvider`)
|
||||||
|
- Tie-break scoring: `server/lib/aiProviderSelection.ts`
|
||||||
|
- Allow/block matching: `server/lib/aiModelKeyMatch.ts`
|
||||||
|
- Model catalog: `server/lib/aiModelCatalog.ts`
|
||||||
|
- Default capabilities per provider type: `server/lib/aiProviderDefaults.ts`
|
||||||
|
|
||||||
|
Overlapping model allows are permitted at save time. Selection happens at
|
||||||
|
request time. If the algorithm cannot confidently pick one provider, the
|
||||||
|
gateway returns `403` with an ambiguous-provider error.
|
||||||
|
|
||||||
|
## Selection Pipeline
|
||||||
|
|
||||||
|
Every gateway request runs through these steps in order. Each step narrows
|
||||||
|
the candidate set. Later steps only run when more than one provider remains.
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Capability filter
|
||||||
|
2. Allow / block lists
|
||||||
|
3. Most specific allow pattern
|
||||||
|
4. Catalog ownership
|
||||||
|
5. Provider class preference
|
||||||
|
6. Ambiguous → error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Capability Filter
|
||||||
|
|
||||||
|
The incoming path selects a capability before any provider logic runs.
|
||||||
|
|
||||||
|
| Path | Capability |
|
||||||
|
|------|------------|
|
||||||
|
| `POST /v1/chat/completions` | `openai_chat` |
|
||||||
|
| `POST /v1/responses` | `openai_responses` |
|
||||||
|
| `POST /v1/messages` | `anthropic_messages` |
|
||||||
|
| Gemini / Vertex / Bedrock routes | their respective capability ids |
|
||||||
|
|
||||||
|
Only attached providers that advertise that capability stay in the candidate
|
||||||
|
set. Default capabilities do not overlap for native OpenAI vs Anthropic:
|
||||||
|
|
||||||
|
| Provider type | Default capabilities |
|
||||||
|
|---------------|----------------------|
|
||||||
|
| `openai` | `openai_chat`, `openai_responses` |
|
||||||
|
| `anthropic` | `anthropic_messages` |
|
||||||
|
| `openRouter` | `openai_chat` |
|
||||||
|
| `vercelAiGateway` | `openai_chat`, `openai_responses` |
|
||||||
|
| `microsoftFoundry` | `openai_chat`, `openai_responses`, `anthropic_messages` |
|
||||||
|
| `custom` | whatever was configured |
|
||||||
|
|
||||||
|
### 2. Allow / Block Lists
|
||||||
|
|
||||||
|
For each remaining provider, the gateway resolves the effective allow and
|
||||||
|
block patterns:
|
||||||
|
|
||||||
|
- **`inherit`**: use the provider's own model lists
|
||||||
|
- **`select`**: use the resource-selected subset of those lists
|
||||||
|
|
||||||
|
A candidate is kept only if `isAllowedByLists(requestedModel, allows, blocks)`
|
||||||
|
passes:
|
||||||
|
|
||||||
|
1. At least one allow pattern must match
|
||||||
|
2. No block pattern may match
|
||||||
|
|
||||||
|
Patterns support `*` and `?` globs (`gpt-*`, `claude-3-5-sonnet-?`).
|
||||||
|
|
||||||
|
### 3. Most Specific Allow Pattern
|
||||||
|
|
||||||
|
Among providers that allow the model, keep those whose matching allow
|
||||||
|
pattern is most specific:
|
||||||
|
|
||||||
|
1. Exact keys beat patterns
|
||||||
|
2. Fewer wildcard characters win
|
||||||
|
3. Longer literal length wins
|
||||||
|
|
||||||
|
Example: `gpt-4o` beats `gpt-*` beats `*`.
|
||||||
|
|
||||||
|
### 4. Catalog Ownership
|
||||||
|
|
||||||
|
When specificity is tied (common with multiple `*` allows), score each
|
||||||
|
provider against the known model catalog:
|
||||||
|
|
||||||
|
| Score | Meaning |
|
||||||
|
|------:|---------|
|
||||||
|
| 2 | Typed provider whose catalog contains the model (`openai` → openai catalog, `anthropic` → anthropic, etc.) |
|
||||||
|
| 1 | Aggregator or custom (`openRouter`, `vercelAiGateway`, `custom`) and the model exists somewhere in the catalog |
|
||||||
|
| 0 | No ownership signal (typed catalog miss, or unknown model on aggregator/custom) |
|
||||||
|
|
||||||
|
Model id lookup tries the raw id, then a stripped `vendor/model` form
|
||||||
|
(e.g. `openai/gpt-4o` → also try `gpt-4o`).
|
||||||
|
|
||||||
|
Typed providers map to catalog providers as:
|
||||||
|
|
||||||
|
| Provider type | Catalog |
|
||||||
|
|---------------|---------|
|
||||||
|
| `openai` | `openai` |
|
||||||
|
| `anthropic` | `anthropic` |
|
||||||
|
| `googleGemini` | `gemini` |
|
||||||
|
| `vertexAi` | `vertex` |
|
||||||
|
| `bedrock` | `bedrock` |
|
||||||
|
| `microsoftFoundry` | `azure` |
|
||||||
|
| `openRouter` / `vercelAiGateway` / `custom` | none (aggregator/custom path) |
|
||||||
|
|
||||||
|
### 5. Provider Class Preference
|
||||||
|
|
||||||
|
If catalog ownership is still tied, prefer:
|
||||||
|
|
||||||
|
| Rank | Class |
|
||||||
|
|-----:|-------|
|
||||||
|
| 2 | Native typed provider (`openai`, `anthropic`, `googleGemini`, …) |
|
||||||
|
| 1 | Aggregator (`openRouter`, `vercelAiGateway`) |
|
||||||
|
| 0 | `custom` |
|
||||||
|
|
||||||
|
### 6. Ambiguous Error
|
||||||
|
|
||||||
|
If more than one distinct provider remains after all steps, the gateway
|
||||||
|
rejects the request:
|
||||||
|
|
||||||
|
```
|
||||||
|
Model "<id>" is ambiguous across multiple AI providers on this resource
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical remaining ties: two OpenAI-type providers both with `*`, or two
|
||||||
|
customs advertising the same capability for an unknown model.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Assume each provider below is attached and enabled on the same inference
|
||||||
|
resource.
|
||||||
|
|
||||||
|
### Example A: OpenAI + Anthropic, Both `*`
|
||||||
|
|
||||||
|
| Provider | Allow | Capabilities |
|
||||||
|
|----------|-------|--------------|
|
||||||
|
| OpenAI | `*` | `openai_chat`, `openai_responses` |
|
||||||
|
| Anthropic | `*` | `anthropic_messages` |
|
||||||
|
|
||||||
|
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||||
|
|
||||||
|
1. Capability → only OpenAI remains
|
||||||
|
2. Allow → OpenAI matches `*`
|
||||||
|
3. Result → **OpenAI**
|
||||||
|
|
||||||
|
Anthropic never reaches pattern or catalog scoring. Capability alone decides.
|
||||||
|
|
||||||
|
**Request:** `POST /v1/messages` with `model: "claude-3-5-sonnet-latest"`
|
||||||
|
|
||||||
|
1. Capability → only Anthropic remains
|
||||||
|
2. Result → **Anthropic**
|
||||||
|
|
||||||
|
### Example B: OpenAI + OpenRouter, Both `*`
|
||||||
|
|
||||||
|
| Provider | Allow | Capabilities |
|
||||||
|
|----------|-------|--------------|
|
||||||
|
| OpenAI | `*` | `openai_chat`, … |
|
||||||
|
| OpenRouter | `*` | `openai_chat` |
|
||||||
|
|
||||||
|
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||||
|
|
||||||
|
1. Capability → both remain (`openai_chat`)
|
||||||
|
2. Allow → both match `*`
|
||||||
|
3. Specificity → tie (`*` vs `*`)
|
||||||
|
4. Catalog → OpenAI scores `2` (owns `gpt-4o`); OpenRouter scores `1`
|
||||||
|
5. Result → **OpenAI**
|
||||||
|
|
||||||
|
### Example C: OpenRouter Only Serving a Claude Model Over OpenAI Chat
|
||||||
|
|
||||||
|
| Provider | Allow | Capabilities |
|
||||||
|
|----------|-------|--------------|
|
||||||
|
| OpenRouter | `*` | `openai_chat` |
|
||||||
|
|
||||||
|
**Request:** `POST /v1/chat/completions` with `model: "anthropic/claude-3.5-sonnet"`
|
||||||
|
|
||||||
|
1. Capability → OpenRouter remains
|
||||||
|
2. Only one candidate → **OpenRouter**
|
||||||
|
|
||||||
|
No tie-breaking needed.
|
||||||
|
|
||||||
|
### Example D: OpenAI (`gpt-*`) + OpenRouter (`*`)
|
||||||
|
|
||||||
|
| Provider | Allow |
|
||||||
|
|----------|-------|
|
||||||
|
| OpenAI | `gpt-*` |
|
||||||
|
| OpenRouter | `*` |
|
||||||
|
|
||||||
|
**Request:** `model: "gpt-4o"` on `openai_chat`
|
||||||
|
|
||||||
|
1. Capability → both
|
||||||
|
2. Allow → both match
|
||||||
|
3. Specificity → OpenAI's `gpt-*` beats OpenRouter's `*`
|
||||||
|
4. Result → **OpenAI**
|
||||||
|
|
||||||
|
Catalog scoring is not needed because specificity already unique'd the set.
|
||||||
|
|
||||||
|
### Example E: OpenAI + Anthropic With Overlapping Custom Capabilities
|
||||||
|
|
||||||
|
Someone grants Anthropic `openai_chat` as well (non-default).
|
||||||
|
|
||||||
|
| Provider | Allow | Capabilities |
|
||||||
|
|----------|-------|--------------|
|
||||||
|
| OpenAI | `*` | `openai_chat`, … |
|
||||||
|
| Anthropic | `*` | `anthropic_messages`, `openai_chat` |
|
||||||
|
|
||||||
|
**Request:** `POST /v1/chat/completions` with `model: "gpt-4o"`
|
||||||
|
|
||||||
|
1. Capability → both remain
|
||||||
|
2. Allow → both match `*`
|
||||||
|
3. Specificity → tie
|
||||||
|
4. Catalog → OpenAI `2`, Anthropic `0` (`gpt-4o` is not in the anthropic catalog)
|
||||||
|
5. Result → **OpenAI**
|
||||||
|
|
||||||
|
### Example F: Two Aggregators, Known Model
|
||||||
|
|
||||||
|
| Provider | Allow |
|
||||||
|
|----------|-------|
|
||||||
|
| OpenRouter | `*` |
|
||||||
|
| Vercel AI Gateway | `*` |
|
||||||
|
|
||||||
|
**Request:** `model: "gpt-4o"` on `openai_chat`
|
||||||
|
|
||||||
|
1. Capability → both
|
||||||
|
2. Allow / specificity → tie
|
||||||
|
3. Catalog → both score `1` (known model, no typed owner in the set)
|
||||||
|
4. Class → both aggregators (rank `1`) → still tied
|
||||||
|
5. Result → **ambiguous error**
|
||||||
|
|
||||||
|
Attach a native OpenAI provider (or narrow one aggregator's allow list) to
|
||||||
|
make this determinable.
|
||||||
|
|
||||||
|
### Example G: Two OpenAI Providers, Both `*`
|
||||||
|
|
||||||
|
| Provider | Type | Allow |
|
||||||
|
|----------|------|-------|
|
||||||
|
| OpenAI Prod | `openai` | `*` |
|
||||||
|
| OpenAI Staging | `openai` | `*` |
|
||||||
|
|
||||||
|
**Request:** `model: "gpt-4o"`
|
||||||
|
|
||||||
|
1–5 all leave both candidates (same capability, same specificity, same
|
||||||
|
catalog ownership, same class).
|
||||||
|
|
||||||
|
Result → **ambiguous error**
|
||||||
|
|
||||||
|
Disambiguate with different allow patterns, disable one attachment, or
|
||||||
|
split across resources.
|
||||||
|
|
||||||
|
### Example H: Unknown Model Across Native + Aggregator
|
||||||
|
|
||||||
|
| Provider | Allow |
|
||||||
|
|----------|-------|
|
||||||
|
| OpenAI | `*` |
|
||||||
|
| OpenRouter | `*` |
|
||||||
|
|
||||||
|
**Request:** `model: "my-fine-tune-v3"` (not in catalog)
|
||||||
|
|
||||||
|
1. Capability → both
|
||||||
|
2. Allow / specificity → tie
|
||||||
|
3. Catalog → both score `0` (typed miss + unknown aggregator model)
|
||||||
|
4. Class → OpenAI (`2`) beats OpenRouter (`1`)
|
||||||
|
5. Result → **OpenAI**
|
||||||
|
|
||||||
|
## Practical Guidance
|
||||||
|
|
||||||
|
- Native OpenAI + Anthropic with `*` is safe. Different default APIs never
|
||||||
|
collide.
|
||||||
|
- OpenAI + OpenRouter with `*` is usually fine for catalog-known OpenAI
|
||||||
|
models. Native wins.
|
||||||
|
- Prefer specific allow patterns (`gpt-4o`, `gpt-*`) when two providers share
|
||||||
|
a capability.
|
||||||
|
- Two providers of the same type both using `*` will stay ambiguous. Narrow
|
||||||
|
at least one allow list.
|
||||||
|
- Custom providers only win ties when no stronger native/aggregator signal
|
||||||
|
remains.
|
||||||
|
|
||||||
|
## Related Behavior
|
||||||
|
|
||||||
|
- **Saving providers on a resource does not reject overlapping allows.**
|
||||||
|
Collisions are resolved (or rejected) per request.
|
||||||
|
- Budgets, auth, and upstream URL / target routing run after a single
|
||||||
|
provider has been selected.
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
# How to build a CRUD endpoint in this repo
|
||||||
|
|
||||||
|
Reference for adding a new CRUD entity to the server. Based on two real
|
||||||
|
examples already in the codebase — read them side by side with this doc:
|
||||||
|
|
||||||
|
- **Public / open-source (Community Edition) pattern**: `server/routers/aiProvider/`
|
||||||
|
- **Enterprise-only pattern**: `server/private/routers/alertRule/`
|
||||||
|
|
||||||
|
The two are structurally identical. The only difference is *where the files
|
||||||
|
live* and *which router they get wired into*.
|
||||||
|
|
||||||
|
## 1. Decide: public or private?
|
||||||
|
|
||||||
|
- `server/routers/<entity>/` — ships in the open-source Community Edition.
|
||||||
|
Anyone running Pangolin gets this.
|
||||||
|
- `server/private/routers/<entity>/` — Enterprise/SaaS only. Gated behind
|
||||||
|
`verifyValidLicense` (and often `verifyValidSubscription(tierMatrix.x)`).
|
||||||
|
Every file here starts with the Fossorial Commercial License header block
|
||||||
|
(copy it verbatim from an existing private file).
|
||||||
|
|
||||||
|
Everything below applies to both — swap `@server/...` for `#private/...`
|
||||||
|
import paths and add license headers when building the private version.
|
||||||
|
|
||||||
|
## 2. Directory layout
|
||||||
|
|
||||||
|
One folder per entity, one file per operation, a barrel `index.ts`:
|
||||||
|
|
||||||
|
```
|
||||||
|
server/routers/<entity>/
|
||||||
|
index.ts # export * from each operation file + ./types
|
||||||
|
types.ts # response payload types + row->public mapper
|
||||||
|
validation.ts # zod schemas/refinements shared by create + update (optional)
|
||||||
|
create<Entity>.ts
|
||||||
|
list<Entities>.ts
|
||||||
|
get<Entity>.ts
|
||||||
|
update<Entity>.ts
|
||||||
|
delete<Entity>.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.ts` is a flat barrel:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export * from "./createAiProvider";
|
||||||
|
export * from "./listAiProviders";
|
||||||
|
export * from "./getAiProvider";
|
||||||
|
export * from "./updateAiProvider";
|
||||||
|
export * from "./deleteAiProvider";
|
||||||
|
export * from "./types";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Anatomy of a single handler
|
||||||
|
|
||||||
|
Every handler file (`create<Entity>.ts`, etc.) follows the same shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { <table>, db } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { GetXResponse } from "@server/routers/<entity>/types";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty() // or entityId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({ /* ... */ }); // create/update only
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get", // put | post | delete
|
||||||
|
path: "/org/{orgId}/x",
|
||||||
|
description: "...",
|
||||||
|
tags: [OpenAPITags.<Entity>],
|
||||||
|
request: { params: paramsSchema, /* body: {...} for write ops, query: for list */ },
|
||||||
|
responses: { 200: { description: "Successful response" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getX(req: Request, res: Response, next: NextFunction): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString()));
|
||||||
|
}
|
||||||
|
// parse body too, if present, same pattern
|
||||||
|
|
||||||
|
// ...business logic against db...
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return next(createHttpError(HttpCode.NOT_FOUND, `X with ID ${id} not found`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<GetXResponse>(res, {
|
||||||
|
data: { /* ... */ },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "X retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules to keep consistent with the rest of the codebase:
|
||||||
|
|
||||||
|
- `z.strictObject` for params/body — rejects unknown keys.
|
||||||
|
- Params parsed first, then body; each on its own `safeParse` + early
|
||||||
|
`next(createHttpError(...))` — never throw raw errors.
|
||||||
|
- Every handler registers itself with the OpenAPI `registry` even if nobody
|
||||||
|
reads the spec directly — it's how `/api/v1/docs` stays accurate.
|
||||||
|
- Catch-all `try/catch` at the bottom: `logger.error(error)` +
|
||||||
|
generic `500` message. Never leak internal error details to the client.
|
||||||
|
- Use `response<T>(res, { data, success, error, message, status })` from
|
||||||
|
`@server/lib/response` for every response, success or otherwise (errors go
|
||||||
|
through `next(createHttpError(...))` instead, not through `response`).
|
||||||
|
- If the route already ran an access-control middleware that fetched the row
|
||||||
|
(see §5), reuse it instead of re-querying:
|
||||||
|
`req.aiProvider && req.aiProvider.providerId === providerId ? [req.aiProvider] : await db.select()...`
|
||||||
|
|
||||||
|
### List handler specifics
|
||||||
|
|
||||||
|
Pagination is a fixed shape (`page`, `pageSize`, optional `query` for
|
||||||
|
search). See `listAiProviders.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const listSchema = z.object({
|
||||||
|
pageSize: z.coerce.number<string>().int().positive().optional().catch(20).default(20),
|
||||||
|
page: z.coerce.number<string>().int().min(0).optional().catch(1).default(1),
|
||||||
|
query: z.string().optional()
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the count query and the page query in `Promise.all`, and return
|
||||||
|
`PaginatedResponse<{ items: T[] }>` (`@server/types/Pagination`) with
|
||||||
|
`{ total, pageSize, page }`.
|
||||||
|
|
||||||
|
### types.ts specifics
|
||||||
|
|
||||||
|
- Define one response type per operation: `List<Entities>Response`,
|
||||||
|
`Get<Entity>Response`, `CreateOrEdit<Entity>Response` (create and update
|
||||||
|
commonly share a response shape).
|
||||||
|
- If the raw DB row needs to be shaped for clients (decrypting secrets,
|
||||||
|
parsing a serialized column, hiding a column), put a `toPublic<Entity>()`
|
||||||
|
mapper here — see `toPublicAiProvider` for the pattern of stripping
|
||||||
|
`apiKey`/serialized columns and re-adding decrypted/parsed versions.
|
||||||
|
|
||||||
|
### validation.ts specifics
|
||||||
|
|
||||||
|
Only needed when create and update share non-trivial zod pieces (enums,
|
||||||
|
`superRefine` cross-field rules). Export the raw schemas (`z.enum([...])`)
|
||||||
|
and refinement functions, and import them into both `createX.ts` and
|
||||||
|
`updateX.ts` — see `aiProvider/validation.ts`'s
|
||||||
|
`refineProviderUpstreamFields`.
|
||||||
|
|
||||||
|
## 4. Wire up an access-control middleware (for id-scoped routes)
|
||||||
|
|
||||||
|
For routes scoped to a single row (`/x/:xId`, as opposed to
|
||||||
|
`/org/:orgId/x` create/list), add a `verify<Entity>Access` middleware in
|
||||||
|
`server/middlewares/` (or `server/private/middlewares/` for enterprise-only
|
||||||
|
entities) and export it from that directory's `index.ts`.
|
||||||
|
|
||||||
|
Pattern (`verifyAiProviderAccess.ts`):
|
||||||
|
|
||||||
|
1. Read the id param, `Number.parseInt`/validate it.
|
||||||
|
2. Load the row by id.
|
||||||
|
3. `404` if it doesn't exist.
|
||||||
|
4. Resolve the row's `orgId`, then check/attach `req.userOrg` (query
|
||||||
|
`userOrgs` if not already on the request), `403` if the user isn't in
|
||||||
|
that org.
|
||||||
|
5. Run `checkOrgAccessPolicy` if `req.orgPolicyAllowed` hasn't been resolved
|
||||||
|
yet.
|
||||||
|
6. Set `req.userOrgId`, `req.userOrgRoleIds`, and stash the row on the
|
||||||
|
request (e.g. `req.aiProvider = provider`) so downstream handlers and
|
||||||
|
`verifyUserHasAction` don't have to refetch it.
|
||||||
|
|
||||||
|
Org-scoped create/list routes (`/org/:orgId/x`) don't need a bespoke
|
||||||
|
middleware — they use the existing generic `verifyOrgAccess` from
|
||||||
|
`@server/middlewares`.
|
||||||
|
|
||||||
|
## 5. Register an action + permission check
|
||||||
|
|
||||||
|
Add one `ActionsEnum` entry per operation in `server/auth/actions.ts`,
|
||||||
|
grouped near the entity's other actions, named `create<Entity>`,
|
||||||
|
`get<Entity>`, `update<Entity>`, `delete<Entity>`, `list<Entities>`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
createAiProvider = "createAiProvider",
|
||||||
|
deleteAiProvider = "deleteAiProvider",
|
||||||
|
getAiProvider = "getAiProvider",
|
||||||
|
listAiProviders = "listAiProviders",
|
||||||
|
updateAiProvider = "updateAiProvider",
|
||||||
|
```
|
||||||
|
|
||||||
|
Every route uses `verifyUserHasAction(ActionsEnum.x)` to check the caller's
|
||||||
|
role/permissions for that action, and mutating routes (create/update/delete)
|
||||||
|
follow it with `logActionAudit(ActionsEnum.x)` to record the action in the
|
||||||
|
audit log.
|
||||||
|
|
||||||
|
## 6. Register the routes
|
||||||
|
|
||||||
|
There are four router files; which one(s) you touch depends on public vs.
|
||||||
|
private and user-facing vs. service-to-service:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `server/routers/external.ts` | Public, user-facing API. Exports `authenticated`, `unauthenticated`, `authRouter` Express routers. |
|
||||||
|
| `server/routers/internal.ts` | Public, internal service-to-service API (gerbil, badger, traefik-config) — no user auth, exports `internalRouter`. |
|
||||||
|
| `server/private/routers/external.ts` | Enterprise-only, user-facing. Imports `authenticated`/`unauthenticated`/`authRouter` **from the public `external.ts`** and re-exports them, then adds more routes on top. |
|
||||||
|
| `server/private/routers/internal.ts` | Enterprise-only, service-to-service. Same re-export trick with `internalRouter`. |
|
||||||
|
|
||||||
|
Private router files always start:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
unauthenticated as ua,
|
||||||
|
authenticated as a,
|
||||||
|
authRouter as aa
|
||||||
|
} from "@server/routers/external";
|
||||||
|
|
||||||
|
export const authenticated = a;
|
||||||
|
export const unauthenticated = ua;
|
||||||
|
export const authRouter = aa;
|
||||||
|
```
|
||||||
|
|
||||||
|
...and then call `authenticated.get/put/post/delete(...)` to bolt on
|
||||||
|
additional, enterprise-only routes on the *same* router instances the public
|
||||||
|
build uses. This is why the private build has strictly more routes than the
|
||||||
|
public build, not a divergent copy.
|
||||||
|
|
||||||
|
### Route registration order (mutating vs read)
|
||||||
|
|
||||||
|
Standard middleware chain per verb, using `alertRule`'s registrations as the
|
||||||
|
template:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Create — org-scoped, no row exists yet
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/x",
|
||||||
|
verifyValidLicense, // private/enterprise routes only
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits, // if the entity counts against a plan limit
|
||||||
|
verifyUserHasAction(ActionsEnum.createX),
|
||||||
|
logActionAudit(ActionsEnum.createX),
|
||||||
|
x.createX
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update — row-scoped
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/x/:xId", // or "/x/:xId" if id is globally unique
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess, // or verifyXAccess if globally-keyed
|
||||||
|
verifyUserHasAction(ActionsEnum.updateX),
|
||||||
|
logActionAudit(ActionsEnum.updateX),
|
||||||
|
x.updateX
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete — row-scoped
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/x/:xId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteX),
|
||||||
|
logActionAudit(ActionsEnum.deleteX),
|
||||||
|
x.deleteX
|
||||||
|
);
|
||||||
|
|
||||||
|
// List — org-scoped, read-only, no audit log
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/xs",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listXs),
|
||||||
|
x.listXs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get one — row-scoped, read-only, no audit log
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/x/:xId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getX),
|
||||||
|
x.getX
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- HTTP verbs: `PUT` = create, `POST` = update, `GET` = read, `DELETE` =
|
||||||
|
delete. This repo does not use `PATCH` for entity updates (site
|
||||||
|
provisioning keys are the one exception, using `PATCH`).
|
||||||
|
- `verifyValidLicense` is only needed on private/enterprise routes; public
|
||||||
|
OSS routes skip it.
|
||||||
|
- Use `verifyValidSubscription(tierMatrix.someFeature)` right after
|
||||||
|
`verifyValidLicense` when a feature is gated to specific SaaS tiers (see
|
||||||
|
`tierMatrix` usages in `server/private/routers/external.ts`).
|
||||||
|
- `verifyLimits` goes on create routes for entities that count against a
|
||||||
|
plan/seat limit.
|
||||||
|
- For entities keyed by a globally-unique id (not nested under `/org/:orgId`),
|
||||||
|
use the dedicated `verify<Entity>Access` middleware from §4 instead of
|
||||||
|
`verifyOrgAccess` on the row-scoped routes (see how `/ai-provider/:providerId`
|
||||||
|
uses `verifyAiProviderAccess`, while `/org/:orgId/ai-provider` create/list
|
||||||
|
use plain `verifyOrgAccess`).
|
||||||
|
- Read-only routes (`get`, `list`) skip `logActionAudit` — only mutations are
|
||||||
|
audited.
|
||||||
|
- `internal*.ts` routes are for trusted internal callers (gerbil/badger
|
||||||
|
sidecars) and generally skip user-facing auth entirely, using
|
||||||
|
`verifySessionUserMiddleware` / `verifyUserFromResourceSessionMiddleware`
|
||||||
|
instead of `verifyOrgAccess`/`verifyUserHasAction`. CRUD entities almost
|
||||||
|
never need internal router entries — only add one if a sidecar process
|
||||||
|
needs direct access to the resource.
|
||||||
|
|
||||||
|
## 7. The `#dynamic` alias (advanced — most CRUD work can ignore this)
|
||||||
|
|
||||||
|
Some middleware (e.g. `logActionAudit`) needs a real implementation in the
|
||||||
|
enterprise/SaaS build but a no-op stub in the open-source build, while
|
||||||
|
being imported by identical code in `server/routers/external.ts` in both
|
||||||
|
builds. That's done via the `#dynamic/*` import alias, which
|
||||||
|
`tsconfig.oss.json` points at `./server/*` and `tsconfig.enterprise.json` /
|
||||||
|
`tsconfig.saas.json` point at `./server/private/*`. You only need this
|
||||||
|
pattern if you're adding a genuinely dual-implementation hook; a normal
|
||||||
|
private-only CRUD entity (like `alertRule`) never touches `#dynamic` — it
|
||||||
|
just lives entirely under `server/private/` and is imported with `#private/*`
|
||||||
|
directly from `server/private/routers/external.ts`.
|
||||||
|
|
||||||
|
## 8. Checklist for a new entity
|
||||||
|
|
||||||
|
1. Add the DB table to `server/db/pg/schema/schema.ts` (and sqlite schema if
|
||||||
|
applicable).
|
||||||
|
2. Add `ActionsEnum` entries in `server/auth/actions.ts`.
|
||||||
|
3. Create `server/routers/<entity>/` (or `server/private/routers/<entity>/`):
|
||||||
|
`types.ts`, optional `validation.ts`, one file per operation, `index.ts`
|
||||||
|
barrel.
|
||||||
|
4. If routes are row-scoped by a global id, add
|
||||||
|
`verify<Entity>Access.ts` to `server/middlewares/` or
|
||||||
|
`server/private/middlewares/`, and export it from that directory's
|
||||||
|
`index.ts`.
|
||||||
|
5. Wire routes into `external.ts` (public or private) following the verb/
|
||||||
|
middleware table in §6. Add to `internal.ts` only if a sidecar needs
|
||||||
|
direct access.
|
||||||
|
6. Add license header block to every new file if it's under `server/private/`.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ server:
|
|||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
credentials: false
|
credentials: false
|
||||||
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||||
|
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: "{{.EmailSMTPHost}}"
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
@@ -36,3 +37,6 @@ flags:
|
|||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 1g
|
memory: 2g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256m
|
memory: 512m
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
condition: service_healthy{{end}}
|
||||||
|
{{if .IsRedis}}redis:
|
||||||
|
condition: service_healthy{{end}}
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- backend{{end}}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,8 +25,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
{{if .InstallGerbil}}
|
|
||||||
gerbil:
|
{{if .InstallGerbil}}gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -39,17 +47,16 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80
|
- 80:80{{end}}
|
||||||
{{end}}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -60,8 +67,50 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: pangolin
|
||||||
|
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
|
||||||
|
POSTGRES_DB: pangolin
|
||||||
|
volumes:
|
||||||
|
- ./postgres18:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pangolin"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend{{end}}
|
||||||
|
|
||||||
|
{{if .IsRedis}}redis:
|
||||||
|
image: redis:8-trixie
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--save 3600 1000
|
||||||
|
--appendonly yes
|
||||||
|
--requirepass {{.IsRedisPass}}
|
||||||
|
volumes:
|
||||||
|
- ./redis8:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- backend{{end}}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}} backend:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin_backend
|
||||||
|
internal: true{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{{if .IsRedis}}redis:
|
||||||
|
host: "redis"
|
||||||
|
port: 6379
|
||||||
|
password: "{{.IsRedisPass}}"{{end}}
|
||||||
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.45.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -53,9 +54,13 @@ type Config struct {
|
|||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
TraefikBouncerKey string
|
TraefikBouncerKey string
|
||||||
DoCrowdsecInstall bool
|
DoCrowdsecInstall bool
|
||||||
EnableGeoblocking bool
|
EnableMaxMind bool
|
||||||
Secret string
|
Secret string
|
||||||
IsEnterprise bool
|
IsEnterprise bool
|
||||||
|
IsPostgreSQL bool
|
||||||
|
IsPostgreSQLPass string
|
||||||
|
IsRedis bool
|
||||||
|
IsRedisPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedContainer string
|
type SupportedContainer string
|
||||||
@@ -66,8 +71,14 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var redisFlag *bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
|
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
|
|
||||||
fmt.Println("Welcome to the Pangolin installer!")
|
fmt.Println("Welcome to the Pangolin installer!")
|
||||||
@@ -119,11 +130,11 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
// Download MaxMind database if requested
|
// Download MaxMind Country / ASN database if requested
|
||||||
if config.EnableGeoblocking {
|
if config.EnableMaxMind {
|
||||||
fmt.Println("\n=== Downloading MaxMind Database ===")
|
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
|
||||||
fmt.Println("You can download it manually later if needed.")
|
fmt.Println("You can download it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,15 +195,15 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
|
||||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -200,13 +211,15 @@ func main() {
|
|||||||
// Now you need to update your config file accordingly to enable geoblocking
|
// Now you need to update your config file accordingly to enable geoblocking
|
||||||
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
||||||
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
||||||
fmt.Println("Add the following line under the 'server' section:")
|
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
|
||||||
|
fmt.Println("Add the following lines under the 'server' section:")
|
||||||
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
||||||
|
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !checkIsCrowdsecInstalledInCompose() {
|
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
|
||||||
fmt.Println("\n=== CrowdSec Install ===")
|
fmt.Println("\n=== CrowdSec Install ===")
|
||||||
// check if crowdsec is installed
|
// check if crowdsec is installed
|
||||||
if readBool("Would you like to install CrowdSec?", false) {
|
if readBool("Would you like to install CrowdSec?", false) {
|
||||||
@@ -480,6 +493,17 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
|
if config.IsEnterprise {
|
||||||
|
if *redisFlag {
|
||||||
|
config.IsRedis = true
|
||||||
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
||||||
|
if config.IsPostgreSQL {
|
||||||
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
|
}
|
||||||
|
|
||||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
@@ -523,7 +547,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -776,29 +800,42 @@ func checkPortsAvailable(port int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadMaxMindDatabase() error {
|
func downloadMaxMindDatabase() error {
|
||||||
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
|
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
|
||||||
|
|
||||||
// Download the GeoLite2 Country database
|
// Download the GeoLite2 Country databases
|
||||||
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
||||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
|
||||||
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the database
|
// Extract the Country database
|
||||||
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the .mmdb file and move it to the config directory
|
// Find the .mmdb file and move it to the config directory
|
||||||
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
||||||
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
|
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
|
||||||
|
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the downloaded files
|
// Clean up the downloaded files
|
||||||
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
|
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
|
||||||
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
|
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
|
||||||
|
}
|
||||||
|
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
|
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,8 +152,8 @@
|
|||||||
"shareErrorSelectResource": "請選擇一個資源",
|
"shareErrorSelectResource": "請選擇一個資源",
|
||||||
"proxyResourceTitle": "管理公開資源",
|
"proxyResourceTitle": "管理公開資源",
|
||||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||||
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
||||||
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||||
"clientResourceTitle": "管理私有資源",
|
"clientResourceTitle": "管理私有資源",
|
||||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||||
"privateResourcesBannerTitle": "零信任私有存取",
|
"privateResourcesBannerTitle": "零信任私有存取",
|
||||||
@@ -489,7 +489,7 @@
|
|||||||
"createdAt": "創建於",
|
"createdAt": "創建於",
|
||||||
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
|
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
|
||||||
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
|
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
|
||||||
"proxyEnableSSL": "啟用 SSL",
|
"proxyEnableSSL": "啟用 TLS",
|
||||||
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
|
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
|
||||||
"target": "目標",
|
"target": "目標",
|
||||||
"configureTarget": "配置目標",
|
"configureTarget": "配置目標",
|
||||||
@@ -1099,6 +1099,7 @@
|
|||||||
"actionGenerateAccessToken": "生成訪問令牌",
|
"actionGenerateAccessToken": "生成訪問令牌",
|
||||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||||
"actionListAccessTokens": "訪問令牌",
|
"actionListAccessTokens": "訪問令牌",
|
||||||
|
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
||||||
"actionCreateResourceRule": "創建資源規則",
|
"actionCreateResourceRule": "創建資源規則",
|
||||||
"actionDeleteResourceRule": "刪除資源規則",
|
"actionDeleteResourceRule": "刪除資源規則",
|
||||||
"actionListResourceRules": "列出資源規則",
|
"actionListResourceRules": "列出資源規則",
|
||||||
@@ -1763,7 +1764,7 @@
|
|||||||
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
|
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
|
||||||
"introTitle": "託管式自架 Pangolin",
|
"introTitle": "託管式自架 Pangolin",
|
||||||
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
|
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
|
||||||
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
|
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
|
||||||
"benefitSimplerOperations": {
|
"benefitSimplerOperations": {
|
||||||
"title": "簡單的操作",
|
"title": "簡單的操作",
|
||||||
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
|
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin();
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
// read allowedDevOrigins.json if it exists
|
||||||
|
let allowedDevOrigins: string[] = [];
|
||||||
|
const allowedDevOriginsPath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
"allowedDevOrigins.json"
|
||||||
|
);
|
||||||
|
if (fs.existsSync(allowedDevOriginsPath)) {
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
|
||||||
|
allowedDevOrigins = JSON.parse(data);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
eslint: {
|
reactCompiler: true,
|
||||||
ignoreDuringBuilds: true
|
transpilePackages: ["@novnc/novnc"],
|
||||||
},
|
output: "standalone",
|
||||||
experimental: {
|
allowedDevOrigins,
|
||||||
reactCompiler: true
|
async redirects() {
|
||||||
},
|
return [
|
||||||
output: "standalone"
|
{
|
||||||
|
source: "/:orgId/settings/resources/proxy/:path*",
|
||||||
|
destination: "/:orgId/settings/resources/public/:path*",
|
||||||
|
permanent: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "/:orgId/settings/resources/client/:path*",
|
||||||
|
destination: "/:orgId/settings/resources/private/:path*",
|
||||||
|
permanent: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withNextIntl(nextConfig);
|
export default withNextIntl(nextConfig);
|
||||||
|
|||||||
@@ -32,13 +32,15 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"@headlessui/react": "2.2.9",
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@headlessui/react": "2.2.10",
|
||||||
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
|
"@novnc/novnc": "^1.7.0",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
"@oslojs/encoding": "1.1.0",
|
"@oslojs/encoding": "1.1.0",
|
||||||
"@radix-ui/react-avatar": "1.1.11",
|
"@radix-ui/react-avatar": "1.1.11",
|
||||||
@@ -59,16 +61,20 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.18.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -80,77 +86,77 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"gpt-tokenizer": "^3.4.0",
|
||||||
|
"helmet": "8.2.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.11.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.3.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "1.17.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.3.1",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.13.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "9.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.21.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.35.6",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.76.1",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"semver": "7.8.1",
|
||||||
"semver": "7.7.4",
|
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "22.2.0",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.21.0",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.69.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.9.2",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.9.1",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sshpk": "1.17.4",
|
"@types/sshpk": "1.17.4",
|
||||||
@@ -160,21 +166,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.4.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.23",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.5.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.60.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 556 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_29_2)">
|
||||||
|
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#0B0B0B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_29_2">
|
||||||
|
<rect width="256" height="257" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<svg width="256" height="257" viewBox="0 0 256 257" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_29_2)">
|
||||||
|
<path d="M50.2278 170.321L100.585 142.064L101.428 139.601L100.585 138.24H98.1225L89.6972 137.722L60.9215 136.944L35.9696 135.907L11.7954 134.611L5.70329 133.314L0 125.796L0.583291 122.037L5.70329 118.603L13.0268 119.251L29.2294 120.352L53.5332 122.037L71.1615 123.074L97.28 125.796H101.428L102.011 124.111L100.585 123.074L99.4835 122.037L74.3372 104.992L47.117 86.9752L32.8587 76.6056L25.1463 71.3559L21.2577 66.4304L19.5727 55.6719L26.5722 47.9595L35.9696 48.6076L38.3676 49.2557L47.8947 56.5792L68.2451 72.3281L94.8172 91.9008L98.7058 95.1413L100.261 94.0395L100.456 93.2618L98.7058 90.3453L84.2532 64.2268L68.8284 37.6547L61.9585 26.637L60.1438 20.0263C59.4957 17.3043 59.042 15.0359 59.042 12.2491L67.0137 1.42582L71.4208 0L82.0496 1.42582L86.5215 5.31443L93.1321 20.4152L103.826 44.2005L120.417 76.5408L125.278 86.1327L127.87 95.0116L128.843 97.7337H130.528V96.1782L131.889 77.9666L134.416 55.6071L136.879 26.8314L137.722 18.7301L141.74 9.00861L149.711 3.75899L155.933 6.74025L161.053 14.0638L160.34 18.7949L157.294 38.562L151.332 69.5413L147.443 90.2805H149.711L152.304 87.6881L162.803 73.7539L180.431 51.7185L188.209 42.9691L197.282 33.3124L203.115 28.7109H214.133L222.234 40.7656L218.605 53.2091L207.263 67.597L197.865 79.7813L184.385 97.9281L175.959 112.446L176.737 113.612L178.746 113.418L209.207 106.937L225.669 103.955L245.306 100.585L254.185 104.733L255.157 108.946L251.658 117.566L230.659 122.75L206.031 127.676L169.349 136.361L168.895 136.685L169.414 137.333L185.94 138.888L193.005 139.277H210.309L242.52 141.675L250.945 147.249L256 154.054L255.157 159.238L242.195 165.849L224.697 161.701L183.866 151.98L169.867 148.48H167.923V149.647L179.589 161.053L200.976 180.367L227.743 205.254L229.104 211.411L225.669 216.271L222.039 215.753L198.513 198.06L189.44 190.088L168.895 172.784H167.534V174.598L172.265 181.533L197.282 219.123L198.578 230.659L196.764 234.418L190.283 236.687L183.153 235.39L168.506 214.846L153.406 191.708L141.221 170.969L139.731 171.812L132.537 249.26L129.167 253.213L121.389 256.194L114.908 251.269L111.473 243.297L114.908 227.548L119.056 207.004L122.426 190.671L125.472 170.386L127.287 163.646L127.157 163.192L125.667 163.386L110.372 184.385L87.1048 215.818L68.6987 235.52L64.2916 237.27L56.6441 233.316L57.357 226.252L61.6344 219.966L87.1048 187.561L102.465 167.469L112.381 155.868L112.316 154.183H111.733L44.0709 198.125L32.0162 199.68L26.8314 194.819L27.4795 186.848L29.9423 184.255L50.2927 170.256L50.2278 170.321Z" fill="#F0EFEC"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_29_2">
|
||||||
|
<rect width="256" height="257" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #26251e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0 {
|
||||||
|
fill: #edecec;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 793 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="716" height="716" viewBox="157 157 402 402" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M508.749 317.399C516.777 287.314 508.991 253.884 485.389 230.282C461.788 206.681 428.36 198.895 398.273 206.923C376.231 184.928 343.39 174.956 311.148 183.596C278.906 192.234 255.45 217.292 247.36 247.361C217.291 255.451 192.233 278.91 183.595 311.149C174.957 343.391 184.927 376.232 206.924 398.274C198.896 428.359 206.683 461.789 230.284 485.391C253.885 508.992 287.313 516.779 317.401 508.75C339.442 530.745 372.286 540.717 404.525 532.079C436.767 523.441 460.223 498.384 468.313 468.315C498.383 460.224 523.44 436.766 532.078 404.526C540.716 372.285 530.747 339.443 508.749 317.402V317.399ZM470.899 244.776C486.892 260.77 493.488 282.601 490.687 303.412L415.577 260.046C412.411 258.218 408.509 258.218 405.345 260.046L317.401 310.82V277.526C317.401 275.191 318.652 273.005 320.676 271.837L387.644 233.174C414.178 218.353 448.346 222.223 470.901 244.776H470.899ZM357.837 311.144L398.275 334.491V381.185L357.837 404.532L317.398 381.185V334.491L357.837 311.144ZM264.776 269.693C265.207 239.305 285.644 211.649 316.453 203.393C338.3 197.54 360.505 202.744 377.127 215.573L302.014 258.937C298.848 260.764 296.898 264.144 296.898 267.798V369.346L268.065 352.699C266.043 351.531 264.776 349.353 264.776 347.017V269.691V269.693ZM203.391 316.454C209.244 294.608 224.854 277.978 244.276 269.999V356.73C244.276 360.384 246.226 363.763 249.392 365.591L337.337 416.365L308.503 433.013C306.481 434.181 303.961 434.188 301.939 433.02L234.971 394.357C208.868 378.789 195.138 347.261 203.391 316.454ZM244.775 470.9C228.781 454.906 222.186 433.075 224.986 412.264L300.096 455.63C303.263 457.457 307.164 457.457 310.328 455.63L398.273 404.856V438.149C398.273 440.485 397.022 442.671 394.997 443.839L328.029 482.502C301.495 497.322 267.327 493.452 244.772 470.9H244.775ZM450.897 445.982C450.466 476.371 430.029 504.027 399.22 512.283C377.373 518.136 355.168 512.932 338.547 500.102L413.659 456.738C416.826 454.911 418.775 451.532 418.775 447.877V346.329L447.609 362.977C449.631 364.145 450.897 366.323 450.897 368.659V445.985V445.982ZM512.282 399.221C506.429 421.068 490.819 437.697 471.397 445.676V358.946C471.397 355.292 469.448 351.912 466.281 350.085L378.336 299.311L407.17 282.663C409.192 281.495 411.712 281.487 413.734 282.655L480.702 321.318C506.805 336.887 520.536 368.415 512.282 399.221Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86274)'><mask id='mask0_1401_86274' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86274)'><path d='M180 240H60V120H180V240Z' fill='#CFCECD'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#211E1E'/></g></g><defs><clipPath id='clip0_1401_86274'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 577 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg width='240' height='300' viewBox='0 0 240 300' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1401_86283)'><mask id='mask0_1401_86283' style='mask-type:luminance' maskUnits='userSpaceOnUse' x='0' y='0' width='240' height='300'><path d='M240 0H0V300H240V0Z' fill='white'/></mask><g mask='url(#mask0_1401_86283)'><path d='M180 240H60V120H180V240Z' fill='#4B4646'/><path d='M180 60H60V240H180V60ZM240 300H0V0H240V300Z' fill='#F1ECEC'/></g></g><defs><clipPath id='clip0_1401_86283'><rect width='240' height='300' fill='white'/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 577 B |
@@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import helmet from "helmet";
|
||||||
|
import cors from "cors";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import {
|
||||||
|
errorHandlerMiddleware,
|
||||||
|
notFoundMiddleware
|
||||||
|
} from "@server/middlewares";
|
||||||
|
import { createAiGatewayRouter } from "@server/routers/aiGateway";
|
||||||
|
|
||||||
|
const aiGatewayPort = config.getRawConfig().server.ai_gateway_port;
|
||||||
|
|
||||||
|
export function createAiGatewayServer() {
|
||||||
|
const aiGatewayServer = express();
|
||||||
|
|
||||||
|
const trustProxy = config.getRawConfig().server.trust_proxy;
|
||||||
|
if (trustProxy) {
|
||||||
|
aiGatewayServer.set("trust proxy", trustProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
aiGatewayServer.use(helmet());
|
||||||
|
aiGatewayServer.use(cors());
|
||||||
|
aiGatewayServer.use(express.json());
|
||||||
|
|
||||||
|
aiGatewayServer.use(createAiGatewayRouter());
|
||||||
|
|
||||||
|
aiGatewayServer.use(notFoundMiddleware);
|
||||||
|
aiGatewayServer.use(errorHandlerMiddleware);
|
||||||
|
|
||||||
|
aiGatewayServer.listen(aiGatewayPort, (err?: any) => {
|
||||||
|
if (err) throw err;
|
||||||
|
logger.info(
|
||||||
|
`AI gateway server is running on http://localhost:${aiGatewayPort}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return aiGatewayServer;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export enum ActionsEnum {
|
export enum ActionsEnum {
|
||||||
createOrgUser = "createOrgUser",
|
createOrgUser = "createOrgUser",
|
||||||
@@ -20,6 +21,8 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
|
updateSiteApprovals = "updateSiteApprovals",
|
||||||
|
restartSite = "restartSite",
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
@@ -47,6 +50,8 @@ export enum ActionsEnum {
|
|||||||
setResourceUsers = "setResourceUsers",
|
setResourceUsers = "setResourceUsers",
|
||||||
setResourceRoles = "setResourceRoles",
|
setResourceRoles = "setResourceRoles",
|
||||||
listResourceUsers = "listResourceUsers",
|
listResourceUsers = "listResourceUsers",
|
||||||
|
listResourceAiModels = "listResourceAiModels",
|
||||||
|
setResourceAiModels = "setResourceAiModels",
|
||||||
// removeRoleSite = "removeRoleSite",
|
// removeRoleSite = "removeRoleSite",
|
||||||
// addRoleAction = "addRoleAction",
|
// addRoleAction = "addRoleAction",
|
||||||
// removeRoleAction = "removeRoleAction",
|
// removeRoleAction = "removeRoleAction",
|
||||||
@@ -69,6 +74,7 @@ export enum ActionsEnum {
|
|||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
getResourceWhitelist = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
generateAccessToken = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
|
createResourceSessionToken = "createResourceSessionToken",
|
||||||
deleteAcessToken = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
listAccessTokens = "listAccessTokens",
|
listAccessTokens = "listAccessTokens",
|
||||||
createResourceRule = "createResourceRule",
|
createResourceRule = "createResourceRule",
|
||||||
@@ -147,12 +153,59 @@ export enum ActionsEnum {
|
|||||||
createAlertRule = "createAlertRule",
|
createAlertRule = "createAlertRule",
|
||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
|
testAlertRule = "testAlertRule",
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
|
listOrgLabels = "listOrgLabels",
|
||||||
|
createOrgLabel = "createOrgLabel",
|
||||||
|
updateOrgLabel = "updateOrgLabel",
|
||||||
|
deleteOrgLabel = "deleteOrgLabel",
|
||||||
|
attachLabelToItem = "attachLabelToItem",
|
||||||
|
detachLabelFromItem = "detachLabelFromItem",
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
deleteHealthCheck = "deleteHealthCheck",
|
deleteHealthCheck = "deleteHealthCheck",
|
||||||
listHealthChecks = "listHealthChecks"
|
listHealthChecks = "listHealthChecks",
|
||||||
|
createBrowserGatewayTarget = "createBrowserGatewayTarget",
|
||||||
|
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
|
||||||
|
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
|
||||||
|
getBrowserGatewayTarget = "getBrowserGatewayTarget",
|
||||||
|
listBrowserGatewayTargets = "listBrowserGatewayTargets",
|
||||||
|
listResourcePolicies = "listResourcePolicies",
|
||||||
|
getResourcePolicy = "getResourcePolicy",
|
||||||
|
createResourcePolicy = "createResourcePolicy",
|
||||||
|
updateResourcePolicy = "updateResourcePolicy",
|
||||||
|
deleteResourcePolicy = "deleteResourcePolicy",
|
||||||
|
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||||
|
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||||
|
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||||
|
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||||
|
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||||
|
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||||
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
|
setResourcePolicyRules = "setResourcePolicyRules",
|
||||||
|
createOrgWideLauncherView = "createOrgWideLauncherView",
|
||||||
|
createAiProvider = "createAiProvider",
|
||||||
|
deleteAiProvider = "deleteAiProvider",
|
||||||
|
getAiProvider = "getAiProvider",
|
||||||
|
listAiProviders = "listAiProviders",
|
||||||
|
updateAiProvider = "updateAiProvider",
|
||||||
|
createAiModel = "createAiModel",
|
||||||
|
deleteAiModel = "deleteAiModel",
|
||||||
|
getAiModel = "getAiModel",
|
||||||
|
listAiModels = "listAiModels",
|
||||||
|
updateAiModel = "updateAiModel",
|
||||||
|
createAiBudget = "createAiBudget",
|
||||||
|
deleteAiBudget = "deleteAiBudget",
|
||||||
|
getAiBudget = "getAiBudget",
|
||||||
|
listAiBudgets = "listAiBudgets",
|
||||||
|
updateAiBudget = "updateAiBudget",
|
||||||
|
createVirtualApiKey = "createVirtualApiKey",
|
||||||
|
deleteVirtualApiKey = "deleteVirtualApiKey",
|
||||||
|
getVirtualApiKey = "getVirtualApiKey",
|
||||||
|
listVirtualApiKeys = "listVirtualApiKeys",
|
||||||
|
updateVirtualApiKey = "updateVirtualApiKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
@@ -185,6 +238,23 @@ export async function checkUserActionPermission(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no direct permission, check role-based permission (any of user's roles)
|
||||||
|
const roleActionPermission = await db
|
||||||
|
.select()
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.actionId, actionId),
|
||||||
|
inArray(roleActions.roleId, userOrgRoleIds),
|
||||||
|
eq(roleActions.orgId, req.userOrgId!)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleActionPermission.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user has direct permission for the action in the current org
|
// Check if the user has direct permission for the action in the current org
|
||||||
const userActionPermission = await db
|
const userActionPermission = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -202,20 +272,7 @@ export async function checkUserActionPermission(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no direct permission, check role-based permission (any of user's roles)
|
return false;
|
||||||
const roleActionPermission = await db
|
|
||||||
.select()
|
|
||||||
.from(roleActions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleActions.actionId, actionId),
|
|
||||||
inArray(roleActions.roleId, userOrgRoleIds),
|
|
||||||
eq(roleActions.orgId, req.userOrgId!)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
return roleActionPermission.length > 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking user action permission:", error);
|
console.error("Error checking user action permission:", error);
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { roleResources, userResources } from "@server/db";
|
import {
|
||||||
|
rolePolicies,
|
||||||
|
roleResources,
|
||||||
|
resources,
|
||||||
|
userPolicies,
|
||||||
|
userResources
|
||||||
|
} from "@server/db";
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
@@ -11,9 +17,14 @@ export async function canUserAccessResource({
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const roleResourceAccess =
|
const [
|
||||||
|
roleResourceAccess,
|
||||||
|
rolePolicyAccess,
|
||||||
|
userResourceAccess,
|
||||||
|
userPolicyAccess
|
||||||
|
] = await Promise.all([
|
||||||
roleIds.length > 0
|
roleIds.length > 0
|
||||||
? await db
|
? db
|
||||||
.select()
|
.select()
|
||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(
|
.where(
|
||||||
@@ -23,26 +34,87 @@ export async function canUserAccessResource({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: [];
|
: [],
|
||||||
|
roleIds.length > 0
|
||||||
if (roleResourceAccess.length > 0) {
|
? db
|
||||||
return true;
|
.select({
|
||||||
}
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
const userResourceAccess = await db
|
})
|
||||||
.select()
|
.from(rolePolicies)
|
||||||
.from(userResources)
|
.innerJoin(
|
||||||
.where(
|
resources,
|
||||||
and(
|
// Shared policy wins; only use default policy when no shared
|
||||||
eq(userResources.userId, userId),
|
// policy is assigned to the resource.
|
||||||
eq(userResources.resourceId, resourceId)
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
: [],
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
userId: userPolicies.userId,
|
||||||
|
resourcePolicyId: userPolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
if (userResourceAccess.length > 0) {
|
return (
|
||||||
return true;
|
roleResourceAccess.length > 0 ||
|
||||||
}
|
rolePolicyAccess.length > 0 ||
|
||||||
|
userResourceAccess.length > 0 ||
|
||||||
return false;
|
userPolicyAccess.length > 0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
@@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function invalidateAllSessionsExceptCurrent(
|
||||||
|
userId: string,
|
||||||
|
currentSessionId: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const userSessions = await trx
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
ne(sessions.sessionId, currentSessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userSessions.length > 0) {
|
||||||
|
await trx.delete(resourceSessions).where(
|
||||||
|
inArray(
|
||||||
|
resourceSessions.userSessionId,
|
||||||
|
userSessions.map((s) => s.sessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.delete(sessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sessions.userId, userId),
|
||||||
|
ne(sessions.sessionId, currentSessionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to invalidate user sessions except current", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
token: string,
|
token: string,
|
||||||
isSecure: boolean,
|
isSecure: boolean,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export async function createResourceSession(opts: {
|
|||||||
userSessionId?: string | null;
|
userSessionId?: string | null;
|
||||||
whitelistId?: number | null;
|
whitelistId?: number | null;
|
||||||
accessTokenId?: string | null;
|
accessTokenId?: string | null;
|
||||||
|
policyPasswordId?: number | null;
|
||||||
|
policyPincodeId?: number | null;
|
||||||
|
policyWhitelistId?: number | null;
|
||||||
doNotExtend?: boolean;
|
doNotExtend?: boolean;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
sessionLength?: number | null;
|
sessionLength?: number | null;
|
||||||
@@ -28,7 +31,10 @@ export async function createResourceSession(opts: {
|
|||||||
!opts.pincodeId &&
|
!opts.pincodeId &&
|
||||||
!opts.whitelistId &&
|
!opts.whitelistId &&
|
||||||
!opts.accessTokenId &&
|
!opts.accessTokenId &&
|
||||||
!opts.userSessionId
|
!opts.userSessionId &&
|
||||||
|
!opts.policyPasswordId &&
|
||||||
|
!opts.policyPincodeId &&
|
||||||
|
!opts.policyWhitelistId
|
||||||
) {
|
) {
|
||||||
throw new Error("Auth method must be provided");
|
throw new Error("Auth method must be provided");
|
||||||
}
|
}
|
||||||
@@ -49,6 +55,9 @@ export async function createResourceSession(opts: {
|
|||||||
whitelistId: opts.whitelistId || null,
|
whitelistId: opts.whitelistId || null,
|
||||||
doNotExtend: opts.doNotExtend || false,
|
doNotExtend: opts.doNotExtend || false,
|
||||||
accessTokenId: opts.accessTokenId || null,
|
accessTokenId: opts.accessTokenId || null,
|
||||||
|
policyPasswordId: opts.policyPasswordId || null,
|
||||||
|
policyPincodeId: opts.policyPincodeId || null,
|
||||||
|
policyWhitelistId: opts.policyWhitelistId || null,
|
||||||
isRequestToken: opts.isRequestToken || false,
|
isRequestToken: opts.isRequestToken || false,
|
||||||
userSessionId: opts.userSessionId || null,
|
userSessionId: opts.userSessionId || null,
|
||||||
issuedAt: new Date().getTime()
|
issuedAt: new Date().getTime()
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
users,
|
||||||
|
virtualApiKeyResources,
|
||||||
|
virtualApiKeys,
|
||||||
|
type VirtualApiKey
|
||||||
|
} from "@server/db";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
decryptVirtualApiKeyToken,
|
||||||
|
VIRTUAL_API_KEY_PREFIX,
|
||||||
|
looksLikeVirtualApiKeyCredential
|
||||||
|
} from "@server/lib/virtualApiKey";
|
||||||
|
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { isWithinExpirationDate } from "oslo";
|
||||||
|
|
||||||
|
export type VirtualApiKeyCredential = {
|
||||||
|
virtualApiKeyId: string;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VirtualApiKeyUserData = {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
name: string | null;
|
||||||
|
role: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getHeader(
|
||||||
|
headers: Record<string, string> | undefined,
|
||||||
|
name: string
|
||||||
|
): string | undefined {
|
||||||
|
if (!headers) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (headers[name] !== undefined) {
|
||||||
|
return headers[name];
|
||||||
|
}
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (key.toLowerCase() === lower) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVkCredential(
|
||||||
|
raw: string | undefined
|
||||||
|
): VirtualApiKeyCredential | null {
|
||||||
|
if (!raw || !looksLikeVirtualApiKeyCredential(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const withoutPrefix = raw.trim().slice(VIRTUAL_API_KEY_PREFIX.length);
|
||||||
|
const dot = withoutPrefix.indexOf(".");
|
||||||
|
return {
|
||||||
|
virtualApiKeyId: withoutPrefix.slice(0, dot),
|
||||||
|
secret: withoutPrefix.slice(dot + 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractVirtualApiKeyCredential(
|
||||||
|
headers: Record<string, string> | undefined
|
||||||
|
): VirtualApiKeyCredential | null {
|
||||||
|
if (!headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = getHeader(headers, "authorization");
|
||||||
|
if (authorization) {
|
||||||
|
const bearerMatch = authorization.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (bearerMatch) {
|
||||||
|
const credential = parseVkCredential(bearerMatch[1]);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const splunkMatch = authorization.match(/^Splunk\s+(.+)$/i);
|
||||||
|
if (splunkMatch) {
|
||||||
|
const credential = parseVkCredential(splunkMatch[1]);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfAig = getHeader(headers, "cf-aig-authorization");
|
||||||
|
if (cfAig) {
|
||||||
|
const bearerMatch = cfAig.match(/^Bearer\s+(.+)$/i);
|
||||||
|
const credential = parseVkCredential(
|
||||||
|
bearerMatch ? bearerMatch[1] : cfAig
|
||||||
|
);
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of ["x-api-key", "x-goog-api-key"] as const) {
|
||||||
|
const credential = parseVkCredential(getHeader(headers, name));
|
||||||
|
if (credential) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildUserData(
|
||||||
|
userId: string,
|
||||||
|
orgId: string
|
||||||
|
): Promise<VirtualApiKeyUserData | undefined> {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||||
|
if (userOrgRoles.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userHasResourceAccess(
|
||||||
|
userId: string,
|
||||||
|
resourceId: number,
|
||||||
|
orgId: string
|
||||||
|
): Promise<{ allowed: boolean; userData?: VirtualApiKeyUserData }> {
|
||||||
|
const [user] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.getRawConfig().flags?.require_email_verification &&
|
||||||
|
!user.emailVerified
|
||||||
|
) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
|
||||||
|
if (userOrgRoles.length === 0) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = await canUserAccessResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleIds: userOrgRoles.map((r) => r.roleId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
userData: {
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function manualKeyHasResourceAccess(
|
||||||
|
key: VirtualApiKey,
|
||||||
|
resourceId: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (key.allResources) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.select({ resourceId: virtualApiKeyResources.resourceId })
|
||||||
|
.from(virtualApiKeyResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(virtualApiKeyResources.virtualApiKeyId, key.virtualApiKeyId),
|
||||||
|
eq(virtualApiKeyResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function touchLastUsedAt(virtualApiKeyId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(virtualApiKeys)
|
||||||
|
.set({ lastUsedAt: Date.now() })
|
||||||
|
.where(eq(virtualApiKeys.virtualApiKeyId, virtualApiKeyId));
|
||||||
|
} catch {
|
||||||
|
// Best-effort; do not fail auth on audit timestamp updates.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyVirtualApiKey({
|
||||||
|
credential,
|
||||||
|
resourceId,
|
||||||
|
orgId
|
||||||
|
}: {
|
||||||
|
credential: VirtualApiKeyCredential;
|
||||||
|
resourceId: number;
|
||||||
|
orgId: string;
|
||||||
|
}): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
error?: string;
|
||||||
|
key?: VirtualApiKey;
|
||||||
|
userData?: VirtualApiKeyUserData;
|
||||||
|
}> {
|
||||||
|
const [key] = await db
|
||||||
|
.select()
|
||||||
|
.from(virtualApiKeys)
|
||||||
|
.where(eq(virtualApiKeys.virtualApiKeyId, credential.virtualApiKeyId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return { valid: false, error: "Virtual API key not found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.orgId !== orgId) {
|
||||||
|
return { valid: false, error: "Virtual API key org mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let plaintext: string;
|
||||||
|
try {
|
||||||
|
plaintext = decryptVirtualApiKeyToken(key.token);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: "Virtual API key secret is invalid" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plaintext !== credential.secret) {
|
||||||
|
return { valid: false, error: "Invalid virtual API key secret" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.expiresAt && !isWithinExpirationDate(new Date(key.expiresAt))) {
|
||||||
|
return { valid: false, error: "Virtual API key has expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.kind === "manual") {
|
||||||
|
const scoped = await manualKeyHasResourceAccess(key, resourceId);
|
||||||
|
if (!scoped) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "Virtual API key is not scoped to this resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let userData: VirtualApiKeyUserData | undefined;
|
||||||
|
if (key.userId) {
|
||||||
|
userData = await buildUserData(key.userId, orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await touchLastUsedAt(key.virtualApiKeyId);
|
||||||
|
return { valid: true, key, userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.kind === "user") {
|
||||||
|
if (!key.userId) {
|
||||||
|
return { valid: false, error: "User virtual API key has no user" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await userHasResourceAccess(
|
||||||
|
key.userId,
|
||||||
|
resourceId,
|
||||||
|
orgId
|
||||||
|
);
|
||||||
|
if (!access.allowed || !access.userData) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: "User is not allowed to access this resource"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await touchLastUsedAt(key.virtualApiKeyId);
|
||||||
|
return { valid: true, key, userData: access.userData };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: "Unknown virtual API key kind" };
|
||||||
|
}
|
||||||
@@ -3,12 +3,16 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
|
|||||||
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||||
|
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||||
|
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await stopPingAccumulator();
|
await stopPingAccumulator();
|
||||||
await flushBandwidthToDb();
|
await flushBandwidthToDb();
|
||||||
await flushConnectionLogToDb();
|
await flushConnectionLogToDb();
|
||||||
await flushSiteBandwidthToDb();
|
await flushSiteBandwidthToDb();
|
||||||
|
await shutdownUsageRecorder();
|
||||||
|
await shutdownAiSessionLogger();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -795,10 +795,13 @@ export const COUNTRIES = [
|
|||||||
name: "Serbia",
|
name: "Serbia",
|
||||||
code: "RS"
|
code: "RS"
|
||||||
},
|
},
|
||||||
{
|
// Removed as this is a deprecated ISO country code, not supported anymore
|
||||||
name: "Serbia and Montenegro",
|
// Also the individual flags for Serbia & Montenegro are already included in the list
|
||||||
code: "CS"
|
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
|
||||||
},
|
// {
|
||||||
|
// name: "Serbia and Montenegro",
|
||||||
|
// code: "CS"
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
name: "Seychelles",
|
name: "Seychelles",
|
||||||
code: "SC"
|
code: "SC"
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { clients, db, resources, siteResources } from "@server/db";
|
import {
|
||||||
|
aiProviders,
|
||||||
|
clients,
|
||||||
|
db,
|
||||||
|
resourcePolicies,
|
||||||
|
resources,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
import { randomInt } from "crypto";
|
import { randomInt } from "crypto";
|
||||||
import { exitNodes, sites } from "@server/db";
|
import { exitNodes, sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -107,6 +114,61 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getUniqueProviderName(orgId: string): Promise<string> {
|
||||||
|
let loops = 0;
|
||||||
|
while (true) {
|
||||||
|
if (loops > 100) {
|
||||||
|
throw new Error("Could not generate a unique name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = generateName();
|
||||||
|
|
||||||
|
const aiProviderCount = await db
|
||||||
|
.select({
|
||||||
|
niceId: aiProviders.niceId,
|
||||||
|
orgId: aiProviders.orgId
|
||||||
|
})
|
||||||
|
.from(aiProviders)
|
||||||
|
.where(
|
||||||
|
and(eq(aiProviders.niceId, name), eq(aiProviders.orgId, orgId))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (aiProviderCount.length === 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
loops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUniqueResourcePolicyName(
|
||||||
|
orgId: string
|
||||||
|
): Promise<string> {
|
||||||
|
let loops = 0;
|
||||||
|
while (true) {
|
||||||
|
if (loops > 100) {
|
||||||
|
throw new Error("Could not generate a unique name");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = generateName();
|
||||||
|
const policyCount = await db
|
||||||
|
.select({
|
||||||
|
niceId: resourcePolicies.niceId,
|
||||||
|
orgId: resourcePolicies.orgId
|
||||||
|
})
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, name),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyCount.length === 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
loops++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUniqueSiteResourceName(
|
export async function getUniqueSiteResourceName(
|
||||||
orgId: string
|
orgId: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
|
export { alias } from "drizzle-orm/pg-core";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
|||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
import { db as mainDb } from "./driver";
|
||||||
import { createPool } from "./poolConfig";
|
import { createPool } from "./poolConfig";
|
||||||
|
|
||||||
function createLogsDb() {
|
function createLogsDb() {
|
||||||
@@ -63,8 +63,7 @@ function createLogsDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections =
|
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
||||||
poolConfig?.max_replica_connections || 20;
|
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = createPool(
|
||||||
conn.connection_string,
|
conn.connection_string,
|
||||||
@@ -91,4 +90,4 @@ function createLogsDb() {
|
|||||||
|
|
||||||
export const logsDb = createLogsDb();
|
export const logsDb = createLogsDb();
|
||||||
export default logsDb;
|
export default logsDb;
|
||||||
export const primaryLogsDb = logsDb.$primary;
|
export const primaryLogsDb = logsDb.$primary;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import config from "@server/lib/config";
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
pool.on("error", (err) => {
|
pool.on("error", (err) => {
|
||||||
// This catches errors on idle clients in the pool. Without this
|
// This catches errors on idle clients in the pool. Without this
|
||||||
// handler an unexpected disconnect would crash the process.
|
// handler an unexpected disconnect would crash the process.
|
||||||
logger.error(
|
console.error(
|
||||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -36,10 +36,32 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
// Set a statement timeout on every new connection so a single slow
|
// Set a statement timeout on every new connection so a single slow
|
||||||
// query can't block the pool forever
|
// query can't block the pool forever
|
||||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||||
logger.warn(
|
console.warn(
|
||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Disable JIT compilation for this connection. Our hot-path queries
|
||||||
|
// (e.g. resource-by-domain lookups) join many tables but only ever
|
||||||
|
// return a handful of rows. When planner row estimates drift (e.g.
|
||||||
|
// due to autovacuum lag under write-heavy load), Postgres decides
|
||||||
|
// these plans are expensive enough to JIT-compile, which can add
|
||||||
|
// multiple seconds of pure compilation overhead per query and
|
||||||
|
// saturate the connection pool. JIT never pays off for these
|
||||||
|
// short-lived OLTP queries, so it's disabled outright rather than
|
||||||
|
// relying on statistics staying fresh.
|
||||||
|
//
|
||||||
|
// Set via a runtime SET command rather than the `options: "-c
|
||||||
|
// jit=off"` startup parameter: connections in SaaS mode go through
|
||||||
|
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
||||||
|
// options with a protocol_violation (08P01) error.
|
||||||
|
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
||||||
|
client.query("SET jit = off").catch((err: Error) => {
|
||||||
|
console.warn(
|
||||||
|
`Failed to set jit=off on ${label} client: ${err.message}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,4 +82,4 @@ export function createPool(
|
|||||||
);
|
);
|
||||||
attachPoolErrorHandlers(pool, label);
|
attachPoolErrorHandlers(pool, label);
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
|
unique,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -11,7 +12,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -19,33 +20,15 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
sessions,
|
|
||||||
clients,
|
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites
|
sites,
|
||||||
|
clients,
|
||||||
|
sessions,
|
||||||
|
labels
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
|
||||||
certId: serial("certId").primaryKey(),
|
|
||||||
domain: varchar("domain", { length: 255 }).notNull().unique(),
|
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
wildcard: boolean("wildcard").default(false),
|
|
||||||
status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed
|
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
|
||||||
lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }),
|
|
||||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
|
||||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull(),
|
|
||||||
orderId: varchar("orderId", { length: 500 }),
|
|
||||||
errorMessage: text("errorMessage"),
|
|
||||||
renewalCount: integer("renewalCount").default(0),
|
|
||||||
certFile: text("certFile"),
|
|
||||||
keyFile: text("keyFile")
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dnsChallenge = pgTable("dnsChallenges", {
|
export const dnsChallenge = pgTable("dnsChallenges", {
|
||||||
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
|
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
|
||||||
domain: varchar("domain", { length: 255 }).notNull(),
|
domain: varchar("domain", { length: 255 }).notNull(),
|
||||||
@@ -93,7 +76,8 @@ export const subscriptions = pgTable("subscriptions", {
|
|||||||
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
|
||||||
expiresAt: bigint("expiresAt", { mode: "number" }),
|
expiresAt: bigint("expiresAt", { mode: "number" }),
|
||||||
trial: boolean("trial").default(false),
|
trial: boolean("trial").default(false),
|
||||||
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license
|
type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license
|
||||||
|
override: boolean("override").default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = pgTable("subscriptionItems", {
|
export const subscriptionItems = pgTable("subscriptionItems", {
|
||||||
@@ -197,6 +181,42 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: varchar("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = pgTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: serial(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey(),
|
||||||
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -207,17 +227,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginPage = pgTable("loginPage", {
|
export const loginPage = pgTable(
|
||||||
loginPageId: serial("loginPageId").primaryKey(),
|
"loginPage",
|
||||||
subdomain: varchar("subdomain"),
|
{
|
||||||
fullDomain: varchar("fullDomain"),
|
loginPageId: serial("loginPageId").primaryKey(),
|
||||||
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
subdomain: varchar("subdomain"),
|
||||||
onDelete: "set null"
|
fullDomain: varchar("fullDomain"),
|
||||||
}),
|
exitNodeId: integer("exitNodeId").references(
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
() => exitNodes.exitNodeId,
|
||||||
onDelete: "set null"
|
{
|
||||||
})
|
onDelete: "set null"
|
||||||
});
|
}
|
||||||
|
),
|
||||||
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
|
onDelete: "set null"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("idx_loginpage_fulldomain")
|
||||||
|
.on(t.fullDomain)
|
||||||
|
.where(sql`${t.fullDomain} IS NOT NULL`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||||
loginPageId: integer("loginPageId")
|
loginPageId: integer("loginPageId")
|
||||||
@@ -583,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
|
||||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||||
export type Customer = InferSelectModel<typeof customers>;
|
export type Customer = InferSelectModel<typeof customers>;
|
||||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||||
|
|||||||
@@ -17,22 +17,39 @@ import {
|
|||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
ResourceHeaderAuth,
|
ResourceHeaderAuth,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
|
resourcePolicyRules,
|
||||||
resources,
|
resources,
|
||||||
roleResources,
|
roleResources,
|
||||||
|
rolePolicies,
|
||||||
sessions,
|
sessions,
|
||||||
userResources,
|
userResources,
|
||||||
|
userPolicies,
|
||||||
users,
|
users,
|
||||||
ResourceHeaderAuthExtendedCompatibility,
|
ResourceHeaderAuthExtendedCompatibility,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
ResourcePolicyPincode,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
ResourcePolicyPassword,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
ResourcePolicyHeaderAuth,
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicyWhiteList
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
import { alias } from "@server/db";
|
||||||
|
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export type ResourceWithAuth = {
|
export type ResourceWithAuth = {
|
||||||
resource: Resource | null;
|
resource: Resource | null;
|
||||||
pincode: ResourcePincode | null;
|
pincode: ResourcePincode | ResourcePolicyPincode | null;
|
||||||
password: ResourcePassword | null;
|
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||||
headerAuth: ResourceHeaderAuth | null;
|
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
|
applyRules: boolean | null;
|
||||||
|
sso: boolean | null;
|
||||||
|
emailWhitelistEnabled: boolean | null;
|
||||||
org: Org;
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +74,33 @@ export async function getResourceByDomain(
|
|||||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||||
|
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||||
|
const sharedPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"sharedPolicyPincode"
|
||||||
|
);
|
||||||
|
const defaultPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"defaultPolicyPincode"
|
||||||
|
);
|
||||||
|
const sharedPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"sharedPolicyPassword"
|
||||||
|
);
|
||||||
|
const defaultPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"defaultPolicyPassword"
|
||||||
|
);
|
||||||
|
const sharedPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"sharedPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
const defaultPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"defaultPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
|
||||||
const potentialResults = await db
|
const potentialResults = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resources)
|
.from(resources)
|
||||||
@@ -79,6 +123,59 @@ export async function getResourceByDomain(
|
|||||||
resources.resourceId
|
resources.resourceId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicy,
|
||||||
|
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyPincode,
|
||||||
|
eq(
|
||||||
|
sharedPolicyPincode.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyPassword,
|
||||||
|
eq(
|
||||||
|
sharedPolicyPassword.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
sharedPolicyHeaderAuth.resourcePolicyId,
|
||||||
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicy,
|
||||||
|
eq(
|
||||||
|
defaultPolicy.resourcePolicyId,
|
||||||
|
resources.defaultResourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPincode,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPincode.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPassword,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPassword.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
defaultPolicyHeaderAuth.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
@@ -108,13 +205,51 @@ export async function getResourceByDomain(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a shared (custom) policy is assigned to the resource, use ONLY
|
||||||
|
// its values — do not fall back to the default policy. The default
|
||||||
|
// policy is only consulted when no shared policy is assigned at all.
|
||||||
|
const hasSharedPolicy = result.sharedPolicy !== null;
|
||||||
|
|
||||||
|
const effectivePolicyPincode = hasSharedPolicy
|
||||||
|
? result.sharedPolicyPincode
|
||||||
|
: (result.defaultPolicyPincode ?? null);
|
||||||
|
const effectivePolicyPassword = hasSharedPolicy
|
||||||
|
? result.sharedPolicyPassword
|
||||||
|
: (result.defaultPolicyPassword ?? null);
|
||||||
|
const effectivePolicyHeaderAuth = hasSharedPolicy
|
||||||
|
? result.sharedPolicyHeaderAuth
|
||||||
|
: (result.defaultPolicyHeaderAuth ?? null);
|
||||||
|
const selectedPolicy = hasSharedPolicy
|
||||||
|
? result.sharedPolicy
|
||||||
|
: result.defaultPolicy;
|
||||||
|
const effectiveApplyRules =
|
||||||
|
selectedPolicy?.applyRules ?? result.resources.applyRules;
|
||||||
|
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
|
||||||
|
const effectiveEmailWhitelistEnabled =
|
||||||
|
selectedPolicy?.emailWhitelistEnabled ??
|
||||||
|
result.resources.emailWhitelistEnabled;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resource: result.resources,
|
resource: {
|
||||||
pincode: result.resourcePincode,
|
...result.resources,
|
||||||
password: result.resourcePassword,
|
applyRules: effectiveApplyRules,
|
||||||
headerAuth: result.resourceHeaderAuth,
|
sso: effectiveSSO,
|
||||||
headerAuthExtendedCompatibility:
|
emailWhitelistEnabled: effectiveEmailWhitelistEnabled
|
||||||
result.resourceHeaderAuthExtendedCompatibility,
|
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct
|
||||||
|
pincode: effectivePolicyPincode ?? result.resourcePincode,
|
||||||
|
password: effectivePolicyPassword ?? result.resourcePassword,
|
||||||
|
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
|
||||||
|
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
|
||||||
|
? ({
|
||||||
|
headerAuthExtendedCompatibilityId: 0,
|
||||||
|
resourceId: result.resources.resourceId,
|
||||||
|
extendedCompatibilityIsActivated:
|
||||||
|
effectivePolicyHeaderAuth.extendedCompatibility
|
||||||
|
} as ResourceHeaderAuthExtendedCompatibility)
|
||||||
|
: result.resourceHeaderAuthExtendedCompatibility,
|
||||||
|
applyRules: effectiveApplyRules,
|
||||||
|
sso: effectiveSSO,
|
||||||
|
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
|
||||||
org: result.orgs
|
org: result.orgs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,58 +289,195 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if role has access to resource
|
* Check if role has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getRoleResourceAccess(
|
export async function getRoleResourceAccess(
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
roleIds: number[]
|
roleIds: number[]
|
||||||
) {
|
) {
|
||||||
const roleResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(roleResources)
|
.select()
|
||||||
.where(
|
.from(roleResources)
|
||||||
and(
|
.where(
|
||||||
eq(roleResources.resourceId, resourceId),
|
and(
|
||||||
inArray(roleResources.roleId, roleIds)
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
inArray(roleResources.roleId, roleIds)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
roleId: rolePolicies.roleId,
|
||||||
|
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
rolePolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
inArray(rolePolicies.roleId, roleIds)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
const combined = [...direct, ...viaPolicies];
|
||||||
|
return combined.length > 0 ? combined : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has direct access to resource
|
* Check if user has access to resource (direct or via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getUserResourceAccess(
|
export async function getUserResourceAccess(
|
||||||
userId: string,
|
userId: string,
|
||||||
resourceId: number
|
resourceId: number
|
||||||
) {
|
) {
|
||||||
const userResourceAccess = await db
|
const [direct, viaPolicies] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(userResources)
|
.select()
|
||||||
.where(
|
.from(userResources)
|
||||||
and(
|
.where(
|
||||||
eq(userResources.userId, userId),
|
and(
|
||||||
eq(userResources.resourceId, resourceId)
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.limit(1),
|
||||||
.limit(1);
|
db
|
||||||
|
.select({
|
||||||
|
userId: userPolicies.userId,
|
||||||
|
resourcePolicyId: userPolicies.resourcePolicyId
|
||||||
|
})
|
||||||
|
.from(userPolicies)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
userPolicies.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resources.resourceId, resourceId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
]);
|
||||||
|
|
||||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
return direct[0] ?? viaPolicies[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get resource rules for a given resource
|
* Get resource rules for a given resource (direct and via resource policy)
|
||||||
*/
|
*/
|
||||||
export async function getResourceRules(
|
export async function getResourceRules(
|
||||||
resourceId: number
|
resourceId: number
|
||||||
): Promise<ResourceRule[]> {
|
): Promise<ResourceRule[]> {
|
||||||
const rules = await db
|
const [directRules, policyRules] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(resourceRules)
|
.select()
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number>`${resourceId}`,
|
||||||
|
enabled: resourcePolicyRules.enabled,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.innerJoin(
|
||||||
|
resources,
|
||||||
|
// Shared policy wins; only use default policy when no shared
|
||||||
|
// policy is assigned to the resource.
|
||||||
|
or(
|
||||||
|
eq(
|
||||||
|
resources.resourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
isNull(resources.resourcePolicyId),
|
||||||
|
eq(
|
||||||
|
resources.defaultResourcePolicyId,
|
||||||
|
resourcePolicyRules.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
]);
|
||||||
|
|
||||||
return rules;
|
const maxDirectPriority = directRules.reduce(
|
||||||
|
(max, r) => Math.max(max, r.priority),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const offsetPolicyRules = policyRules.map((r) => ({
|
||||||
|
...r,
|
||||||
|
priority: maxDirectPriority + r.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the whitelisted email associated with a resource session's whitelist
|
||||||
|
* match (either a direct resource whitelist entry or a resource policy
|
||||||
|
* whitelist entry).
|
||||||
|
*/
|
||||||
|
export async function getWhitelistEmail(
|
||||||
|
whitelistId?: number | null,
|
||||||
|
policyWhitelistId?: number | null
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (whitelistId) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ email: resourceWhitelist.email })
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(eq(resourceWhitelist.whitelistId, whitelistId))
|
||||||
|
.limit(1);
|
||||||
|
return row?.email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policyWhitelistId) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ email: resourcePolicyWhiteList.email })
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.whitelistId, policyWhitelistId))
|
||||||
|
.limit(1);
|
||||||
|
return row?.email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,79 +1,46 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import type BetterSqlite3 from "better-sqlite3";
|
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "@server/lib/consts";
|
||||||
import { existsSync, mkdirSync } from "fs";
|
import { existsSync, mkdirSync } from "fs";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
export const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
export const exists = checkFileExists(location);
|
export const exists = checkFileExists(location);
|
||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
|
||||||
* execution, freeing native sqlite3_stmt memory deterministically instead
|
|
||||||
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
|
||||||
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
|
||||||
* reusable .prepare() builders. No such usage exists in this codebase.
|
|
||||||
*/
|
|
||||||
function autoFinalizeStatement(
|
|
||||||
stmt: BetterSqlite3.Statement
|
|
||||||
): BetterSqlite3.Statement {
|
|
||||||
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
|
||||||
return function (this: any, ...args: any[]) {
|
|
||||||
try {
|
|
||||||
return fn.apply(this, args);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
// finalize() exists on the native Statement at runtime but
|
|
||||||
// is missing from @types/better-sqlite3.
|
|
||||||
(stmt as any).finalize();
|
|
||||||
} catch {
|
|
||||||
// Already finalized — harmless
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as unknown as T;
|
|
||||||
};
|
|
||||||
|
|
||||||
stmt.run = wrapExec(stmt.run);
|
|
||||||
stmt.get = wrapExec(stmt.get);
|
|
||||||
stmt.all = wrapExec(stmt.all);
|
|
||||||
|
|
||||||
return stmt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const verbose =
|
||||||
|
process.env.QUERY_LOGGING == "true"
|
||||||
|
? (message: unknown) => logger.debug(String(message))
|
||||||
|
: undefined;
|
||||||
|
const sqlite = new Database(location, { verbose });
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
|
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
||||||
|
// env var does NOT revert an existing WAL database.
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
// No busy_timeout pragma: better-sqlite3 already arms
|
||||||
// retry loops that accumulate memory.
|
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
||||||
sqlite.pragma("busy_timeout = 5000");
|
// (lib/database.js), so an explicit pragma is redundant.
|
||||||
|
|
||||||
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
||||||
// TraefikConfigManager JOINs that block the event loop.
|
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
||||||
sqlite.pragma("cache_size = -65536");
|
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
||||||
|
|
||||||
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
||||||
// reducing event-loop blocking.
|
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
||||||
sqlite.pragma("mmap_size = 268435456");
|
// fresh statement per query (no statement cache), so statements cannot
|
||||||
|
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
||||||
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
|
||||||
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
|
||||||
const originalPrepare = sqlite.prepare.bind(sqlite);
|
|
||||||
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
|
||||||
return autoFinalizeStatement(originalPrepare(source));
|
|
||||||
};
|
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from "./safeRead";
|
|||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
export * from "./migrate";
|
export * from "./migrate";
|
||||||
|
export { alias } from "drizzle-orm/sqlite-core";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
labels,
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -21,28 +22,6 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { serial, varchar } from "drizzle-orm/mysql-core";
|
|
||||||
import { pgTable } from "drizzle-orm/pg-core";
|
|
||||||
import { bigint } from "zod";
|
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
|
||||||
domain: text("domain").notNull().unique(),
|
|
||||||
domainId: text("domainId").references(() => domains.domainId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
wildcard: integer("wildcard", { mode: "boolean" }).default(false),
|
|
||||||
status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed
|
|
||||||
expiresAt: integer("expiresAt"),
|
|
||||||
lastRenewalAttempt: integer("lastRenewalAttempt"),
|
|
||||||
createdAt: integer("createdAt").notNull(),
|
|
||||||
updatedAt: integer("updatedAt").notNull(),
|
|
||||||
orderId: text("orderId"),
|
|
||||||
errorMessage: text("errorMessage"),
|
|
||||||
renewalCount: integer("renewalCount").default(0),
|
|
||||||
certFile: text("certFile"),
|
|
||||||
keyFile: text("keyFile")
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||||
dnsChallengeId: integer("dnsChallengeId").primaryKey({
|
dnsChallengeId: integer("dnsChallengeId").primaryKey({
|
||||||
@@ -91,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
trial: integer("trial", { mode: "boolean" }).default(false),
|
trial: integer("trial", { mode: "boolean" }).default(false),
|
||||||
billingCycleAnchor: integer("billingCycleAnchor"),
|
billingCycleAnchor: integer("billingCycleAnchor"),
|
||||||
type: text("type") // tier1, tier2, tier3, or license
|
type: text("type"), // tier1, tier2, tier3, or license
|
||||||
|
override: integer("override", { mode: "boolean" }).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||||
@@ -195,6 +175,44 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
||||||
|
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.notNull()
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
}),
|
||||||
|
destination: text("destination").notNull() // a cidr range
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remoteExitNodePreferenceLabels = sqliteTable(
|
||||||
|
// this controls what sites are enforced to connect to this node
|
||||||
|
"remoteExitNodePreferenceLabels",
|
||||||
|
{
|
||||||
|
remoteExitNodePreferenceLabelId: integer(
|
||||||
|
"remoteExitNodePreferenceLabelId"
|
||||||
|
).primaryKey({ autoIncrement: true }),
|
||||||
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
.references(() => remoteExitNodes.remoteExitNodeId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
labelId: integer("labelId")
|
||||||
|
.references(() => labels.labelId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
||||||
|
t.remoteExitNodeId,
|
||||||
|
t.labelId
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
@@ -591,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
|
||||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||||
export type Customer = InferSelectModel<typeof customers>;
|
export type Customer = InferSelectModel<typeof customers>;
|
||||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||||
|
|||||||
@@ -31,9 +31,24 @@ export type AlertNotificationProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
dashboardLink: string;
|
dashboardLink: string;
|
||||||
|
isTestAlert?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getEventMeta(eventType: AlertEventType): {
|
function getEventMeta(
|
||||||
|
eventType: AlertEventType,
|
||||||
|
isTestAlert: boolean = false
|
||||||
|
): {
|
||||||
|
heading: string;
|
||||||
|
previewText: string;
|
||||||
|
summary: string;
|
||||||
|
statusLabel: string | null;
|
||||||
|
statusColor: string | null;
|
||||||
|
} {
|
||||||
|
const meta = getBaseEventMeta(eventType);
|
||||||
|
return isTestAlert ? { ...meta, heading: `[TEST] ${meta.heading}` } : meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBaseEventMeta(eventType: AlertEventType): {
|
||||||
heading: string;
|
heading: string;
|
||||||
previewText: string;
|
previewText: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -180,8 +195,14 @@ function formatDataItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AlertNotification = (props: AlertNotificationProps) => {
|
export const AlertNotification = (props: AlertNotificationProps) => {
|
||||||
const { eventType, orgId, data, dashboardLink } = props;
|
const {
|
||||||
const meta = getEventMeta(eventType);
|
eventType,
|
||||||
|
orgId,
|
||||||
|
data,
|
||||||
|
dashboardLink,
|
||||||
|
isTestAlert = false
|
||||||
|
} = props;
|
||||||
|
const meta = getEventMeta(eventType, isTestAlert);
|
||||||
const dataItems = formatDataItems(data);
|
const dataItems = formatDataItems(data);
|
||||||
|
|
||||||
const isToggle =
|
const isToggle =
|
||||||
@@ -242,6 +263,12 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
|||||||
Open your dashboard to view more details and manage
|
Open your dashboard to view more details and manage
|
||||||
your alert rules.
|
your alert rules.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
{isTestAlert && (
|
||||||
|
<EmailText>
|
||||||
|
This is a test alert. No action is required,
|
||||||
|
and no real event has occurred.
|
||||||
|
</EmailText>
|
||||||
|
)}
|
||||||
|
|
||||||
<EmailSection>
|
<EmailSection>
|
||||||
<ButtonLink href={dashboardLink}>
|
<ButtonLink href={dashboardLink}>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
type IdentityApiKeyGeneratedProps = {
|
||||||
|
orgName: string;
|
||||||
|
accountLabel?: string | null;
|
||||||
|
credential: string;
|
||||||
|
resourceUrls: string[];
|
||||||
|
hasMoreResources: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IdentityApiKeyGenerated = ({
|
||||||
|
orgName,
|
||||||
|
accountLabel,
|
||||||
|
credential,
|
||||||
|
resourceUrls,
|
||||||
|
hasMoreResources
|
||||||
|
}: IdentityApiKeyGeneratedProps) => {
|
||||||
|
const previewText = `Your personal identity key for ${orgName}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind config={themeColors}>
|
||||||
|
<Body className="font-sans bg-gray-50">
|
||||||
|
<EmailContainer>
|
||||||
|
<EmailLetterHead />
|
||||||
|
|
||||||
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
This is your personal identity key for{" "}
|
||||||
|
<strong>{orgName}</strong>. It belongs to your
|
||||||
|
account and identifies you when you use public AI
|
||||||
|
gateways.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
Use it with resources your administrator has granted
|
||||||
|
you, or that your role has access to. Treat this key
|
||||||
|
like a password and do not share it.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailSection>
|
||||||
|
<EmailText>Your identity key:</EmailText>
|
||||||
|
<div className="inline-block max-w-full">
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
|
||||||
|
<span className="text-sm font-mono text-gray-900 break-all">
|
||||||
|
{credential}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EmailSection>
|
||||||
|
|
||||||
|
<EmailFooter>
|
||||||
|
<EmailSignature />
|
||||||
|
</EmailFooter>
|
||||||
|
</EmailContainer>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IdentityApiKeyGenerated;
|
||||||
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
|
|||||||
const isLastDay = daysRemaining === 1;
|
const isLastDay = daysRemaining === 1;
|
||||||
|
|
||||||
const previewText = hasEnded
|
const previewText = hasEnded
|
||||||
? `Your trial for ${orgName} has ended.`
|
? `Your cloud trial for ${orgName} has ended.`
|
||||||
: isLastDay
|
: isLastDay
|
||||||
? `Your trial for ${orgName} ends tomorrow.`
|
? `Your cloud trial for ${orgName} ends tomorrow.`
|
||||||
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
|
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
|
||||||
|
|
||||||
const heading = hasEnded
|
const heading = hasEnded
|
||||||
? "Your Trial Ended"
|
? "Your Cloud Trial Ended"
|
||||||
: "Your Trial is Ending Soon";
|
: "Your Cloud Trial is Ending Soon";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
|
|||||||
{hasEnded ? (
|
{hasEnded ? (
|
||||||
<>
|
<>
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Your free trial for{" "}
|
Your cloud free trial for{" "}
|
||||||
<strong>{orgName}</strong> ended on{" "}
|
<strong>{orgName}</strong> ended on{" "}
|
||||||
<strong>{trialEndsAt}</strong>. Your account
|
<strong>{trialEndsAt}</strong>. Your account
|
||||||
has been moved to the free plan, which
|
has been moved to the free plan, which
|
||||||
@@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({
|
|||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
Some features and resources may now be
|
Some features and resources may now be
|
||||||
restricted. To restore full
|
restricted. To restore full access and
|
||||||
access and continue using all the features
|
continue using all the features you had
|
||||||
you had during your trial, please upgrade to
|
during your trial, please upgrade to a paid
|
||||||
a paid plan.
|
plan. This does not effect any self hosted
|
||||||
|
licenses.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
@@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({
|
|||||||
<EmailText>
|
<EmailText>
|
||||||
After your trial ends, your account will be
|
After your trial ends, your account will be
|
||||||
moved to the free plan and some
|
moved to the free plan and some
|
||||||
functionality may be restricted.
|
functionality may be restricted. This does
|
||||||
|
not effect any self hosted licenses.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
|
||||||
|
import { themeColors } from "./lib/theme";
|
||||||
|
import {
|
||||||
|
EmailContainer,
|
||||||
|
EmailFooter,
|
||||||
|
EmailGreeting,
|
||||||
|
EmailInfoSection,
|
||||||
|
EmailLetterHead,
|
||||||
|
EmailSection,
|
||||||
|
EmailSignature,
|
||||||
|
EmailText
|
||||||
|
} from "./components/Email";
|
||||||
|
|
||||||
|
type VirtualApiKeyGeneratedProps = {
|
||||||
|
orgName: string;
|
||||||
|
keyName: string | null;
|
||||||
|
credential: string;
|
||||||
|
resourceUrls: string[];
|
||||||
|
hasMoreResources: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VirtualApiKeyGenerated = ({
|
||||||
|
orgName,
|
||||||
|
keyName,
|
||||||
|
credential,
|
||||||
|
resourceUrls,
|
||||||
|
hasMoreResources
|
||||||
|
}: VirtualApiKeyGeneratedProps) => {
|
||||||
|
const previewText = `A virtual API key for ${orgName} has been shared with you`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind config={themeColors}>
|
||||||
|
<Body className="font-sans bg-gray-50">
|
||||||
|
<EmailContainer>
|
||||||
|
<EmailLetterHead />
|
||||||
|
|
||||||
|
<EmailGreeting>Hi there,</EmailGreeting>
|
||||||
|
|
||||||
|
<EmailText>
|
||||||
|
A virtual API key for <strong>{orgName}</strong> has
|
||||||
|
been shared with you. This key grants access to the
|
||||||
|
public AI gateways it was created for. Treat this
|
||||||
|
key like a password and do not share it.
|
||||||
|
</EmailText>
|
||||||
|
|
||||||
|
<EmailSection>
|
||||||
|
<EmailText>Your virtual API key:</EmailText>
|
||||||
|
<div className="inline-block max-w-full">
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg px-4 py-3 mx-auto text-left">
|
||||||
|
<span className="text-sm font-mono text-gray-900 break-all">
|
||||||
|
{credential}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EmailSection>
|
||||||
|
|
||||||
|
<EmailInfoSection
|
||||||
|
title="Key details"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Organization",
|
||||||
|
value: orgName
|
||||||
|
},
|
||||||
|
...(keyName
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: "Name",
|
||||||
|
value: keyName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{resourceUrls.length > 0 && (
|
||||||
|
<>
|
||||||
|
<EmailText>
|
||||||
|
This key can be used to authenticate to the
|
||||||
|
following AI gateway resources:
|
||||||
|
</EmailText>
|
||||||
|
<div className="px-6 pb-2">
|
||||||
|
{resourceUrls.map((url) => (
|
||||||
|
<p
|
||||||
|
key={url}
|
||||||
|
className="text-base text-gray-700 leading-relaxed"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
className="text-primary font-medium break-all"
|
||||||
|
>
|
||||||
|
{url}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMoreResources && (
|
||||||
|
<EmailText>
|
||||||
|
Contact your administrator to get the
|
||||||
|
full list.
|
||||||
|
</EmailText>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmailFooter>
|
||||||
|
<EmailSignature />
|
||||||
|
</EmailFooter>
|
||||||
|
</EmailContainer>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualApiKeyGenerated;
|
||||||
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
|
|||||||
<Img
|
<Img
|
||||||
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
|
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
|
||||||
alt="Pangolin Logo"
|
alt="Pangolin Logo"
|
||||||
width="180"
|
width="135"
|
||||||
height="auto"
|
height="auto"
|
||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
import { createNextServer } from "./nextServer";
|
import { createNextServer } from "./nextServer";
|
||||||
import { createInternalServer } from "./internalServer";
|
import { createInternalServer } from "./internalServer";
|
||||||
|
import { createAiGatewayServer } from "./aiGatewayServer";
|
||||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||||
import {
|
import {
|
||||||
ApiKey,
|
ApiKey,
|
||||||
ApiKeyOrg,
|
ApiKeyOrg,
|
||||||
|
AiBudget,
|
||||||
|
AiModel,
|
||||||
|
AiProvider,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
Session,
|
Session,
|
||||||
SiteResource,
|
SiteResource,
|
||||||
User,
|
User,
|
||||||
UserOrg
|
UserOrg,
|
||||||
|
VirtualApiKey
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { setHostMeta } from "@server/lib/hostMeta";
|
import { setHostMeta } from "@server/lib/hostMeta";
|
||||||
import { initTelemetryClient } from "@server/lib/telemetry";
|
|
||||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||||
import { initCleanup } from "#dynamic/cleanup";
|
import { initCleanup } from "#dynamic/cleanup";
|
||||||
|
import { startSchedulers } from "#dynamic/startSchedulers";
|
||||||
import license from "#dynamic/license/license";
|
import license from "#dynamic/license/license";
|
||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
|
||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
|
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -37,14 +41,14 @@ async function startServers() {
|
|||||||
|
|
||||||
await fetchServerIp();
|
await fetchServerIp();
|
||||||
|
|
||||||
initTelemetryClient();
|
await initAiModelCatalog();
|
||||||
|
|
||||||
initLogCleanupInterval();
|
startSchedulers();
|
||||||
initAcmeCertSync();
|
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
|
const aiGatewayServer = createAiGatewayServer();
|
||||||
|
|
||||||
const nextServer = await createNextServer();
|
const nextServer = await createNextServer();
|
||||||
if (config.getRawConfig().traefik.file_mode) {
|
if (config.getRawConfig().traefik.file_mode) {
|
||||||
@@ -63,6 +67,7 @@ async function startServers() {
|
|||||||
apiServer,
|
apiServer,
|
||||||
nextServer,
|
nextServer,
|
||||||
internalServer,
|
internalServer,
|
||||||
|
aiGatewayServer,
|
||||||
integrationServer
|
integrationServer
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,10 @@ declare global {
|
|||||||
userOrgIds?: string[];
|
userOrgIds?: string[];
|
||||||
remoteExitNode?: RemoteExitNode;
|
remoteExitNode?: RemoteExitNode;
|
||||||
siteResource?: SiteResource;
|
siteResource?: SiteResource;
|
||||||
|
aiProvider?: AiProvider;
|
||||||
|
aiModel?: AiModel;
|
||||||
|
aiBudget?: AiBudget;
|
||||||
|
virtualApiKey?: VirtualApiKey;
|
||||||
orgPolicyAllowed?: boolean;
|
orgPolicyAllowed?: boolean;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
|||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
import { registry } from "./openApi";
|
import { registry, openApiTags } from "./openApi";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { APP_PATH } from "./lib/consts";
|
import { APP_PATH } from "./lib/consts";
|
||||||
@@ -152,11 +152,19 @@ function getOpenApiDocumentation() {
|
|||||||
|
|
||||||
if (!hasExistingResponses) {
|
if (!hasExistingResponses) {
|
||||||
def.route.responses = {
|
def.route.responses = {
|
||||||
"*": {
|
"200": {
|
||||||
description: "",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({})
|
schema: z.object({
|
||||||
|
data: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +181,8 @@ function getOpenApiDocumentation() {
|
|||||||
version: "v1",
|
version: "v1",
|
||||||
title: "Pangolin Integration API"
|
title: "Pangolin Integration API"
|
||||||
},
|
},
|
||||||
servers: [{ url: "/v1" }]
|
servers: [{ url: "/v1" }],
|
||||||
|
tags: openApiTags
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!process.env.DISABLE_GEN_OPENAPI) {
|
if (!process.env.DISABLE_GEN_OPENAPI) {
|
||||||
|
|||||||
@@ -1,3 +1,866 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import {
|
||||||
|
certificates,
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
db,
|
||||||
|
domains,
|
||||||
|
newts,
|
||||||
|
siteNetworks,
|
||||||
|
SiteResource,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import {
|
||||||
|
generateSubnetProxyTargetV2,
|
||||||
|
SubnetProxyTargetV2
|
||||||
|
} from "@server/lib/ip";
|
||||||
|
import { updateTargets } from "@server/routers/client/targets";
|
||||||
|
import cache from "#dynamic/lib/cache";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
interface AcmeCert {
|
||||||
|
domain: { main: string; sans?: string[] };
|
||||||
|
certificate: string;
|
||||||
|
key: string;
|
||||||
|
Store: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcmeJson {
|
||||||
|
[resolver: string]: {
|
||||||
|
Certificates: AcmeCert[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushCertUpdateToAffectedNewts(
|
||||||
|
domain: string,
|
||||||
|
domainId: string | null,
|
||||||
|
oldCertPem: string | null,
|
||||||
|
oldKeyPem: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||||
|
let affectedResources: SiteResource[] = [];
|
||||||
|
|
||||||
|
if (domainId) {
|
||||||
|
affectedResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.domainId, domainId),
|
||||||
|
eq(siteResources.ssl, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: match by exact fullDomain when no domainId is available
|
||||||
|
affectedResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.fullDomain, domain),
|
||||||
|
eq(siteResources.ssl, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affectedResources.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const resource of affectedResources) {
|
||||||
|
try {
|
||||||
|
// Get all sites for this resource via siteNetworks
|
||||||
|
const resourceSiteRows = resource.networkId
|
||||||
|
? await db
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (resourceSiteRows.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all clients with access to this resource
|
||||||
|
const resourceClients = await db
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
pubKey: clients.pubKey,
|
||||||
|
subnet: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.innerJoin(
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
eq(
|
||||||
|
clients.clientId,
|
||||||
|
clientSiteResourcesAssociationsCache.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceClients.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||||
|
if (resource.fullDomain) {
|
||||||
|
await cache.del(`cert:${resource.fullDomain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate target once - same cert applies to all sites for this resource
|
||||||
|
const newTargets = await generateSubnetProxyTargetV2(
|
||||||
|
resource,
|
||||||
|
resourceClients
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newTargets) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the old targets - same routing shape but with the previous cert/key.
|
||||||
|
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||||
|
// semantics correct so the update message accurately reflects what changed.
|
||||||
|
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||||
|
...t,
|
||||||
|
tlsCert: oldCertPem ?? undefined,
|
||||||
|
tlsKey: oldKeyPem ?? undefined
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Push update to each site's newt
|
||||||
|
for (const { siteId } of resourceSiteRows) {
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateTargets(
|
||||||
|
newt.newtId,
|
||||||
|
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||||
|
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||||
|
const lookupDomain = certDomain.startsWith("*.")
|
||||||
|
? certDomain.slice(2)
|
||||||
|
: certDomain;
|
||||||
|
|
||||||
|
// 1. Exact baseDomain match (any domain type)
|
||||||
|
const exactMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.baseDomain, lookupDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (exactMatch.length > 0) {
|
||||||
|
return exactMatch[0].domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||||
|
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||||
|
// matches a wildcard domain with baseDomain "example.com".
|
||||||
|
const parts = lookupDomain.split(".");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const candidate = parts.slice(i).join(".");
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
const wildcardMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.baseDomain, candidate),
|
||||||
|
eq(domains.type, "wildcard")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (wildcardMatch.length > 0) {
|
||||||
|
return wildcardMatch[0].domainId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstCert(pemBundle: string): string | null {
|
||||||
|
const match = pemBundle.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether an ACME cert entry represents a wildcard cert by checking
|
||||||
|
* both the primary domain (`main`) and the SANs. Some ACME clients (notably
|
||||||
|
* Traefik) store the bare apex in `main` and only put the wildcard form in
|
||||||
|
* `sans` (e.g. main="access.example.com", sans=["*.access.example.com"]).
|
||||||
|
*/
|
||||||
|
function detectWildcard(
|
||||||
|
main: string,
|
||||||
|
sans: string[] | undefined
|
||||||
|
): { wildcard: boolean; wildcardSan: string | null } {
|
||||||
|
if (main.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: null };
|
||||||
|
}
|
||||||
|
if (Array.isArray(sans)) {
|
||||||
|
for (const san of sans) {
|
||||||
|
if (typeof san !== "string") continue;
|
||||||
|
if (san === `*.${main}` || san.startsWith("*.")) {
|
||||||
|
return { wildcard: true, wildcardSan: san };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { wildcard: false, wildcardSan: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HttpCert {
|
||||||
|
wildcard: boolean;
|
||||||
|
altName: string;
|
||||||
|
certName: string;
|
||||||
|
commonName: string;
|
||||||
|
certFile: string;
|
||||||
|
keyFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: HTTP endpoint returned status ${response.status}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpCerts: HttpCert[];
|
||||||
|
try {
|
||||||
|
httpCerts = await response.json();
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates returned from HTTP endpoint`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of httpCerts) {
|
||||||
|
const domain = cert?.certName;
|
||||||
|
|
||||||
|
if (!domain || typeof domain !== "string") {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert with missing certName`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certPem = cert.certFile;
|
||||||
|
const keyPem = cert.keyFile;
|
||||||
|
|
||||||
|
if (!certPem?.trim() || !keyPem?.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
|
if (!firstCertPemForValidation) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedX509: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
validatedX509 = new crypto.X509Certificate(
|
||||||
|
firstCertPemForValidation
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(keyPem);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = cert.wildcard ?? false;
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let oldCertPem: string | null = null;
|
||||||
|
let oldKeyPem: string | null = null;
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decrypt(
|
||||||
|
existing[0].certFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
oldCertPem = storedCertPem;
|
||||||
|
if (existing[0].keyFile) {
|
||||||
|
try {
|
||||||
|
oldKeyPem = decrypt(
|
||||||
|
existing[0].keyFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} catch (keyErr) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
try {
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCert = encrypt(
|
||||||
|
certPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const encryptedKey = encrypt(
|
||||||
|
keyPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
oldCertPem,
|
||||||
|
oldKeyPem
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeCertForDomain(
|
||||||
|
domain: string,
|
||||||
|
certPem: string,
|
||||||
|
keyPem: string,
|
||||||
|
validatedX509: crypto.X509Certificate
|
||||||
|
): Promise<void> {
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let oldCertPem: string | null = null;
|
||||||
|
let oldKeyPem: string | null = null;
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decrypt(
|
||||||
|
existing[0].certFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||||
|
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oldCertPem = storedCertPem;
|
||||||
|
if (existing[0].keyFile) {
|
||||||
|
try {
|
||||||
|
oldKeyPem = decrypt(
|
||||||
|
existing[0].keyFile,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
} catch (keyErr) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
try {
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(validatedX509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCert = encrypt(
|
||||||
|
certPem,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
oldCertPem,
|
||||||
|
oldKeyPem
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||||
|
const results: string[] = [];
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not read directory "${dirPath}": ${err}`
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dirPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
results.push(...findAcmeJsonFiles(fullPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
// check if it is a json file
|
||||||
|
if (entry.name.endsWith(".json")) {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(fullPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not read file "${fullPath}": ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${fullPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`acmeCertSync: could not read "${acmeJsonPath}": ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acmeJson: AcmeJson;
|
||||||
|
try {
|
||||||
|
acmeJson = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: could not parse "${acmeJsonPath}" as JSON: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvers = Object.keys(acmeJson || {});
|
||||||
|
if (resolvers.length === 0) {
|
||||||
|
logger.debug(`acmeCertSync: no resolvers found in acme.json`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect certificates from every resolver. If the same domain appears in
|
||||||
|
// multiple resolvers, the last one wins (resolvers iterated in object order).
|
||||||
|
const allCerts: AcmeCert[] = [];
|
||||||
|
for (const resolver of resolvers) {
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// logger.debug(
|
||||||
|
// `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
|
||||||
|
// );
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
allCerts.push(cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cert of allCerts) {
|
||||||
|
const mainDomain = cert?.domain?.main;
|
||||||
|
|
||||||
|
if (!mainDomain || typeof mainDomain !== "string") {
|
||||||
|
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert.certificate || !cert.key) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let certPem: string;
|
||||||
|
let keyPem: string;
|
||||||
|
try {
|
||||||
|
certPem = Buffer.from(cert.certificate, "base64").toString("utf8");
|
||||||
|
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the decoded data actually parses as a real X.509 cert
|
||||||
|
// before we touch the database. This prevents importing partially-written
|
||||||
|
// or corrupted entries from acme.json.
|
||||||
|
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||||
|
if (!firstCertPemForValidation) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let validatedX509: crypto.X509Certificate;
|
||||||
|
try {
|
||||||
|
validatedX509 = new crypto.X509Certificate(
|
||||||
|
firstCertPemForValidation
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity-check the private key parses too
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(keyPem);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all domains covered by this cert: main + every SAN.
|
||||||
|
// Each domain gets its own row in the certificates table so that
|
||||||
|
// lookups by any hostname on the cert succeed independently.
|
||||||
|
const allDomains = new Set<string>([mainDomain]);
|
||||||
|
if (Array.isArray(cert.domain?.sans)) {
|
||||||
|
for (const san of cert.domain.sans) {
|
||||||
|
if (typeof san === "string" && san.trim()) {
|
||||||
|
allDomains.add(san.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger.debug(
|
||||||
|
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||||
|
// );
|
||||||
|
|
||||||
|
for (const domain of allDomains) {
|
||||||
|
try {
|
||||||
|
await storeCertForDomain(
|
||||||
|
domain,
|
||||||
|
certPem,
|
||||||
|
keyPem,
|
||||||
|
validatedX509
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function initAcmeCertSync(): void {
|
export function initAcmeCertSync(): void {
|
||||||
// stub
|
if (build == "saas") {
|
||||||
}
|
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = config.getRawConfig();
|
||||||
|
|
||||||
|
if (!configData.flags?.enable_acme_cert_sync) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acmeJsonPath =
|
||||||
|
configData.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||||
|
const intervalMs = configData.acme?.sync_interval_ms ?? 5000;
|
||||||
|
const httpEndpoint = configData.acme?.acme_http_endpoint;
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
if (httpEndpoint) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runSync = () => {
|
||||||
|
if (httpEndpoint) {
|
||||||
|
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
|
||||||
|
let stat: fs.Stats | null = null;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(acmeJsonPath);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`acmeCertSync: cannot stat path "${acmeJsonPath}": ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
const files = findAcmeJsonFiles(acmeJsonPath);
|
||||||
|
if (files.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no acme.json files found in directory "${acmeJsonPath}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// logger.debug(
|
||||||
|
// `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
|
||||||
|
// );
|
||||||
|
for (const file of files) {
|
||||||
|
syncAcmeCerts(file).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
`acmeCertSync: error during sync of "${file}": ${err}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
syncAcmeCerts(acmeJsonPath).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run immediately on init, then on the configured interval
|
||||||
|
runSync();
|
||||||
|
|
||||||
|
setInterval(runSync, intervalMs);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
eq,
|
||||||
|
gte,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
SQL,
|
||||||
|
type InferInsertModel
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
AiBudget,
|
||||||
|
aiBudgetBreachEvents,
|
||||||
|
aiBudgets,
|
||||||
|
aiModels,
|
||||||
|
aiUsageRecords,
|
||||||
|
db,
|
||||||
|
userOrgRoles
|
||||||
|
} from "@server/db";
|
||||||
|
import { modelKeyMatches } from "@server/lib/aiModelKeyMatch";
|
||||||
|
import type { AiUsage } from "@server/lib/aiUsageExtraction";
|
||||||
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
type BudgetPeriod = AiBudget["period"];
|
||||||
|
|
||||||
|
const PERIOD_DURATIONS_MS: Record<Exclude<BudgetPeriod, "lifetime">, number> = {
|
||||||
|
hourly: 60 * 60 * 1000,
|
||||||
|
daily: 24 * 60 * 60 * 1000,
|
||||||
|
weekly: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
monthly: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
yearly: 365 * 24 * 60 * 60 * 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
// Budgets are cheap to be a little stale about (enforcement is already
|
||||||
|
// check-then-act, not transactional). Re-derive each budget's usage sum
|
||||||
|
// from aiUsageRecords at most this often; in between, completed requests
|
||||||
|
// just add their own contribution onto the cached sum instead of
|
||||||
|
// re-querying/re-aggregating from scratch.
|
||||||
|
const BUDGET_CACHE_REFRESH_MS = 8_000;
|
||||||
|
// Redis-level TTL is only a safety net for eviction if a budget stops
|
||||||
|
// seeing traffic - the actual staleness check is the computedAt timestamp
|
||||||
|
// stored in the cached value, compared against BUDGET_CACHE_REFRESH_MS.
|
||||||
|
const BUDGET_CACHE_SAFETY_TTL_SEC = 60;
|
||||||
|
|
||||||
|
function applicableBudgetsCacheKey(ctx: BudgetScopeContext): string {
|
||||||
|
const roleKey = [...ctx.roleIds].sort((a, b) => a - b).join(",");
|
||||||
|
return [
|
||||||
|
"aiBudget:applicable",
|
||||||
|
ctx.orgId,
|
||||||
|
ctx.providerId,
|
||||||
|
ctx.requestedModel,
|
||||||
|
ctx.resourceId ?? "",
|
||||||
|
ctx.siteResourceId ?? "",
|
||||||
|
roleKey,
|
||||||
|
ctx.virtualApiKeyId ?? ""
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
function budgetUsageCacheKey(budgetId: number): string {
|
||||||
|
return `aiBudget:usage:${budgetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CachedBudgetUsage = {
|
||||||
|
sum: number;
|
||||||
|
computedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Budget periods are trailing windows from "now", not calendar-aligned
|
||||||
|
// (e.g. "daily" = last 24h). "lifetime" has no lower bound.
|
||||||
|
function windowStart(period: BudgetPeriod, now: number): number {
|
||||||
|
if (period === "lifetime") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return now - PERIOD_DURATIONS_MS[period];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetScopeContext = {
|
||||||
|
orgId: string;
|
||||||
|
providerId: number;
|
||||||
|
requestedModel: string;
|
||||||
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
|
roleIds: number[];
|
||||||
|
requestUserId: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every budget that could apply to this request: the provider itself, any
|
||||||
|
* model on that provider whose (possibly wildcarded) modelKey matches the
|
||||||
|
* requested model, the target resource/site-resource, and any role the
|
||||||
|
* requesting user holds in the org. Cached for BUDGET_CACHE_REFRESH_MS since
|
||||||
|
* budget/model config changes are rare and a request-scoped org/provider/
|
||||||
|
* model/resource/role combination repeats constantly under real traffic.
|
||||||
|
*/
|
||||||
|
export async function resolveApplicableBudgets(
|
||||||
|
ctx: BudgetScopeContext
|
||||||
|
): Promise<AiBudget[]> {
|
||||||
|
const cacheKey = applicableBudgetsCacheKey(ctx);
|
||||||
|
const cached = await cache.get<AiBudget[]>(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgets = await fetchApplicableBudgets(ctx);
|
||||||
|
await cache.set(cacheKey, budgets, BUDGET_CACHE_REFRESH_MS / 1000);
|
||||||
|
return budgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchApplicableBudgets(
|
||||||
|
ctx: BudgetScopeContext
|
||||||
|
): Promise<AiBudget[]> {
|
||||||
|
const providerModels = await db
|
||||||
|
.select({ modelId: aiModels.modelId, modelKey: aiModels.modelKey })
|
||||||
|
.from(aiModels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiModels.providerId, ctx.providerId),
|
||||||
|
eq(aiModels.enabled, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const matchingModelIds = providerModels
|
||||||
|
.filter((m) => modelKeyMatches(m.modelKey, ctx.requestedModel))
|
||||||
|
.map((m) => m.modelId);
|
||||||
|
|
||||||
|
const scopeConditions: SQL[] = [
|
||||||
|
and(
|
||||||
|
eq(aiBudgets.providerId, ctx.providerId),
|
||||||
|
isNull(aiBudgets.modelId)
|
||||||
|
)!
|
||||||
|
];
|
||||||
|
if (matchingModelIds.length > 0) {
|
||||||
|
scopeConditions.push(inArray(aiBudgets.modelId, matchingModelIds));
|
||||||
|
}
|
||||||
|
if (ctx.resourceId != null) {
|
||||||
|
scopeConditions.push(eq(aiBudgets.resourceId, ctx.resourceId));
|
||||||
|
}
|
||||||
|
if (ctx.siteResourceId != null) {
|
||||||
|
scopeConditions.push(eq(aiBudgets.siteResourceId, ctx.siteResourceId));
|
||||||
|
}
|
||||||
|
if (ctx.roleIds.length > 0) {
|
||||||
|
scopeConditions.push(inArray(aiBudgets.roleId, ctx.roleIds));
|
||||||
|
}
|
||||||
|
if (ctx.virtualApiKeyId != null) {
|
||||||
|
scopeConditions.push(
|
||||||
|
eq(aiBudgets.virtualApiKeyId, ctx.virtualApiKeyId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(aiBudgets)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiBudgets.orgId, ctx.orgId),
|
||||||
|
eq(aiBudgets.enabled, true),
|
||||||
|
or(...scopeConditions)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sumUsageAmount(
|
||||||
|
where: SQL,
|
||||||
|
unit: AiBudget["unit"]
|
||||||
|
): Promise<number> {
|
||||||
|
const column =
|
||||||
|
unit === "usd" ? aiUsageRecords.costUsd : aiUsageRecords.totalTokens;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ total: sql<number>`coalesce(sum(${column}), 0)` })
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(where);
|
||||||
|
return Number(row?.total ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sums recorded usage for a single budget's scope + rolling window. Model
|
||||||
|
* budgets can't be pushed down to SQL because the model's key may itself be
|
||||||
|
* a glob, so those rows are fetched for the provider+window and matched in
|
||||||
|
* JS the same way access-control matching does.
|
||||||
|
*/
|
||||||
|
export async function sumUsageForBudget(
|
||||||
|
budget: AiBudget,
|
||||||
|
ctx: BudgetScopeContext,
|
||||||
|
now: number
|
||||||
|
): Promise<number> {
|
||||||
|
const start = windowStart(budget.period, now);
|
||||||
|
|
||||||
|
if (budget.modelId != null) {
|
||||||
|
const [model] = await db
|
||||||
|
.select({
|
||||||
|
providerId: aiModels.providerId,
|
||||||
|
modelKey: aiModels.modelKey
|
||||||
|
})
|
||||||
|
.from(aiModels)
|
||||||
|
.where(eq(aiModels.modelId, budget.modelId))
|
||||||
|
.limit(1);
|
||||||
|
if (!model) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
requestedModel: aiUsageRecords.requestedModel,
|
||||||
|
costUsd: aiUsageRecords.costUsd,
|
||||||
|
totalTokens: aiUsageRecords.totalTokens
|
||||||
|
})
|
||||||
|
.from(aiUsageRecords)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
eq(aiUsageRecords.providerId, model.providerId),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return rows
|
||||||
|
.filter((r) => modelKeyMatches(model.modelKey, r.requestedModel))
|
||||||
|
.reduce(
|
||||||
|
(sum, r) =>
|
||||||
|
sum +
|
||||||
|
(budget.unit === "usd" ? (r.costUsd ?? 0) : r.totalTokens),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budget.providerId != null) {
|
||||||
|
return sumUsageAmount(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
eq(aiUsageRecords.providerId, budget.providerId),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)!,
|
||||||
|
budget.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budget.resourceId != null) {
|
||||||
|
return sumUsageAmount(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
eq(aiUsageRecords.resourceId, budget.resourceId),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)!,
|
||||||
|
budget.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budget.siteResourceId != null) {
|
||||||
|
return sumUsageAmount(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
eq(aiUsageRecords.siteResourceId, budget.siteResourceId),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)!,
|
||||||
|
budget.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budget.roleId != null) {
|
||||||
|
const members = await db
|
||||||
|
.select({ userId: userOrgRoles.userId })
|
||||||
|
.from(userOrgRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgRoles.roleId, budget.roleId),
|
||||||
|
eq(userOrgRoles.orgId, ctx.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const userIds = members.map((m) => m.userId);
|
||||||
|
if (userIds.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return sumUsageAmount(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
inArray(aiUsageRecords.userId, userIds),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)!,
|
||||||
|
budget.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (budget.virtualApiKeyId != null) {
|
||||||
|
return sumUsageAmount(
|
||||||
|
and(
|
||||||
|
eq(aiUsageRecords.orgId, ctx.orgId),
|
||||||
|
eq(aiUsageRecords.virtualApiKeyId, budget.virtualApiKeyId),
|
||||||
|
gte(aiUsageRecords.createdAt, start)
|
||||||
|
)!,
|
||||||
|
budget.unit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached wrapper around sumUsageForBudget. Reuses a per-budget cached sum
|
||||||
|
* for up to BUDGET_CACHE_REFRESH_MS, and otherwise falls through to the DB
|
||||||
|
* aggregation and reseeds the cache. Completed requests within that window
|
||||||
|
* top the cached sum up via applyUsageToBudgetCache below rather than
|
||||||
|
* forcing a re-aggregation on every request.
|
||||||
|
*/
|
||||||
|
async function getBudgetUsage(
|
||||||
|
budget: AiBudget,
|
||||||
|
ctx: BudgetScopeContext,
|
||||||
|
now: number
|
||||||
|
): Promise<number> {
|
||||||
|
const cacheKey = budgetUsageCacheKey(budget.budgetId);
|
||||||
|
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
|
||||||
|
if (cached && now - cached.computedAt < BUDGET_CACHE_REFRESH_MS) {
|
||||||
|
return cached.sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = await sumUsageForBudget(budget, ctx, now);
|
||||||
|
await cache.set(
|
||||||
|
cacheKey,
|
||||||
|
{ sum, computedAt: now } satisfies CachedBudgetUsage,
|
||||||
|
BUDGET_CACHE_SAFETY_TTL_SEC
|
||||||
|
);
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once a request's actual usage is known, for every budget that was
|
||||||
|
* resolved as applicable to it (i.e. checkBudgets' returned `budgets`).
|
||||||
|
* Adds this request's contribution directly onto each budget's cached sum
|
||||||
|
* so the next request in the same refresh window doesn't need to re-query
|
||||||
|
* or re-aggregate. If there's no warm cache entry, or it's already due for
|
||||||
|
* a refresh, this is a no-op - the next reader re-derives from the DB,
|
||||||
|
* which by then already includes this request's row via recordUsage.
|
||||||
|
*/
|
||||||
|
export async function applyUsageToBudgetCache(
|
||||||
|
budgets: AiBudget[],
|
||||||
|
usage: { usd: number; tokens: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
budgets.map(async (budget) => {
|
||||||
|
const delta = budget.unit === "usd" ? usage.usd : usage.tokens;
|
||||||
|
if (!delta) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = budgetUsageCacheKey(budget.budgetId);
|
||||||
|
const cached = await cache.get<CachedBudgetUsage>(cacheKey);
|
||||||
|
if (
|
||||||
|
!cached ||
|
||||||
|
Date.now() - cached.computedAt >= BUDGET_CACHE_REFRESH_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await cache.set(
|
||||||
|
cacheKey,
|
||||||
|
{
|
||||||
|
sum: cached.sum + delta,
|
||||||
|
computedAt: cached.computedAt
|
||||||
|
} satisfies CachedBudgetUsage,
|
||||||
|
BUDGET_CACHE_SAFETY_TTL_SEC
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttled to one durable event per budget per breach window, so a soft
|
||||||
|
// budget being exceeded doesn't write a row on every subsequent request
|
||||||
|
// while it stays over.
|
||||||
|
async function recordBreachEventIfNew(
|
||||||
|
budget: AiBudget,
|
||||||
|
ctx: BudgetScopeContext,
|
||||||
|
usageAmount: number,
|
||||||
|
now: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const start = windowStart(budget.period, now);
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: aiBudgetBreachEvents.id })
|
||||||
|
.from(aiBudgetBreachEvents)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aiBudgetBreachEvents.budgetId, budget.budgetId),
|
||||||
|
gte(aiBudgetBreachEvents.createdAt, start)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(aiBudgetBreachEvents).values({
|
||||||
|
orgId: ctx.orgId,
|
||||||
|
budgetId: budget.budgetId,
|
||||||
|
enforcement: budget.enforcement,
|
||||||
|
unit: budget.unit,
|
||||||
|
period: budget.period,
|
||||||
|
amount: budget.amount,
|
||||||
|
usageAmount,
|
||||||
|
blocked: budget.enforcement === "hard",
|
||||||
|
requestUserId: ctx.requestUserId,
|
||||||
|
createdAt: now
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to record AI budget breach event", {
|
||||||
|
error,
|
||||||
|
budgetId: budget.budgetId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BudgetCheckResult = {
|
||||||
|
blocked: boolean;
|
||||||
|
blockingBudget?: AiBudget;
|
||||||
|
// Every budget resolved as applicable to this request, regardless of
|
||||||
|
// whether it was breached - pass to applyUsageToBudgetCache once this
|
||||||
|
// request's actual usage is known.
|
||||||
|
budgets: AiBudget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function checkBudgets(
|
||||||
|
ctx: BudgetScopeContext
|
||||||
|
): Promise<BudgetCheckResult> {
|
||||||
|
const budgets = await resolveApplicableBudgets(ctx);
|
||||||
|
if (budgets.length === 0) {
|
||||||
|
return { blocked: false, budgets: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let blockingBudget: AiBudget | undefined;
|
||||||
|
|
||||||
|
for (const budget of budgets) {
|
||||||
|
const usage = await getBudgetUsage(budget, ctx, now);
|
||||||
|
if (usage < budget.amount) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await recordBreachEventIfNew(budget, ctx, usage, now);
|
||||||
|
|
||||||
|
if (budget.enforcement === "hard" && !blockingBudget) {
|
||||||
|
blockingBudget = budget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockingBudget
|
||||||
|
? { blocked: true, blockingBudget, budgets }
|
||||||
|
: { blocked: false, budgets };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsageRecordInput = {
|
||||||
|
orgId: string;
|
||||||
|
providerId: number;
|
||||||
|
resourceId: number | null;
|
||||||
|
siteResourceId: number | null;
|
||||||
|
userId: string | null;
|
||||||
|
virtualApiKeyId: string | null;
|
||||||
|
requestedModel: string;
|
||||||
|
usage: AiUsage;
|
||||||
|
costUsd: number | null;
|
||||||
|
createdAt?: number;
|
||||||
|
// Same id as the aiSessionLog row logged for this request, so the two
|
||||||
|
// can be joined to show token/cost usage alongside the session
|
||||||
|
// transcript. Undefined when the session wasn't logged (e.g. session
|
||||||
|
// log retention disabled for the org).
|
||||||
|
sessionId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiUsageRecordInsert = InferInsertModel<typeof aiUsageRecords>;
|
||||||
|
|
||||||
|
// In-memory buffer for batching AI usage record inserts, mirroring the
|
||||||
|
// approach in server/routers/badger/logRequestAudit.ts. Usage rows are read
|
||||||
|
// back on every budget-cache miss (see getBudgetUsage above), which happens
|
||||||
|
// at least every BUDGET_CACHE_REFRESH_MS, so this buffer is flushed much
|
||||||
|
// more aggressively than the request audit log to keep the table from
|
||||||
|
// lagging behind what budget enforcement needs. Unlike the audit log, there
|
||||||
|
// is no retention/cleanup job for this table - usage history is kept
|
||||||
|
// indefinitely for billing and historical reporting.
|
||||||
|
const usageRecordBuffer: AiUsageRecordInsert[] = [];
|
||||||
|
|
||||||
|
const USAGE_BATCH_SIZE = 20; // Write to DB every 20 records
|
||||||
|
const USAGE_BATCH_INTERVAL_MS = 1000; // Or every 1 second, whichever comes first
|
||||||
|
const USAGE_MAX_BUFFER_SIZE = 5000; // Prevent unbounded memory growth
|
||||||
|
let usageFlushTimer: NodeJS.Timeout | null = null;
|
||||||
|
let isUsageFlushInProgress = false;
|
||||||
|
|
||||||
|
async function flushUsageRecords() {
|
||||||
|
if (usageRecordBuffer.length === 0 || isUsageFlushInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isUsageFlushInProgress = true;
|
||||||
|
|
||||||
|
const recordsToWrite = usageRecordBuffer.splice(
|
||||||
|
0,
|
||||||
|
usageRecordBuffer.length
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a transaction to ensure all inserts succeed or fail together
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Batch insert in groups to avoid overwhelming the database
|
||||||
|
const DB_BATCH_SIZE = 25;
|
||||||
|
for (let i = 0; i < recordsToWrite.length; i += DB_BATCH_SIZE) {
|
||||||
|
const batch = recordsToWrite.slice(i, i + DB_BATCH_SIZE);
|
||||||
|
await tx.insert(aiUsageRecords).values(batch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.debug(
|
||||||
|
`Flushed ${recordsToWrite.length} AI usage records to database`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error flushing AI usage records:", error);
|
||||||
|
// On transaction error, put records back at the front of the buffer
|
||||||
|
// to retry, but only if the buffer isn't too large
|
||||||
|
if (
|
||||||
|
usageRecordBuffer.length <
|
||||||
|
USAGE_MAX_BUFFER_SIZE - recordsToWrite.length
|
||||||
|
) {
|
||||||
|
usageRecordBuffer.unshift(...recordsToWrite);
|
||||||
|
logger.info(
|
||||||
|
`Re-queued ${recordsToWrite.length} AI usage records for retry`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
`Buffer full, dropped ${recordsToWrite.length} AI usage records`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUsageFlushInProgress = false;
|
||||||
|
// If buffer filled up while we were flushing, flush again
|
||||||
|
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
|
||||||
|
flushUsageRecords().catch((err) =>
|
||||||
|
logger.error("Error in follow-up AI usage flush:", err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleUsageFlush() {
|
||||||
|
if (usageFlushTimer === null) {
|
||||||
|
usageFlushTimer = setTimeout(() => {
|
||||||
|
usageFlushTimer = null;
|
||||||
|
flushUsageRecords().catch((err) =>
|
||||||
|
logger.error("Error in scheduled AI usage flush:", err)
|
||||||
|
);
|
||||||
|
}, USAGE_BATCH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully flush all pending AI usage records (call this on shutdown).
|
||||||
|
*/
|
||||||
|
export async function shutdownUsageRecorder() {
|
||||||
|
if (usageFlushTimer) {
|
||||||
|
clearTimeout(usageFlushTimer);
|
||||||
|
usageFlushTimer = null;
|
||||||
|
}
|
||||||
|
// Force flush even if one is in progress by waiting and retrying
|
||||||
|
while (isUsageFlushInProgress) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
await flushUsageRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordUsage(input: UsageRecordInput): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { usage } = input;
|
||||||
|
const totalTokens =
|
||||||
|
usage.promptTokens +
|
||||||
|
usage.cacheReadTokens +
|
||||||
|
usage.cacheWriteTokens +
|
||||||
|
usage.completionTokens +
|
||||||
|
usage.reasoningTokens;
|
||||||
|
|
||||||
|
// Prevent unbounded buffer growth - drop oldest entries if buffer is too large
|
||||||
|
if (usageRecordBuffer.length >= USAGE_MAX_BUFFER_SIZE) {
|
||||||
|
const dropped = usageRecordBuffer.splice(0, USAGE_BATCH_SIZE);
|
||||||
|
logger.warn(
|
||||||
|
`AI usage record buffer exceeded max size (${USAGE_MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
usageRecordBuffer.push({
|
||||||
|
orgId: input.orgId,
|
||||||
|
providerId: input.providerId,
|
||||||
|
resourceId: input.resourceId,
|
||||||
|
siteResourceId: input.siteResourceId,
|
||||||
|
userId: input.userId,
|
||||||
|
virtualApiKeyId: input.virtualApiKeyId,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
requestedModel: input.requestedModel,
|
||||||
|
promptTokens: usage.promptTokens,
|
||||||
|
cacheReadTokens: usage.cacheReadTokens,
|
||||||
|
cacheWriteTokens: usage.cacheWriteTokens,
|
||||||
|
completionTokens: usage.completionTokens,
|
||||||
|
reasoningTokens: usage.reasoningTokens,
|
||||||
|
totalTokens,
|
||||||
|
costUsd: input.costUsd,
|
||||||
|
estimated: usage.estimated,
|
||||||
|
createdAt: input.createdAt ?? Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush immediately if buffer is full, otherwise schedule a flush
|
||||||
|
if (usageRecordBuffer.length >= USAGE_BATCH_SIZE) {
|
||||||
|
flushUsageRecords().catch((err) =>
|
||||||
|
logger.error("Error flushing AI usage records:", err)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scheduleUsageFlush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to record AI usage", { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
import { AI_CAPABILITIES, type AiCapability } from "@app/lib/aiCapabilities";
|
||||||
|
|
||||||
|
export { AI_CAPABILITIES, type AiCapability };
|
||||||
|
|
||||||
|
export type AiCapabilityRoute = {
|
||||||
|
method: "POST";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiProtocolFamily = "openai" | "anthropic" | "google" | "bedrock";
|
||||||
|
|
||||||
|
export type AiCapabilityDefinition = {
|
||||||
|
id: AiCapability;
|
||||||
|
protocolFamily: AiProtocolFamily;
|
||||||
|
routes: AiCapabilityRoute[];
|
||||||
|
extractModel: (req: Request) => string | undefined;
|
||||||
|
resolveUpstreamUrl: (
|
||||||
|
baseUrl: string,
|
||||||
|
req: Request,
|
||||||
|
model: string
|
||||||
|
) => string;
|
||||||
|
isStreaming: (req: Request, contentType: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function bodyModel(req: Request): string | undefined {
|
||||||
|
return typeof req.body?.model === "string" ? req.body.model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramModel(req: Request): string | undefined {
|
||||||
|
const model = req.params?.model;
|
||||||
|
return typeof model === "string" && model.length > 0 ? model : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinUpstreamUrl(baseUrl: string, path: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
let suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
|
||||||
|
let basePathname = "/";
|
||||||
|
try {
|
||||||
|
basePathname = new URL(base).pathname.replace(/\/+$/, "") || "/";
|
||||||
|
} catch {
|
||||||
|
// Fall through with "/" non-absolute bases are not expected in
|
||||||
|
// production, but keep joining usable for malformed input.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePathname !== "/") {
|
||||||
|
const baseSegs = basePathname.split("/").filter(Boolean);
|
||||||
|
const pathSegs = suffix.split("/").filter(Boolean);
|
||||||
|
const max = Math.min(baseSegs.length, pathSegs.length);
|
||||||
|
let overlap = 0;
|
||||||
|
for (let n = max; n >= 1; n--) {
|
||||||
|
const baseSuffix = baseSegs.slice(-n);
|
||||||
|
const pathPrefix = pathSegs.slice(0, n);
|
||||||
|
if (baseSuffix.every((seg, i) => seg === pathPrefix[i])) {
|
||||||
|
overlap = n;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (overlap > 0) {
|
||||||
|
const remaining = pathSegs.slice(overlap);
|
||||||
|
suffix = remaining.length > 0 ? `/${remaining.join("/")}` : "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suffix === "/") {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathFromRequest(req: Request): string {
|
||||||
|
const raw = req.originalUrl || req.url || req.path;
|
||||||
|
return raw.startsWith("/") ? raw : `/${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyRequestsStream(req: Request): boolean {
|
||||||
|
return req.body?.stream === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeIsSse(contentType: string): boolean {
|
||||||
|
return contentType.includes("text/event-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeIsAmazonEventStream(contentType: string): boolean {
|
||||||
|
return contentType.includes("application/vnd.amazon.eventstream");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathIncludes(req: Request, fragment: string): boolean {
|
||||||
|
return pathFromRequest(req).includes(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBodyOrSseStreaming(req: Request, contentType: string): boolean {
|
||||||
|
return bodyRequestsStream(req) || contentTypeIsSse(contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGeminiStyleStreaming(req: Request, contentType: string): boolean {
|
||||||
|
return (
|
||||||
|
pathIncludes(req, "streamGenerateContent") ||
|
||||||
|
pathIncludes(req, "alt=sse") ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AI_CAPABILITY_DEFS: Record<AiCapability, AiCapabilityDefinition> =
|
||||||
|
{
|
||||||
|
openai_chat: {
|
||||||
|
id: "openai_chat",
|
||||||
|
protocolFamily: "openai",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/v1/chat/completions" },
|
||||||
|
{ method: "POST", path: "/chat/completions" }
|
||||||
|
],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
openai_responses: {
|
||||||
|
id: "openai_responses",
|
||||||
|
protocolFamily: "openai",
|
||||||
|
routes: [{ method: "POST", path: "/v1/responses" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
anthropic_messages: {
|
||||||
|
id: "anthropic_messages",
|
||||||
|
protocolFamily: "anthropic",
|
||||||
|
routes: [{ method: "POST", path: "/v1/messages" }],
|
||||||
|
extractModel: bodyModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isBodyOrSseStreaming
|
||||||
|
},
|
||||||
|
gemini_generate_content: {
|
||||||
|
id: "gemini_generate_content",
|
||||||
|
protocolFamily: "google",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1beta/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isGeminiStyleStreaming
|
||||||
|
},
|
||||||
|
google_generate_content: {
|
||||||
|
id: "google_generate_content",
|
||||||
|
protocolFamily: "google",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
// Vertex publisher model generateContent
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:generateContent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamGenerateContent"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: isGeminiStyleStreaming
|
||||||
|
},
|
||||||
|
google_raw_predict: {
|
||||||
|
id: "google_raw_predict",
|
||||||
|
protocolFamily: "google",
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:rawPredict"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/v1/projects/:project/locations/:location/publishers/:publisher/models/:model\\:streamRawPredict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "streamRawPredict") ||
|
||||||
|
pathIncludes(req, "alt=sse") ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
},
|
||||||
|
bedrock_model_invoke: {
|
||||||
|
id: "bedrock_model_invoke",
|
||||||
|
protocolFamily: "bedrock",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/invoke" },
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
path: "/model/:model/invoke-with-response-stream"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "invoke-with-response-stream") ||
|
||||||
|
contentTypeIsAmazonEventStream(contentType) ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
},
|
||||||
|
bedrock_converse: {
|
||||||
|
id: "bedrock_converse",
|
||||||
|
protocolFamily: "bedrock",
|
||||||
|
routes: [
|
||||||
|
{ method: "POST", path: "/model/:model/converse" },
|
||||||
|
{ method: "POST", path: "/model/:model/converse-stream" }
|
||||||
|
],
|
||||||
|
extractModel: paramModel,
|
||||||
|
resolveUpstreamUrl: (base, req) =>
|
||||||
|
joinUpstreamUrl(base, pathFromRequest(req)),
|
||||||
|
isStreaming: (req, contentType) =>
|
||||||
|
pathIncludes(req, "converse-stream") ||
|
||||||
|
contentTypeIsAmazonEventStream(contentType) ||
|
||||||
|
contentTypeIsSse(contentType)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAiCapability(value: unknown): value is AiCapability {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(AI_CAPABILITIES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an Express-style route path from AI_CAPABILITY_DEFS into a RegExp.
|
||||||
|
* Handles `:param` segments and escaped literal colons (`\:`).
|
||||||
|
*/
|
||||||
|
export function routePatternToRegExp(routePath: string): RegExp {
|
||||||
|
let pattern = "";
|
||||||
|
for (let i = 0; i < routePath.length; i++) {
|
||||||
|
const ch = routePath[i];
|
||||||
|
if (
|
||||||
|
ch === "\\" &&
|
||||||
|
i + 1 < routePath.length &&
|
||||||
|
routePath[i + 1] === ":"
|
||||||
|
) {
|
||||||
|
pattern += ":";
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === ":") {
|
||||||
|
// Named param: consume until next / or end
|
||||||
|
i++;
|
||||||
|
while (
|
||||||
|
i < routePath.length &&
|
||||||
|
routePath[i] !== "/" &&
|
||||||
|
!(routePath[i] === "\\" && routePath[i + 1] === ":")
|
||||||
|
) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i--; // loop will ++
|
||||||
|
pattern += "[^/]+";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Escape regex special chars
|
||||||
|
if (/[.*+?^${}()|[\]\\]/.test(ch)) {
|
||||||
|
pattern += "\\" + ch;
|
||||||
|
} else {
|
||||||
|
pattern += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RegExp(`^${pattern}$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAiCapabilityFromPath(path: string): AiCapability | null {
|
||||||
|
const pathname = path.split("?")[0] || "/";
|
||||||
|
const normalized = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||||
|
|
||||||
|
for (const def of Object.values(AI_CAPABILITY_DEFS)) {
|
||||||
|
for (const route of def.routes) {
|
||||||
|
if (routePatternToRegExp(route.path).test(normalized)) {
|
||||||
|
return def.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCapabilities(raw: unknown): AiCapability[] {
|
||||||
|
if (raw == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown = raw;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: AiCapability[] = [];
|
||||||
|
const seen = new Set<AiCapability>();
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (isAiCapability(item) && !seen.has(item)) {
|
||||||
|
seen.add(item);
|
||||||
|
out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCapabilities(capabilities: AiCapability[]): string {
|
||||||
|
return JSON.stringify(capabilities);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerHasCapability(
|
||||||
|
capabilities: AiCapability[] | string | null | undefined,
|
||||||
|
capability: AiCapability
|
||||||
|
): boolean {
|
||||||
|
const list =
|
||||||
|
typeof capabilities === "string" || capabilities == null
|
||||||
|
? parseCapabilities(capabilities)
|
||||||
|
: capabilities;
|
||||||
|
return list.includes(capability);
|
||||||
|
}
|
||||||