Compare commits

..

327 Commits

Author SHA1 Message Date
Owen e7f5f04632 Pass 2 bring over browser resources and http resource cert gen 2026-08-14 18:06:46 -04:00
Owen 0c0606b158 Pass 1 of pulling traefik config into functions 2026-08-14 17:42:23 -04:00
Owen 52f5ad6523 Show the cert status on the frontend 2026-08-14 17:08:43 -04:00
Owen 780906a232 Move certificates 2026-08-14 16:57:18 -04:00
Owen b4c509e8f6 Move the acme cert sync 2026-08-14 16:44:18 -04:00
Owen 24998ce0a1 Remove advanced resources paywall 2026-08-14 16:33:05 -04:00
Owen 111d4b1b8c Make sure certs are created when creating and updating private resources 2026-08-14 14:33:59 -04:00
Owen 32d76ee2ac Resolve cert not generated without a provider 2026-08-14 14:27:14 -04:00
Owen 98ad6fb31b Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-14 14:21:04 -04:00
Owen 3a18a3994f Fix private resource ssl when applying through blueprints 2026-08-14 14:21:00 -04:00
Owen 6d35b20880 Fix overlap check 2026-08-14 14:14:56 -04:00
miloschwartz 173d4a536f visual tweaks 2026-08-14 13:45:52 -04:00
miloschwartz d7bff686c0 add help banner to providers and virtual api keys 2026-08-14 12:24:00 -04:00
Owen 193f6da8d1 Handle the list of models without allow/block 2026-08-14 12:23:20 -04:00
Owen 954dc8d1d9 Handle overlapping fullDomain properly 2026-08-14 12:23:20 -04:00
Owen 234819f8f2 Dont load private resource inference without a exit node loaded 2026-08-14 12:23:20 -04:00
miloschwartz b34a8d116b improve org selector 2026-08-14 12:18:55 -04:00
miloschwartz 7375b9efae improve model picker 2026-08-14 11:56:02 -04:00
Owen c8f170d197 Allow budgets to be set on the resources 2026-08-14 11:43:01 -04:00
Owen 4989d1e31a Allow overlapping domains on public inference resources 2026-08-14 11:43:01 -04:00
miloschwartz c568251d8d improve model picker 2026-08-14 11:38:11 -04:00
miloschwartz db2de4af89 improve usage analytics filters 2026-08-14 11:11:52 -04:00
Owen 574ae8f5f9 Include inference support in blueprints 2026-08-14 10:51:59 -04:00
Owen 125091d719 Send email whitelist user downstream headers
Fix #1226
2026-08-14 10:51:59 -04:00
Owen 5d37f44241 Add niceid to ui and api for providers 2026-08-14 10:51:58 -04:00
Owen f0f5e9219b add niceId to provider 2026-08-14 10:51:58 -04:00
miloschwartz ee75a09f8c improve sign in url style 2026-08-14 10:33:44 -04:00
miloschwartz c49c6f5837 improve budget editor component 2026-08-14 10:27:17 -04:00
Owen 03118f1ede Support labels on blueprints
Ref https://github.com/orgs/fosrl/discussions/2849
2026-08-14 09:16:50 -04:00
miloschwartz 0ff5ea4f6a strip settings from tab name 2026-08-13 17:08:37 -04:00
Owen 978fa8bfc6 Fix refresh buttons and dont auto refresh
Fixes #3560
2026-08-13 16:57:43 -04:00
Owen 36c7c5dc34 Rename AI tables to not have ai in the name 2026-08-13 16:57:43 -04:00
miloschwartz 8f3628d4ec rename inference to ai gateway 2026-08-13 16:57:29 -04:00
miloschwartz fcd3c72cac add missing routes to integration api 2026-08-13 16:35:47 -04:00
Owen 3f0be4a39d Filter out unhealthy targets 2026-08-13 16:32:04 -04:00
miloschwartz f9ff3a5715 improve resource type picker 2026-08-13 15:52:33 -04:00
miloschwartz ed71c90d73 move ai logs under gateway section 2026-08-13 15:28:22 -04:00
miloschwartz e580c7db7c use pangolin-key prefix for virtual api keys 2026-08-13 15:22:18 -04:00
miloschwartz 4a9b0117a2 copy key without clicking reveal first 2026-08-13 15:16:39 -04:00
miloschwartz 9b10292e02 add model picker to create provider wizard 2026-08-13 15:08:10 -04:00
miloschwartz d9a9ae14fd various ui improvements 2026-08-13 14:54:09 -04:00
Owen db716f781e Revert "fix logo warning in logs"
This reverts commit 355294a2d4.
2026-08-13 13:51:11 -04:00
Owen e92bb9043b Fix issues with possible null providerId 2026-08-13 11:04:11 -04:00
Owen a2f5d830d6 Remove not null constraint where setting null 2026-08-13 10:20:35 -04:00
miloschwartz 77e3422e88 change provider descriptions and dont set default url for bedrock 2026-08-13 10:13:32 -04:00
miloschwartz 9a551c91e5 always require upstream url 2026-08-13 10:07:11 -04:00
Owen c35ac493ed Rebalance the data in the expand column 2026-08-13 09:38:42 -04:00
Owen e100645a00 Use the right subnet 2026-08-13 09:38:42 -04:00
miloschwartz 96bfc66a94 set default provider name 2026-08-13 09:37:53 -04:00
miloschwartz e21c9eec31 Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-13 09:29:02 -04:00
miloschwartz a9f32102b5 format custom error codes according to capability 2026-08-13 09:26:58 -04:00
Owen 7291628f86 optionally stamp ip header for downstream use 2026-08-13 09:14:37 -04:00
Owen 5fbb205044 search the right resource 2026-08-12 17:52:39 -04:00
Owen 02ab24d01e Fix clients not connecting on first connect 2026-08-12 17:16:59 -04:00
Owen 3cfbd66a80 Merge branch 'dev' into aig 2026-08-12 16:34:52 -04:00
Owen 860fa47b7c Split out the token header into a common middleware for efficiency 2026-08-12 16:30:42 -04:00
miloschwartz 71d9d8f010 fix accept invite as idp user 2026-08-12 16:25:20 -04:00
Owen cc58e28e54 Allow merging a catalog file with the api response 2026-08-12 16:06:12 -04:00
Owen 653dd920ad Allow remote header overrides and fix display issues with log 2026-08-12 15:54:22 -04:00
miloschwartz dd78c2cc08 dont show servers idps in create user when idp mode is org on enterprise 2026-08-12 15:20:41 -04:00
Owen 47ae017f94 add virtual api keys to budgets 2026-08-12 13:21:56 -04:00
miloschwartz c33fa8782b Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-12 12:35:30 -04:00
miloschwartz 93cba1d098 support sending capability specific error codes 2026-08-12 12:34:25 -04:00
Owen d3a1f9798d Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-12 11:45:28 -04:00
miloschwartz 115c3cbf07 show sso page in browser for gateway resource 2026-08-12 11:32:22 -04:00
Owen bed5817da3 send, process, store, display virtual api key ai information in usage and sessions 2026-08-12 11:20:25 -04:00
miloschwartz 83035753af support bypass auth rules on gateway 2026-08-12 11:18:16 -04:00
miloschwartz 379b53bcca redirect to api key page when access gateway in browser 2026-08-12 11:15:50 -04:00
Owen 355294a2d4 fix logo warning in logs 2026-08-12 10:48:06 -04:00
Owen 833b27ab4a separate the users and roles tabs 2026-08-12 10:40:38 -04:00
Owen 8a04f13dd4 translate keys and restructure roles and users 2026-08-12 10:40:38 -04:00
miloschwartz 49020fa6ea add virtual api key validation in verifySession 2026-08-12 10:38:26 -04:00
Owen ac3402a8b3 Adjust structure delete models 2026-08-12 09:41:42 -04:00
Owen 9369e60695 Merge branch 'dev' into aig 2026-08-12 09:19:12 -04:00
Owen 295e38d2af Remove arbitrary max 50 limit from email whitelist
Fix #3568
2026-08-12 09:11:10 -04:00
Owen 11595f5f96 use title case and fix colors 2026-08-11 18:02:17 -04:00
Owen b08e875b37 basic ai analytics created 2026-08-11 17:52:27 -04:00
Owen 0016b8fce7 show other tokens on the expanded row 2026-08-11 16:40:24 -04:00
Owen f5b10df7cf fix non-https crypto issue 2026-08-11 16:40:24 -04:00
miloschwartz 1782f31075 show link to provider settings 2026-08-11 16:38:22 -04:00
Owen f7aca85417 link the usage with the session to display together 2026-08-11 16:15:01 -04:00
miloschwartz c42df737b0 improve model provider selection algorithm 2026-08-11 16:10:46 -04:00
Owen 75c6af3b11 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-11 15:58:12 -04:00
Owen e734cc93a1 small visual adjustments and chat button 2026-08-11 15:57:57 -04:00
Owen e3ccc4f8d4 show the sessions in the ui 2026-08-11 15:50:51 -04:00
miloschwartz 896c362dce only support sso on inference resource auth policy 2026-08-11 15:35:28 -04:00
miloschwartz 864ab97293 hide expire column 2026-08-11 14:59:03 -04:00
Owen bc2f291352 normalize the requests to also store in the log for viewing later 2026-08-11 14:45:25 -04:00
miloschwartz 98f5e39a7f show ai gateway resource details in launcher 2026-08-11 14:42:07 -04:00
Owen e0a66e79bb Continue to quiet log messages 2026-08-11 14:07:33 -04:00
Owen c781be4e70 quiet up some more logs 2026-08-11 14:05:58 -04:00
Owen 48c4b44f72 log chat sessions to database 2026-08-11 12:19:28 -04:00
Owen 68a84181d9 remove excess logging 2026-08-11 12:19:27 -04:00
Owen b60390d805 batch up usage records for insert 2026-08-11 12:19:27 -04:00
miloschwartz b33e776072 add virtual keys link in header on launcher 2026-08-11 12:09:11 -04:00
miloschwartz 2187f23588 add page to retrieve user virtual api keys 2026-08-11 11:53:39 -04:00
miloschwartz 732fd4eba1 allow retrieving api key secret 2026-08-11 10:47:04 -04:00
Owen 495c772d6b Merge branch 'dev' into aig 2026-08-11 09:42:10 -04:00
Owen 02e4fe8b48 Show the provided domains when changing properly 2026-08-11 09:40:55 -04:00
miloschwartz 6d45486bb5 add manual virtual api key create ui 2026-08-10 21:44:38 -04:00
Owen 4c1f7f6243 add caching to budget check 2026-08-10 17:39:00 -04:00
miloschwartz 1e3a9fb921 add virtual api key schema and crud endpoints 2026-08-10 17:20:06 -04:00
Owen 8b8e7913dc budget enforcement logic 2026-08-10 17:17:24 -04:00
Owen 88515a7dad remove duplicate save buttons 2026-08-10 15:31:45 -04:00
Owen e012063f5b add the budget to the models on the provider 2026-08-10 15:09:39 -04:00
Owen e76377d3c7 Match the pg query logging 2026-08-10 14:55:44 -04:00
Owen a357f42c48 allow creating role with budgets 2026-08-10 14:55:44 -04:00
Owen 2becb15916 put the budget on the role form 2026-08-10 14:55:44 -04:00
miloschwartz 186eeed784 add richer model editor on provider 2026-08-10 14:54:58 -04:00
Owen 216c932cb9 order by budget id 2026-08-10 14:12:34 -04:00
Owen 52de5eb539 add the budget config to the resources 2026-08-10 14:08:17 -04:00
Owen eec3119297 Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-10 13:55:10 -04:00
Owen f2e7f83b3d match the budget picker to the rules picker 2026-08-10 13:55:05 -04:00
miloschwartz 93dea0525e basic provider model selector 2026-08-10 12:32:17 -04:00
Owen 7e6e0a8a9c make the budget match the rules 2026-08-10 12:20:47 -04:00
Owen 211d3a53f5 initial budget ui on the provider 2026-08-10 12:03:51 -04:00
miloschwartz 187936e5dd use class with helper methods for model catelog 2026-08-10 11:54:57 -04:00
Owen b180d064d1 adjust conflict to be on unit<>period and add list endpoints for providers 2026-08-10 11:39:34 -04:00
Owen ed46afd81a add subscription override column 2026-08-10 11:15:48 -04:00
Owen 0050fad70d add basic crud for ai budgets 2026-08-10 11:05:10 -04:00
miloschwartz 3dc9c100e9 better form feedback when saving roles 2026-08-10 11:02:44 -04:00
Owen 969e7c9296 add crud endpoint skill 2026-08-10 10:58:11 -04:00
Owen 591caab45a Add documentation for creating new crud endpoints 2026-08-10 10:47:18 -04:00
miloschwartz 02e97d6ae4 add copy button to org id 2026-08-10 10:44:25 -04:00
miloschwartz 996160fadc add missing resource policy actions to api key closes #3542 2026-08-10 10:38:40 -04:00
Owen dc1923ab1f Remove budget periods 2026-08-10 09:57:01 -04:00
Owen fe5831eb48 pull the models from the upstream server 2026-08-10 09:57:00 -04:00
Owen ca79abc9d4 Update structure 2026-08-10 09:57:00 -04:00
miloschwartz 9eafa067b9 improved provider picker ui on resource 2026-08-07 16:13:51 -04:00
miloschwartz 5e5e172d39 add streaming function to capability 2026-08-07 14:36:15 -04:00
miloschwartz bc7a883f6c add some parallelization to ai gateway pipeline 2026-08-07 14:36:11 -04:00
Owen 12056aebc6 Add basic cost calculations for testing 2026-08-07 13:54:32 -04:00
miloschwartz 297cb9c8f2 set provider default capabilities 2026-08-07 12:25:29 -04:00
miloschwartz d699455b38 support changing capabilities on provider integrations 2026-08-07 11:23:07 -04:00
miloschwartz 9e7b4afaec support allow list and blocklist 2026-08-07 10:59:50 -04:00
Owen e91c344e64 Update link to be correct 2026-08-07 10:21:15 -04:00
Owen 07f628b928 add the pangolin header information 2026-08-06 17:53:51 -04:00
Owen 184e1425a4 Private connection working with traefik 2026-08-06 17:22:01 -04:00
Owen 22f2990f56 support streaming and closes properly on site targets 2026-08-06 17:15:11 -04:00
Owen 6c28c5f383 Basic target routing 2026-08-06 15:48:13 -04:00
Owen e9f9cf54f4 Include the sheme in the routing header 2026-08-06 15:18:11 -04:00
Owen 751d1b083d Add back port restrictions 2026-08-06 14:55:07 -04:00
Owen a4d77a4fd3 Fix info box ui 2026-08-06 14:45:25 -04:00
Owen f72252552f Fix saving 2026-08-06 14:28:14 -04:00
miloschwartz c5d68675c9 support patterns in model key 2026-08-06 14:27:08 -04:00
miloschwartz 36b8ef5fba Merge branch 'dev' into aig 2026-08-06 13:59:07 -04:00
Owen 6cca5e0472 Use fulldoman instead of the alias for the site resources 2026-08-06 13:45:51 -04:00
miloschwartz 4048fa274a fix non admins cant see private resources details in launcher 2026-08-06 12:32:19 -04:00
Owen b4d2974e19 Properly configure ssl and domain when creating and editing 2026-08-06 12:20:56 -04:00
Owen fb896d6c0f Add domain to the resource selection 2026-08-06 12:03:40 -04:00
miloschwartz 82b86263dc allow chars in 2fa input form closes #3532 2026-08-06 11:39:06 -04:00
Owen 6564bfe8ae disabled advanced mode on the providers 2026-08-06 11:00:34 -04:00
Owen 75ce7e91d7 Restrict the ai site resource router to exit node subnet 2026-08-06 11:00:34 -04:00
Owen 656eea5bb1 add a target routing provider for custom target resources 2026-08-06 11:00:34 -04:00
miloschwartz aba27a7bbf support skip tls per request 2026-08-05 17:53:28 -04:00
miloschwartz 3d7e322bf9 add headers to provider 2026-08-05 17:42:58 -04:00
miloschwartz 1f3fff4a9d Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-05 16:55:58 -04:00
miloschwartz 39e06f2b6d add api capabilities 2026-08-05 16:55:48 -04:00
Owen b775c5b674 include clients in the peer config 2026-08-05 16:24:09 -04:00
Owen 796bf37141 ai gateway override only required sometimes 2026-08-05 16:24:01 -04:00
Owen c5b0e1e876 add QUERY_LOGGING env to sqlite driver for verbose logging 2026-08-05 16:17:28 -04:00
miloschwartz 2e8bd7a8c7 improve form error codes 2026-08-05 15:56:12 -04:00
miloschwartz 790daba796 allow no provider on a resource 2026-08-05 15:43:42 -04:00
miloschwartz bcf6b86b84 add no auth and passthrough auth 2026-08-05 15:39:08 -04:00
miloschwartz 2e9bd50172 add provider specific auth modes 2026-08-05 15:17:50 -04:00
miloschwartz c673dce484 pin docker base image 2026-08-05 13:34:45 -04:00
Owen e1dd8965dc Remove extra checks 2026-08-05 11:31:12 -04:00
Owen 346a0bdc98 Resolve syntax issue 2026-08-05 10:31:14 -04:00
Owen 8c1169738e Merge branch 'aig' of github.com:fosrl/pangolin into aig 2026-08-05 10:24:42 -04:00
miloschwartz bc80f91a45 gateway endpoint mvp 2026-08-05 10:24:37 -04:00
Owen 3d062389e9 update oss with gateway traefik routers 2026-08-05 10:14:40 -04:00
Owen 7afddb5eb5 Clean logging 2026-08-05 10:00:36 -04:00
Owen 425a99e5ee Show the auth settings for testing 2026-08-05 09:59:41 -04:00
Owen a5e9339af9 Merge branch 'dev' into aig 2026-08-05 09:32:03 -04:00
Owen 835a30cffe Show the cert status of the namespace domains properly 2026-08-04 17:44:34 -04:00
miloschwartz 83e20c2dfa adjust placeholder ui for rules and targets 2026-08-04 17:36:00 -04:00
miloschwartz f85d41945a Merge branch 'aig' of https://github.com/fosrl/pangolin into aig 2026-08-04 17:27:10 -04:00
miloschwartz 149eb17b27 create basic public inference resource 2026-08-04 17:26:57 -04:00
Owen 18b90da6ab Merge branch 'main' into dev 2026-08-04 17:22:32 -04:00
Owen 72d469b19c Revert changes to traefik config to pull site resources 2026-08-04 17:17:51 -04:00
Owen 6526d7f225 Remove completions endpoints 2026-08-04 17:09:44 -04:00
Owen 36c0edc62e Use p host to pass the host header 2026-08-04 17:08:59 -04:00
Owen aad26b9ae4 move the ai gateway to its own server 2026-08-04 17:08:59 -04:00
Owen 80dcdfe251 change override to work for bother badger and ai gateway 2026-08-04 17:08:59 -04:00
miloschwartz 1696fc37a8 dont set port restrictions for inference 2026-08-04 17:04:29 -04:00
miloschwartz 7759d87835 add basic ui for private inference resource 2026-08-04 16:54:23 -04:00
miloschwartz c085de1e9e Merge branch 'aig' of https://github.com/fosrl/pangolin into aig
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 15:56:27 -04:00
miloschwartz e38359c74f add crud for adding providers and models to resources 2026-08-04 15:54:24 -04:00
Owen 2cfd7e867b delete the client peers from the exit nodes properly 2026-08-04 15:46:22 -04:00
Owen ec5a2b0cbe add peers to exit node 2026-08-04 15:46:22 -04:00
Owen 2bf426bc22 dont restrict to enterprise and dont link with providers yet 2026-08-04 15:46:21 -04:00
miloschwartz ed8545f8a2 update schema to support models and provider access on inferences resources and add budgets 2026-08-04 14:50:07 -04:00
Owen 9811492a0b send the aliases when connecting for the first time 2026-08-04 14:23:25 -04:00
Owen 973925b35d allow creating basic inference resource 2026-08-04 14:22:36 -04:00
Owen 0b30cfc341 Working on inference resource management 2026-08-04 11:48:36 -04:00
Owen a7e44944fb Use ip to find user 2026-08-04 10:43:49 -04:00
Owen 9b25ebd6cd remove token fallback 2026-08-04 10:19:22 -04:00
miloschwartz 33b775e263 clean up providers ui 2026-08-04 10:15:49 -04:00
miloschwartz b0edc6302e providers table, create, and edit first pass 2026-08-04 10:15:49 -04:00
Owen 1a644b131d Pull the session cookie properly 2026-08-04 10:15:49 -04:00
Owen f7689b7a5a Endpoints to update models on the resource 2026-08-04 10:15:49 -04:00
Owen 1073011a2a first pass of traefik -> basic gateway 2026-08-04 10:15:49 -04:00
miloschwartz 6fa0009ebf add targets and refactor endpoints 2026-08-04 10:15:49 -04:00
Owen 42c0abedb7 Add basic page to configure the alias 2026-08-04 10:15:48 -04:00
Owen 56e59a93f3 Handle the alias create and update special case exit nodes 2026-08-04 10:15:48 -04:00
Owen 9d581f3897 Fix types 2026-08-04 10:15:48 -04:00
miloschwartz e5ac6ec7cd add default providers and support overrides 2026-08-04 10:15:48 -04:00
miloschwartz 694fe98131 add crud for providers and models 2026-08-04 10:15:48 -04:00
miloschwartz 730078597e add basic schema 2026-08-04 10:15:48 -04:00
Owen 32ac8db803 Handle aliases when registering 2026-08-04 10:15:48 -04:00
Owen aad2ed2719 Get exit node every time 2026-08-04 10:15:48 -04:00
Owen a790fed297 sync the exit node connection 2026-08-04 10:15:48 -04:00
Owen deb2d5ce2a Update site resources to handle new inference 2026-08-04 10:15:48 -04:00
Owen 093097c619 send if we need to connect to the gerbil or not 2026-08-04 10:15:48 -04:00
Owen 2cdeb7c104 Add inference resource type 2026-08-04 10:15:47 -04:00
Owen 4dbb04bfb8 Handle the ping exit node request backward mode for olm and install handler 2026-08-04 10:15:47 -04:00
Owen fa8b921635 Fix postgres schema exit node subnet 2026-08-04 10:15:47 -04:00
Owen 33dd10c670 Rename subnet for clarity, pick subnet on client 2026-08-04 10:15:47 -04:00
Owen ba24e1c4f5 Add exit node selection to the clients 2026-08-04 10:15:47 -04:00
Owen f079714caf Dont redirect when the browser agent is not real 2026-08-04 10:07:52 -04:00
Owen efd2792197 bump default rate limit 2026-08-03 17:57:36 -04:00
Owen Schwartz efe22c889c Merge pull request #3522 from fosrl/dev
Move rate linmit to file
2026-08-03 14:35:43 -04:00
Owen 7f2b3eb481 Move rate linmit to file 2026-08-03 14:34:11 -04:00
Owen Schwartz 81be4a35d9 Merge pull request #3521 from fosrl/dev
1.21.1-s.1
2026-08-03 14:11:05 -04:00
Owen e84da6a8df Add rate limits to sensitve pages 2026-08-03 14:08:09 -04:00
miloschwartz 3ef3ede7df dont pass orgId in generate oidc url 2026-08-03 11:41:22 -04:00
Owen 1e521b0b54 Dont double log error 2026-08-03 10:35:26 -04:00
Owen 59ea701304 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-31 14:23:43 -04:00
miloschwartz 7d7c54107d add first seen and last seen columns to user devices table 2026-07-31 14:23:06 -04:00
Owen Schwartz f0f6673d69 Merge pull request #3511 from fosrl/feat/show-idp-icon-in-smart-login-form
feat: show the correct idp type icon in the last used idp
2026-07-31 09:32:01 -04:00
Owen Schwartz 71561d0e65 Merge pull request #3512 from fosrl/fix/batch-status-hover-data
Fix: resolve batch certificates for NS & Wildcard domains
2026-07-31 09:31:21 -04:00
Fred KISSIE af87edf3a6 ♻️ use const 2026-07-30 22:06:38 +01:00
Fred KISSIE 13caad18c7 🎨 format file 2026-07-30 21:53:46 +01:00
Fred KISSIE 47522b7e3a ♻️ set cert error if initial value is null 2026-07-30 21:53:31 +01:00
Fred KISSIE c8c8d74452 🐛 resolve certificates for non exact domains too 2026-07-30 21:52:51 +01:00
Fred KISSIE e7098963d6 ♻️ Code Refactoring 2026-07-30 17:56:15 +01:00
Fred KISSIE f015fb592b ♻️ Prioritize variant in place of idp.type if available 2026-07-30 17:55:58 +01:00
Fred KISSIE c099167905 Merge branch 'dev' into feat/show-idp-icon-in-smart-login-form 2026-07-30 17:37:47 +01:00
Owen b0e274f5a9 Fix 100% showing on resources with no hc anymore 2026-07-30 11:14:34 -04:00
Owen Schwartz 146c287aba Merge pull request #3505 from fosrl/dev
1.21.1
2026-07-29 21:27:33 -04:00
Owen Schwartz 95ee56217c Merge pull request #3504 from fosrl/crowdin_dev
New Crowdin updates
2026-07-29 21:25:59 -04:00
Owen Schwartz 7625cc208d New translations en-us.json (French)
[ci skip]
2026-07-29 18:14:07 -04:00
Owen Schwartz f2dfd939bc New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-29 18:14:05 -04:00
Owen Schwartz 699bc8ddb4 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-29 18:14:03 -04:00
Owen Schwartz e658e007e1 New translations en-us.json (Turkish)
[ci skip]
2026-07-29 18:14:01 -04:00
Owen Schwartz 3103265450 New translations en-us.json (Russian)
[ci skip]
2026-07-29 18:13:59 -04:00
Owen Schwartz 97789d9e2e New translations en-us.json (Portuguese)
[ci skip]
2026-07-29 18:13:57 -04:00
Owen Schwartz a1aa3d96d4 New translations en-us.json (Polish)
[ci skip]
2026-07-29 18:13:56 -04:00
Owen Schwartz ae39bc0ecd New translations en-us.json (Dutch)
[ci skip]
2026-07-29 18:13:54 -04:00
Owen Schwartz e4d81aa610 New translations en-us.json (Korean)
[ci skip]
2026-07-29 18:13:52 -04:00
Owen Schwartz f59b524266 New translations en-us.json (Italian)
[ci skip]
2026-07-29 18:13:50 -04:00
Owen Schwartz 58c499acae New translations en-us.json (German)
[ci skip]
2026-07-29 18:13:48 -04:00
Owen Schwartz f22301a1eb New translations en-us.json (Danish)
[ci skip]
2026-07-29 18:13:46 -04:00
Owen Schwartz d6264fb39a New translations en-us.json (Czech)
[ci skip]
2026-07-29 18:13:44 -04:00
Owen Schwartz 08b5a9b34b New translations en-us.json (Bulgarian)
[ci skip]
2026-07-29 18:13:42 -04:00
Owen Schwartz 263976bf41 New translations en-us.json (Spanish)
[ci skip]
2026-07-29 18:13:40 -04:00
Owen Schwartz 0e4cd3a5ba Merge pull request #3414 from fosrl/dependabot/go_modules/install/go-install-dependencies-3804ca7238
Bump golang.org/x/term from 0.44.0 to 0.45.0 in /install in the go-install-dependencies group across 1 directory
2026-07-29 18:00:54 -04:00
Owen Schwartz aeed271f3e Merge pull request #3494 from fosrl/dependabot/npm_and_yarn/next-16.2.11
Bump next from 16.2.6 to 16.2.11
2026-07-29 17:58:09 -04:00
Owen Schwartz d41e9bbe68 Merge pull request #3472 from fosrl/dependabot/npm_and_yarn/axios-1.18.0
Bump axios from 1.16.1 to 1.18.0
2026-07-29 17:57:46 -04:00
Owen Schwartz 904a5520b8 Merge pull request #3476 from fosrl/dependabot/npm_and_yarn/body-parser-2.3.0
Bump body-parser from 2.2.2 to 2.3.0
2026-07-29 17:57:34 -04:00
Owen Schwartz 0f43ae4871 Merge pull request #3486 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.4
Bump fast-uri from 3.1.2 to 3.1.4
2026-07-29 17:57:23 -04:00
Owen Schwartz bd0cf98319 Merge pull request #3489 from fosrl/dependabot/github_actions/github-actions-dependencies-3a33fbbccd
Bump the github-actions-dependencies group across 1 directory with 5 updates
2026-07-29 17:57:07 -04:00
Owen Schwartz fc7d6bbaf9 Merge pull request #3496 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.0
Bump js-yaml from 4.2.0 to 4.3.0
2026-07-29 17:56:37 -04:00
Owen 1b89c328de Merge branch 'main' into dev 2026-07-29 17:54:59 -04:00
miloschwartz e7f147d4ca fix site status stuck loading sometimes 2026-07-29 17:52:55 -04:00
miloschwartz 41dbc7d5ed remove time logs 2026-07-29 17:45:19 -04:00
miloschwartz 9f297317f2 bump version 2026-07-29 17:33:49 -04:00
Fred KISSIE e0a8721207 🚧 wip: idp type icon in last used idp 2026-07-29 22:29:56 +01:00
Owen Schwartz a69b310109 Merge pull request #3460 from shubhamsinnh/fix/idn-punycode-domain-validation
Fix Punycode domain validation
2026-07-29 17:29:25 -04:00
miloschwartz 864a6a39cd Merge branch 'main' into dev 2026-07-29 17:27:22 -04:00
Milo Schwartz 1882d4df88 Merge pull request #3469 from fosrl/refactor/batch-status-requests
refactor: batch status histories
2026-07-29 17:27:11 -04:00
miloschwartz ef3f71e9b5 Merge branch 'main' into dev 2026-07-29 17:09:54 -04:00
Milo Schwartz 9981c4c035 Merge pull request #3410 from Adityakk9031/#3408
fix: redirect to /auth/initial-setup after hitting auth rate limit (#…
2026-07-29 17:09:40 -04:00
Fred KISSIE bc267b7107 ♻️ refactor 2026-07-29 19:11:16 +01:00
Fred KISSIE 5cbb767e5f Set default certificates for private resources 2026-07-29 19:11:03 +01:00
Fred KISSIE ac79621cae ♻️ Adjust the polling interval to 30s for valid certs 2026-07-29 19:05:33 +01:00
miloschwartz f47c94d05b check idp org ownership on save policy closes #3290 2026-07-29 09:38:15 -04:00
Fred KISSIE 10773432bb ♻️ Batch certificate queries 2026-07-28 20:59:42 +01:00
dependabot[bot] d5a56fb71d Bump js-yaml from 4.2.0 to 4.3.0
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.2.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 12:24:11 +00:00
dependabot[bot] b5518d029e Bump next from 16.2.6 to 16.2.11
Bumps [next](https://github.com/vercel/next.js) from 16.2.6 to 16.2.11.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.6...v16.2.11)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 07:10:39 +00:00
Owen 9a9ae649ef Add default to path
Fix #3484
2026-07-27 10:02:32 -04:00
Owen aa6dc67015 Pull the version on the info page and use api 2026-07-27 10:01:12 -04:00
dependabot[bot] 0da96b06ea Bump the github-actions-dependencies group across 1 directory with 5 updates
Bumps the github-actions-dependencies group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `7.0.0` | `7.0.1` |
| [docker/login-action](https://github.com/docker/login-action) | `4.2.0` | `4.5.1` |
| [actions/setup-go](https://github.com/actions/setup-go) | `6.4.0` | `7.0.0` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.4.0` | `7.0.0` |
| [actions/stale](https://github.com/actions/stale) | `10.3.0` | `10.4.0` |



Updates `actions/checkout` from 7.0.0 to 7.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1)

Updates `docker/login-action` from 4.2.0 to 4.5.1
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...abd2ef45e78c5afb21d64d4ca52ee8550d9572c7)

Updates `actions/setup-go` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e)

Updates `actions/setup-node` from 6.4.0 to 7.0.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e...820762786026740c76f36085b0efc47a31fe5020)

Updates `actions/stale` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-node
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 01:34:06 +00:00
dependabot[bot] 6649f15498 Bump fast-uri from 3.1.2 to 3.1.4
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 12:40:05 +00:00
Fred KISSIE 49854f31a5 🚧 WIP: refactor useCertificate to use tanstack qeury 2026-07-24 21:03:52 +01:00
Fred KISSIE 762c79511b 🚧 Add batchedCertificate queries 2026-07-24 20:59:29 +01:00
Fred KISSIE 6e29ebd649 ♻️ refactor getbatchedcertificate 2026-07-24 20:58:47 +01:00
Fred KISSIE e128b8c282 get batched certificates endpoint 2026-07-24 19:07:37 +01:00
Fred KISSIE 832e382888 🚧 WIP: batch certificates 2026-07-23 22:00:31 +01:00
dependabot[bot] 8a68b899f5 Bump golang.org/x/term
Bumps the go-install-dependencies group with 1 update in the /install directory: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-install-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 01:33:10 +00:00
Owen Schwartz 71f45b8a80 Merge pull request #3479 from fosrl/dev
1.21.0-s.1
2026-07-22 17:27:34 -04:00
Owen ecf008a8d9 Improve cert retreival and new newt combined certs 2026-07-22 17:22:51 -04:00
Fred KISSIE b3880e5c02 ♻️ refactor 2026-07-22 21:34:13 +01:00
Fred KISSIE 58004a8ec9 ♻️ little refactor 2026-07-22 21:25:31 +01:00
Fred KISSIE b0ff7d2707 ♻️ batch healthcheck status history 2026-07-22 21:06:40 +01:00
Fred KISSIE 262f7bd090 resource status histories 2026-07-22 20:57:32 +01:00
Fred KISSIE b1558b09b1 ♻️ refactor imports 2026-07-22 19:33:00 +01:00
Fred KISSIE a33de8268b 🐛 Get last known events working with sqlite 2026-07-22 19:32:53 +01:00
dependabot[bot] c92d5096c4 Bump body-parser from 2.2.2 to 2.3.0
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.2.2 to 2.3.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 23:15:07 +00:00
Fred KISSIE 9867d3c876 ♻️ const instead of let 2026-07-21 20:31:57 +01:00
Fred KISSIE 70bddba55b Make batched status history work 2026-07-21 20:29:16 +01:00
Fred KISSIE 23181f4019 🚧 WIP: batched site status histories 2026-07-21 19:57:09 +01:00
Owen Schwartz 19c1c2042b Merge pull request #3468 from fosrl/fix/labels-dropdown-flicker
fix labels dropdown flicker if no changes applied
2026-07-20 20:28:37 -04:00
Fred KISSIE 9cc3190e3a 🚧 WIP: batched status 2026-07-20 20:37:52 +01:00
dependabot[bot] 72f179578b Bump axios from 1.16.1 to 1.18.0
Bumps [axios](https://github.com/axios/axios) from 1.16.1 to 1.18.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 19:06:46 +00:00
Fred KISSIE 4c873e7c48 Merge branch 'dev' into refactor/batch-status-requests 2026-07-20 18:38:00 +01:00
Fred KISSIE 56fcb80b23 💄 fix labels dropdown flicker if no changes applied 2026-07-20 17:36:45 +01:00
Owen Schwartz 41c68148a9 Merge pull request #3467 from fosrl/dev
1.21.0
2026-07-20 11:46:48 -04:00
Owen Schwartz 5d38059b95 Merge pull request #3466 from fosrl/api-improvements
api improvements
2026-07-20 11:44:25 -04:00
Owen df0198df9c Update internal -> private 2026-07-20 11:44:07 -04:00
Owen 26713b53f6 Fix #3462 2026-07-20 11:44:07 -04:00
Owen 2bc6b28978 Unify put vs post 2026-07-20 11:44:07 -04:00
Owen f1ed4da8a4 Move to legacy 2026-07-20 11:44:07 -04:00
Owen adeefb9dbd Add missing endpoints to api 2026-07-20 11:44:05 -04:00
Owen bf1cc705a5 Update tags 2026-07-20 11:43:10 -04:00
Owen 0b82dae01e More route cleanup 2026-07-20 11:43:09 -04:00
Owen 75afe6ece2 Order more api routes 2026-07-20 11:43:09 -04:00
Owen 70e2fe1e4e Just add ordering 2026-07-20 11:43:06 -04:00
Owen 17e03457e1 Recategorize 2026-07-20 11:42:37 -04:00
Owen 4d8cb7e231 Add aliaes for public/private resources 2026-07-20 11:42:35 -04:00
Owen 9561d23f1e Resovle endcoding issue 2026-07-19 14:41:07 -04:00
Shubham Singh 0275f44056 Fix Punycode domain validation 2026-07-19 13:45:44 +05:30
Owen a2e1c7b751 Go to domains if no domains 2026-07-18 16:30:29 -04:00
Owen 9e2ec72ced Fix missing resource id in cache 2026-07-18 16:15:30 -04:00
Owen 02fe1f3abd Claify that this is only for the cloud 2026-07-18 11:53:56 -04:00
Fred KISSIE 1580b7abff 🚧 wip: batch status histories 2026-07-10 07:02:22 +02:00
Aditya kumar singh bb5547a157 fix: redirect to /auth/initial-setup after hitting auth rate limit (#3408) 2026-07-08 23:33:57 +05:30
502 changed files with 46697 additions and 6326 deletions
+31
View File
@@ -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.
+9 -9
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Monitor storage space
run: |
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Monitor storage space
run: |
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Extract tag name
id: get-tag
@@ -264,7 +264,7 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: 1.25
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
registry: ghcr.io
username: ${{ github.actor }}
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
days-before-stale: 14
days-before-close: 14
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build Docker image pg
run: make dev-build-pg
+4 -4
View File
@@ -1,5 +1,5 @@
# FROM node:24-slim AS base
FROM public.ecr.aws/docker/library/node:24-slim AS base
# FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
WORKDIR /app
@@ -32,8 +32,8 @@ FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24-slim AS runner
FROM public.ecr.aws/docker/library/node:24-slim AS runner
# FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM node:24.18.1-alpine
WORKDIR /app
+101 -1
View File
@@ -1,5 +1,5 @@
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 { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq } from "drizzle-orm";
@@ -132,12 +132,16 @@ export const rotateServerSecret: CommandModule<
const certs = await db.select().from(certificates);
const streamingDestinations = await db.select().from(eventStreamingDestinations);
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 ${licenseKeys.length} license key(s)`);
console.log(`Found ${certs.length} certificate(s)`);
console.log(`Found ${streamingDestinations.length} event streaming destination(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
console.log("\nDecrypting and re-encrypting values...");
@@ -171,11 +175,24 @@ export const rotateServerSecret: CommandModule<
encryptedConfig: string;
};
type AiProviderUpdate = {
providerId: number;
encryptedApiKey: string | null;
encryptedHeaders: string | null;
};
type VirtualApiKeyUpdate = {
virtualApiKeyId: string;
encryptedToken: string;
};
const idpUpdates: IdpUpdate[] = [];
const licenseKeyUpdates: LicenseKeyUpdate[] = [];
const certUpdates: CertUpdate[] = [];
const streamingDestinationUpdates: StreamingDestinationUpdate[] = [];
const webhookActionUpdates: WebhookActionUpdate[] = [];
const aiProviderUpdates: AiProviderUpdate[] = [];
const virtualApiKeyUpdates: VirtualApiKeyUpdate[] = [];
// Process idpOidcConfig entries
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
console.log("\nUpdating database in transaction...");
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)`);
@@ -383,6 +480,8 @@ export const rotateServerSecret: CommandModule<
console.log(`Rotated ${certUpdates.length} certificate(s)`);
console.log(`Rotated ${streamingDestinationUpdates.length} event streaming destination(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
console.log("\nUpdating config file...");
@@ -402,6 +501,7 @@ export const rotateServerSecret: CommandModule<
console.log(` - Certificates: ${certUpdates.length}`);
console.log(` - Event streaming destinations: ${streamingDestinationUpdates.length}`);
console.log(` - Alert webhook actions: ${webhookActionUpdates.length}`);
console.log(` - AI providers: ${aiProviderUpdates.length}`);
console.log(
`\n IMPORTANT: Restart the server for the new secret to take effect.`
);
+285
View File
@@ -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"`
15 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.
+347
View File
@@ -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/`.
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.44.0
golang.org/x/term v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
+4 -4
View File
@@ -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/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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
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/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Потвърдете изтриването на споделима връзка",
"shareQuestionRemove": "Сигурни ли сте, че искате да изтриете тази споделена връзка?",
"shareMessageRemove": "След изтриване връзката вече няма да работи и всеки, който я използва, ще загуби достъп до ресурса.",
"shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.",
"shareTokenDescription": "Токен за достъп може да бъде предаден като параметър на заявка или в хедърите на заявката. По подразбиране трябва да се изпраща при всяка заявка. Ако е активирано задържане на сесията, първата заявка го заменя за сесийна бисквитка.",
"accessToken": "Достъп Токен",
"usageExamples": "Примери за използване",
"tokenId": "Токен ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Заглавие (по избор)",
"sharePathOptional": "Път (по избор)",
"sharePathDescription": "След удостоверяване, линкът ще препрати потребителите на този път.",
"shareAssociateUserOptional": "Асоцииране на потребител (по избор)",
"shareAssociateUserDescription": "Когато е настроен, заявките използващи този линк се свързват с потребителя в достъпните логове и заглавия за идентичност. Линкът се премахва, ако потребителят напусне организацията.",
"userSelect": "Изберете потребител",
"usersNotFound": "Няма намерени потребители",
"expireIn": "Изтече",
"neverExpire": "Никога не изтича",
"sharePersistSession": "Задръж сесия след първо използване",
"sharePersistSessionDescription": "Когато е активирано, първата заявка с този токен чрез параметър на заявка или хедър създава сесийна бисквитка, така че следващите заявки не се нуждаят от токена. Оставете го изключено за клиенти на API, които трябва да изпращат токена при всяка заявка.",
"shareExpireDescription": "Времето на изтичане е колко дълго връзката ще бъде използваема и ще предоставя достъп до ресурса. След това време, връзката няма да работи и потребителите, които са я използвали, ще загубят достъп до ресурса.",
"shareSeeOnce": "Ще можете да видите този линк само веднъж. Уверете се, че го копирате.",
"shareAccessHint": "Всеки с тази връзка може да има достъп до ресурса. Споделяйте я с внимание.",
@@ -928,7 +934,7 @@
"newtVersion": "Версия",
"architecture": "Архитектура",
"sites": "Сайтове",
"siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.",
"siteWgAnyClients": "Използвайте всеки WireGuard клиент, за да се свържете. Ще трябва да адресирате частни ресурси, използвайки IP на връстника.",
"siteWgCompatibleAllClients": "Съвместим с всички WireGuard клиенти",
"siteWgManualConfigurationRequired": "Необходима е ръчна конфигурация",
"userErrorNotAdminOrOwner": "Потребителят не е администратор или собственик",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Поддомейн: {subdomain}",
"domainPickerNamespace": "Име на пространство: {namespace}",
"domainPickerShowMore": "Покажи повече",
"domainPickerNoDomainsAvailableTitle": "Няма налични домейни",
"domainPickerNoDomainsAvailableDescription": "Все още нямате конфигурирани домейни. Създайте домейн, за да продължите.",
"domainPickerNoDomainsAvailableAction": "Отидете на Домейни",
"regionSelectorTitle": "Избор на регион",
"domainPickerRemoteExitNodeWarning": "Предоставените домейни не се поддържат, когато сайтовете се свързват към отдалечени крайни възли. За да бъдат ресурсите налични на отдалечени възли, използвайте персонализиран домейн вместо това.",
"regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Публичен",
"resourcesTableClientResources": "Частен",
"resourcesTableNoProxyResourcesFound": "Не са намерени ресурсни проксита.",
"resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.",
"resourcesTableNoInternalResourcesFound": "Не са намерени частни ресурси.",
"resourcesTableDestination": "Дестинация",
"resourcesTableAlias": "Псевдоним",
"resourcesTableAliasAddress": "Адрес на псевдоним.",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Отмяна",
"editInternalResourceDialogSaveResource": "Запазване на ресурс",
"editInternalResourceDialogSuccess": "Успех",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Вътрешният ресурс успешно актуализиран",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Частният ресурс е успешно обновен",
"editInternalResourceDialogError": "Грешка",
"editInternalResourceDialogFailedToUpdateInternalResource": "Неуспешно актуализиране на вътрешен ресурс",
"editInternalResourceDialogFailedToUpdateInternalResource": "Неуспешен опит за обновяване на частен ресурс",
"editInternalResourceDialogNameRequired": "Името е задължително",
"editInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа",
"editInternalResourceDialogProxyPortMin": "Прокси портът трябва да бъде поне 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Псевдоним",
"editInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.",
"createInternalResourceDialogNoSitesAvailable": "Няма достъпни сайтове",
"createInternalResourceDialogNoSitesAvailableDescription": "Трябва да имате поне един сайт на Newt с конфигурирана мрежа, за да създадете вътрешни ресурси.",
"createInternalResourceDialogNoSitesAvailableDescription": "Трябва да имате поне един Newt сайт с конфигуриран сабнет, за да създадете частни ресурси.",
"createInternalResourceDialogClose": "Затвори",
"createInternalResourceDialogCreateClientResource": "Създаване на частен ресурс",
"createInternalResourceDialogCreateClientResourceDescription": "Създайте нов ресурс, който ще бъде достъпен само за клиенти, свързани към организацията",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Отмяна",
"createInternalResourceDialogCreateResource": "Създаване на ресурс",
"createInternalResourceDialogSuccess": "Успех",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Вътрешният ресурс създаден успешно",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Частният ресурс е създаден успешно",
"createInternalResourceDialogError": "Грешка",
"createInternalResourceDialogFailedToCreateInternalResource": "Неуспешно създаване на вътрешен ресурс",
"createInternalResourceDialogFailedToCreateInternalResource": "Неуспешен опит за създаване на частен ресурс",
"createInternalResourceDialogNameRequired": "Името е задължително",
"createInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа",
"createInternalResourceDialogPleaseSelectSite": "Моля, изберете сайт",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Източен адрес",
"destinationAddress": "Адрес на дестинация",
"duration": "Продължителност",
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> за използване на тази функция. <bookADemoLink>Резервирайте демонстрация или пробен POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> е необходим за използване на тази функция. Тази функция също е налична в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Резервирайте демонстрация или пробен POC</bookADemoLink>.",
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>, за да използвате тази функция. <bookADemoLink>Резервирайте безплатна демонстрация или пробен POC, за да научите повече.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> е необходим за използване на тази функция. Тази функция също е налична в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Резервирайте безплатна демонстрация или пробен POC, за да научите повече.</bookADemoLink>",
"certResolver": "Решавач на сертификати",
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
"selectCertResolver": "Изберете решавач на сертификати",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Potvrdit odstranění sdílného odkazu",
"shareQuestionRemove": "Jste si jisti, že chcete smazat tento odkaz ke sdílení?",
"shareMessageRemove": "Jakmile bude smazán, odkaz přestane fungovat a všichni, kdo jej používají, ztratí přístup k prostředku.",
"shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v hlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.",
"shareTokenDescription": "Přístupový token může být předán jako dotazový parametr nebo v hlavičkách žádostí. Ve výchozím nastavení musí být odesílán s každou žádostí. Pokud je povolena perzistence relace, první žádost jej vymění za relaci cookie.",
"accessToken": "Přístupový token",
"usageExamples": "Příklady použití",
"tokenId": "ID tokenu",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Název (volitelné)",
"sharePathOptional": "Cesta (volitelně)",
"sharePathDescription": "Odkaz přesměruje uživatele na tuto cestu po autentikaci.",
"shareAssociateUserOptional": "Přiřadit uživatele (volitelné)",
"shareAssociateUserDescription": "Pokud je nastaveno, žádosti pomocí tohoto odkazu jsou v přístupových protokolech a hlavičkách identity přidružené k uživateli. Odkaz je odstraněn, pokud uživatel opustí organizaci.",
"userSelect": "Vyberte uživatele",
"usersNotFound": "Nebyl nalezen žádný uživatel",
"expireIn": "Platnost vyprší za",
"neverExpire": "Nikdy nevyprší",
"sharePersistSession": "Udržet relaci po prvním použití",
"sharePersistSessionDescription": "Pokud je povoleno, první žádost s tímto tokenem prostřednictvím dotazu nebo hlavičky nastaví relaci cookie, takže pozdější žádosti nepotřebují token. Nechcete-li, aby API klienti posílali token s každou žádostí, vypněte to.",
"shareExpireDescription": "Doba platnosti určuje, jak dlouho bude odkaz použitelný a bude poskytovat přístup ke zdroji. Po této době odkaz již nebude fungovat a uživatelé kteří tento odkaz používali ztratí přístup ke zdroji.",
"shareSeeOnce": "Tento odkaz uvidíte pouze jednou. Nezapomeňte jej zkopírovat.",
"shareAccessHint": "Kdokoli s tímto odkazem může přistupovat ke zdroji. Sdílejte jej s rozvahou.",
@@ -928,7 +934,7 @@
"newtVersion": "Verze",
"architecture": "Architektura",
"sites": "Stránky",
"siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.",
"siteWgAnyClients": "Pro připojení použijte jakéhokoli klienta WireGuard. Budete muset adresovat privátní zdroje pomocí IP protějšku.",
"siteWgCompatibleAllClients": "Kompatibilní se všemi klienty aplikace WireGuard",
"siteWgManualConfigurationRequired": "Je vyžadována ruční konfigurace",
"userErrorNotAdminOrOwner": "Uživatel není administrátor nebo vlastník",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdoména: {subdomain}",
"domainPickerNamespace": "Jmenný prostor: {namespace}",
"domainPickerShowMore": "Zobrazit více",
"domainPickerNoDomainsAvailableTitle": "Nejsou k dispozici žádné domény",
"domainPickerNoDomainsAvailableDescription": "Zatím nemáte žádné nastavené domény. Vytvořte doménu, abyste mohli pokračovat.",
"domainPickerNoDomainsAvailableAction": "Přejít na domény",
"regionSelectorTitle": "Vybrat region",
"domainPickerRemoteExitNodeWarning": "Poskytnuté domény nejsou podporovány, když se stránky připojují k vzdáleným výstupním uzlům. Pro dostupné zdroje na vzdálených uzlech použijte vlastní doménu.",
"regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Veřejnost",
"resourcesTableClientResources": "Soukromé",
"resourcesTableNoProxyResourcesFound": "Nebyly nalezeny žádné zdroje proxy",
"resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.",
"resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné privátní zdroje.",
"resourcesTableDestination": "Místo určení",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adresa aliasu",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Zrušit",
"editInternalResourceDialogSaveResource": "Uložit dokument",
"editInternalResourceDialogSuccess": "Úspěšně",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interní zdroj byl úspěšně aktualizován",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privátní zdroj byl úspěšně aktualizován",
"editInternalResourceDialogError": "Chyba",
"editInternalResourceDialogFailedToUpdateInternalResource": "Aktualizace interního zdroje se nezdařila",
"editInternalResourceDialogFailedToUpdateInternalResource": "Nepodařilo se aktualizovat privátní zdroj",
"editInternalResourceDialogNameRequired": "Název je povinný",
"editInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků",
"editInternalResourceDialogProxyPortMin": "Port proxy serveru musí být alespoň 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.",
"createInternalResourceDialogNoSitesAvailable": "Nejsou k dispozici žádné weby",
"createInternalResourceDialogNoSitesAvailableDescription": "Musíte mít alespoň jeden Newt web s podsítí nakonfigurovanou pro vytvoření vnitřních zdrojů.",
"createInternalResourceDialogNoSitesAvailableDescription": "K vytvoření privátních zdrojů potřebujete mít alespoň jednu lokalitu Newt s nastavenou podsítí.",
"createInternalResourceDialogClose": "Zavřít",
"createInternalResourceDialogCreateClientResource": "Vytvořit soukromý zdroj",
"createInternalResourceDialogCreateClientResourceDescription": "Vytvořte nový zdroj, který bude přístupný pouze klientům připojeným k organizaci",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Zrušit",
"createInternalResourceDialogCreateResource": "Vytvořit zdroj",
"createInternalResourceDialogSuccess": "Úspěšně",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interní zdroj byl úspěšně vytvořen",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privátní zdroj byl úspěšně vytvořen",
"createInternalResourceDialogError": "Chyba",
"createInternalResourceDialogFailedToCreateInternalResource": "Nepodařilo se vytvořit interní zdroj",
"createInternalResourceDialogFailedToCreateInternalResource": "Nepodařilo se vytvořit privátní zdroj",
"createInternalResourceDialogNameRequired": "Název je povinný",
"createInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků",
"createInternalResourceDialogPleaseSelectSite": "Vyberte prosím web",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Zdrojová adresa",
"destinationAddress": "Cílová adresa",
"duration": "Doba trvání",
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> nebo <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Zarezervujte si demo nebo POC zkušební verzi</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Rezervujte si demo nebo POC zkušební verzi</bookADemoLink>.",
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> nebo <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Zarezervujte si bezplatné demo nebo POC zkušební verzi, abyste se dozvěděli více.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Rezervujte si demo nebo POC zkušební verzi zdarma, abyste se dozvěděli více.</bookADemoLink>",
"certResolver": "Oddělovač certifikátů",
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
"selectCertResolver": "Vyberte řešič certifikátů",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Bekræft sletning af delbar link",
"shareQuestionRemove": "Er du sikker på, at du vil slette dette delingslink?",
"shareMessageRemove": "Når linket er slettet, vil det ikke længere fungere, og alle, der bruger det, mister adgang til ressourcen.",
"shareTokenDescription": "Adgangstoken kan sendes på to måter: som en queryparameter eller i request-headers. Disse skal sendes fra klienten på hver forespørgsel om autentificeret adgang.",
"shareTokenDescription": "Adgangstokenet kan sendes som en forespørgselsparameter eller i anmodningsoverskrifter. Som standard skal det sendes ved hver anmodning. Hvis sessionsvedholdelse er aktiveret, byttes den første anmodning for en sessionscookie.",
"accessToken": "Adgangstoken",
"usageExamples": "Brukseksempler",
"tokenId": "Token-ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Titel (valgfrit)",
"sharePathOptional": "Sti (valgfrit)",
"sharePathDescription": "Linket vil videresende brugere til denne stien efter autentificering.",
"shareAssociateUserOptional": "Tilknyt brugeren (valgfrit)",
"shareAssociateUserDescription": "Når det er indstillet, tilskrives anmodninger ved hjælp af dette link brugeren i adgangslogfiler og identitetsoverskrifter. Linket fjernes, hvis brugeren forlader organisationen.",
"userSelect": "Vælg bruger",
"usersNotFound": "Ingen brugere fundet",
"expireIn": "Udløber om",
"neverExpire": "Udløber aldrig",
"sharePersistSession": "Bevar session efter første brug",
"sharePersistSessionDescription": "Når det er aktiveret, sætter den første anmodning med dette token via en forespørgselsparameter eller overskrift en sessionscookie, så senere anmodninger ikke behøver tokenet. Lad være med det til API-klienter, der ønsker at sende tokenet ved hver anmodning.",
"shareExpireDescription": "Udløbstid er hvor længe linket vil være brukbar og give adgang til ressourcen. Efter denne tiden vil linket ikke længere fungere, og brugere som brugte denne linket vil miste adgangen til ressourcen.",
"shareSeeOnce": "Du vil kun kunne se dette link én gang. Sørg for at kopiere det.",
"shareAccessHint": "Alle med denne linket kan få adgang til ressourcen. Del forsiktig.",
@@ -928,7 +934,7 @@
"newtVersion": "Version",
"architecture": "Arkitektur",
"sites": "Websteder",
"siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.",
"siteWgAnyClients": "Brug enhver WireGuard-klient til at oprette forbindelse. Du skal adressere private ressourcer ved hjælp af peer IP.",
"siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter",
"siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet",
"userErrorNotAdminOrOwner": "Bruger er ikke administrator eller ejer",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Underdomæne: {subdomain}",
"domainPickerNamespace": "Navnerom: {namespace}",
"domainPickerShowMore": "Vis mere",
"domainPickerNoDomainsAvailableTitle": "Ingen domæner tilgængelige",
"domainPickerNoDomainsAvailableDescription": "Du har endnu ikke oprettet nogen domæner. Opret et domæne for at fortsætte.",
"domainPickerNoDomainsAvailableAction": "Gå til domæner",
"regionSelectorTitle": "Vælg Region",
"domainPickerRemoteExitNodeWarning": "Leverede domæner understøttes ikke, når sites kobles til eksterne exitnoder. For ressourcer, der skal være tilgængelige på eksterne noder, skal du i stedet bruge et brugerdefineret domæne.",
"regionSelectorInfo": "At vælge en region hjelper oss med at give bedre ydeevne for din placering. Du behøver ikke være i samme region som serveren.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Offentlig",
"resourcesTableClientResources": "Privat",
"resourcesTableNoProxyResourcesFound": "Ingen proxy-ressourcer fundet.",
"resourcesTableNoInternalResourcesFound": "Ingen interne ressourcer fundet.",
"resourcesTableNoInternalResourcesFound": "Ingen private ressourcer fundet.",
"resourcesTableDestination": "Destination",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias adresse",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Annuller",
"editInternalResourceDialogSaveResource": "Gem ressource",
"editInternalResourceDialogSuccess": "Succes",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressource opdateret med succes",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privat ressource opdateret succesfuldt",
"editInternalResourceDialogError": "Fejl",
"editInternalResourceDialogFailedToUpdateInternalResource": "Mislykkedes at opdatere intern ressource",
"editInternalResourceDialogFailedToUpdateInternalResource": "Mislykkedes at opdatere privat ressource",
"editInternalResourceDialogNameRequired": "Navn er påkrævet",
"editInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn",
"editInternalResourceDialogProxyPortMin": "Proxy-port skal være mindst 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.",
"createInternalResourceDialogNoSitesAvailable": "Ingen tilgængelige steder",
"createInternalResourceDialogNoSitesAvailableDescription": "Du skal have mindst ét Newt-site med et konfigureret subnet for at oprette interne ressourcer.",
"createInternalResourceDialogNoSitesAvailableDescription": "Du skal have mindst ét Newt-site med et konfigureret subnet for at oprette private ressourcer.",
"createInternalResourceDialogClose": "Luk",
"createInternalResourceDialogCreateClientResource": "Opret privat ressource",
"createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Annuller",
"createInternalResourceDialogCreateResource": "Opret ressource",
"createInternalResourceDialogSuccess": "Succes",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressource oprettet med succes",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privat ressource oprettet succesfuldt",
"createInternalResourceDialogError": "Fejl",
"createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke oprette intern ressource",
"createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke oprette privat ressource",
"createInternalResourceDialogNameRequired": "Navn er påkrævet",
"createInternalResourceDialogNameMaxLength": "Navn kan ikke være længere enn 255 tegn",
"createInternalResourceDialogPleaseSelectSite": "Vælg venligst et site",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Kildeadresse",
"destinationAddress": "Måladresse ",
"duration": "Varighed",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>-licens eller <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> er påkrævet for at bruge denne funktion. <bookADemoLink>Book en demo eller POC-prøveversion</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for at bruge denne funktion. Denne funktion er også tilgængelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book en demo eller POC-prøve</bookADemoLink>.",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>-licens eller <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> er påkrævet for at bruge denne funktion. <bookADemoLink>Book en gratis demo eller POC-prøveversion for at lære mere.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for at bruge denne funktion. Denne funktion er også tilgængelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book en gratis demo eller POC-prøve for at lære mere.</bookADemoLink>",
"certResolver": "Certifikatløser",
"certResolverDescription": "Vælg certifikatløser som skal bruges for denne ressource.",
"selectCertResolver": "Vælg certifikatløser",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Löschung des Freigabelinks bestätigen",
"shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
"shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.",
"shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.",
"shareTokenDescription": "Der Zugriffstoken kann als Abfrageparameter oder in Anforderungsheadern übergeben werden. Standardmäßig muss er bei jeder Anforderung gesendet werden. Wenn die Sitzungsbeständigkeit aktiviert ist, wird die erste Anfrage gegen ein Sitzungscookie ausgetauscht.",
"accessToken": "Zugriffstoken",
"usageExamples": "Nutzungsbeispiele",
"tokenId": "Token-ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Titel (optional)",
"sharePathOptional": "Pfad (optional)",
"sharePathDescription": "Der Link leitet Benutzer nach der Authentifizierung zu diesem Pfad weiter.",
"shareAssociateUserOptional": "Benutzer zuweisen (optional)",
"shareAssociateUserDescription": "Wenn gesetzt, werden Anfragen mit diesem Link in Zugriffsprotokollen und Identitätsheadern dem Benutzer zugeordnet. Der Link wird entfernt, wenn der Benutzer die Organisation verlässt.",
"userSelect": "Benutzer auswählen",
"usersNotFound": "Keine Benutzer gefunden",
"expireIn": "Läuft ab in",
"neverExpire": "Läuft nie ab",
"sharePersistSession": "Sitzung nach erster Nutzung beibehalten",
"sharePersistSessionDescription": "Wenn aktiviert, setzt die erste Anfrage mit diesem Token über einen Abfrageparameter oder einen Header ein Sitzungscookie, sodass spätere Anfragen das Token nicht benötigen. Deaktivieren Sie dies für API-Clients, die das Token bei jeder Anfrage senden sollen.",
"shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.",
"shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.",
"shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.",
@@ -928,7 +934,7 @@
"newtVersion": "Version",
"architecture": "Architektur",
"sites": "Standorte",
"siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.",
"siteWgAnyClients": "Verwenden Sie jeden beliebigen WireGuard-Client, um sich zu verbinden. Sie müssen die privaten Ressourcen mit der Peer-IP adressieren.",
"siteWgCompatibleAllClients": "Kompatibel mit allen WireGuard-Clients",
"siteWgManualConfigurationRequired": "Manuelle Konfiguration erforderlich",
"userErrorNotAdminOrOwner": "Benutzer ist kein Administrator oder Eigentümer",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdomain: {subdomain}",
"domainPickerNamespace": "Namespace: {namespace}",
"domainPickerShowMore": "Mehr anzeigen",
"domainPickerNoDomainsAvailableTitle": "Keine Domains verfügbar",
"domainPickerNoDomainsAvailableDescription": "Sie haben noch keine Domains eingerichtet. Erstellen Sie eine Domain, um fortzufahren.",
"domainPickerNoDomainsAvailableAction": "Zu Domains wechseln",
"regionSelectorTitle": "Region auswählen",
"domainPickerRemoteExitNodeWarning": "Angegebene Domains werden nicht unterstützt, wenn sich Websites mit externen Exit-Knoten verbinden. Damit Ressourcen auf entfernten Knoten verfügbar sind, verwenden Sie stattdessen eine eigene Domain.",
"regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Öffentlich",
"resourcesTableClientResources": "Privat",
"resourcesTableNoProxyResourcesFound": "Keine Proxy-Ressourcen gefunden.",
"resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.",
"resourcesTableNoInternalResourcesFound": "Keine privaten Ressourcen gefunden.",
"resourcesTableDestination": "Ziel",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias-Adresse",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Abbrechen",
"editInternalResourceDialogSaveResource": "Ressource speichern",
"editInternalResourceDialogSuccess": "Erfolg",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne Ressource erfolgreich aktualisiert",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private Ressource erfolgreich aktualisiert",
"editInternalResourceDialogError": "Fehler",
"editInternalResourceDialogFailedToUpdateInternalResource": "Interne Ressource konnte nicht aktualisiert werden",
"editInternalResourceDialogFailedToUpdateInternalResource": "Fehler beim Aktualisieren der privaten Ressource",
"editInternalResourceDialogNameRequired": "Name ist erforderlich",
"editInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein",
"editInternalResourceDialogProxyPortMin": "Proxy-Port muss mindestens 1 sein",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.",
"createInternalResourceDialogNoSitesAvailable": "Kein Standort verfügbar",
"createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens ein Newt-Standort mit einem konfigurierten Subnetz haben, um interne Ressourcen zu erstellen.",
"createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens eine Newt-Site mit einem konfigurierten Subnetz haben, um private Ressourcen zu erstellen.",
"createInternalResourceDialogClose": "Schließen",
"createInternalResourceDialogCreateClientResource": "Private Ressource erstellen",
"createInternalResourceDialogCreateClientResourceDescription": "Erstelle eine neue Ressource, die nur für Clients zugänglich ist, die mit der Organisation verbunden sind",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Abbrechen",
"createInternalResourceDialogCreateResource": "Ressource erstellen",
"createInternalResourceDialogSuccess": "Erfolg",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne Ressource erfolgreich erstellt",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private Ressource erfolgreich erstellt",
"createInternalResourceDialogError": "Fehler",
"createInternalResourceDialogFailedToCreateInternalResource": "Interne Ressource konnte nicht erstellt werden",
"createInternalResourceDialogFailedToCreateInternalResource": "Fehler beim Erstellen der privaten Ressource",
"createInternalResourceDialogNameRequired": "Name ist erforderlich",
"createInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein",
"createInternalResourceDialogPleaseSelectSite": "Bitte wählen Sie einen Standort aus",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Quelladresse",
"destinationAddress": "Zieladresse",
"duration": "Dauer",
"licenseRequiredToUse": "Eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz oder <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> wird benötigt, um diese Funktion nutzen zu können. <bookADemoLink>Buchen Sie eine Demo oder POC Testversion</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> wird benötigt, um diese Funktion nutzen zu können. Diese Funktion ist auch in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>verfügbar. <bookADemoLink>Buchen Sie eine Demo oder POC Testversion</bookADemoLink>.",
"licenseRequiredToUse": "Eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz oder <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> wird benötigt, um diese Funktion zu nutzen. <bookADemoLink>Buchen Sie eine kostenlose Demo oder POC Testversion, um mehr zu erfahren.</bookADemoLink>",
"ossEnterpriseEditionRequired": "Die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> ist notwendig, um diese Funktion zu nutzen. Diese Funktion ist auch in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar. <bookADemoLink>Buchen Sie eine kostenlose Demo oder POC Testversion, um mehr zu erfahren.</bookADemoLink>",
"certResolver": "Zertifikatsauflöser",
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
"selectCertResolver": "Zertifikatsauflöser auswählen",
+484 -20
View File
@@ -43,6 +43,8 @@
"inviteLoginUser": "Please make sure you're logged in as the correct user.",
"inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.",
"inviteCreateUser": "Please create an account first.",
"inviteErrorOidcNotAllowed": "Invites can only be accepted by internal accounts. Sign out and log in with your password for this email.",
"inviteLoginInternalOnly": "Invites require an internal account with a password. Create an account or sign in with your password.",
"goHome": "Go Home",
"inviteLogInOtherUser": "Log In as a Different User",
"createAnAccount": "Create an Account",
@@ -287,6 +289,20 @@
"siteSelectionDescription": "This site will provide connectivity to the target.",
"resourceType": "Resource Type",
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This cant be changed later.",
"resourceTypeSearch": "Search resource types...",
"resourceTypeNotFound": "No resource type found",
"resourceTypeHttpDescription": "Proxy web traffic over HTTPS using a domain name",
"resourceTypeInferenceDescription": "Route AI requests through attached providers",
"resourceTypeSshDescription": "Access an SSH server from the browser",
"resourceTypeRdpDescription": "Access a remote desktop from the browser",
"resourceTypeVncDescription": "Access a VNC desktop from the browser",
"resourceTypeTcpDescription": "Proxy raw TCP traffic using a port number",
"resourceTypeUdpDescription": "Proxy raw UDP traffic using a port number",
"privateResourceTypeDescription": "This controls how clients reach the resource. This cant be changed later.",
"privateResourceTypeHostDescription": "Expose a single host on the site network to connected clients",
"privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients",
"privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain",
"privateResourceTypeSshDescription": "Access an SSH server from connected clients",
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
"resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
@@ -855,12 +871,16 @@
"authMethodsSave": "Save Settings",
"policyAuthStackTitle": "Authentication",
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
"policyAuthInferenceStackDescription": "Choose which users and roles can authenticate to this AI gateway",
"policyAuthOrLogicTitle": "Multiple authentication methods active",
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
"policyAuthMethodActive": "Active",
"policyAuthMethodOff": "Off",
"policyAuthSsoTitle": "Platform SSO",
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
"policyAuthInferenceIdentityKeySignInUrl": "Sign-in URL",
"policyAuthInferenceIdentityKeyHelpNoUrl": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users can retrieve their key by signing in with their identity provider at this resource's URL, where it will be shown after login.",
"policyAuthInferenceIdentityKeyHelp": "Every user already has an identity API key, so you only need to create virtual API keys for non-user clients or shared access. Users retrieve their key here after signing in with their identity provider.",
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
"policyAuthSsoDefaultIdp": "Default provider",
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
@@ -934,7 +954,7 @@
"newtVersion": "Version",
"architecture": "Architecture",
"sites": "Sites",
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.",
"siteWgAnyClients": "Use any WireGuard client to connect. You will have to address private resources using the peer IP.",
"siteWgCompatibleAllClients": "Compatible with all WireGuard clients",
"siteWgManualConfigurationRequired": "Manual configuration required",
"userErrorNotAdminOrOwner": "User is not an admin or owner",
@@ -1079,6 +1099,8 @@
"accessRoleErrorNewRequired": "New role is required",
"accessRoleErrorRemove": "Failed to remove role",
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
"accessRoleInferenceBudget": "AI Budget",
"accessRoleInferenceBudgetDescription": "Configure how members of this role restrict AI usage based on spending or token limits",
"accessRoleName": "Role Name",
"accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
"accessRoleRemove": "Remove Role",
@@ -1424,6 +1446,28 @@
"actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site",
"actionListSites": "List Sites",
"actionCreateAiProvider": "Create AI Provider",
"actionDeleteAiProvider": "Delete AI Provider",
"actionGetAiProvider": "Get AI Provider",
"actionListAiProviders": "List AI Providers",
"actionUpdateAiProvider": "Update AI Provider",
"actionCreateAiModel": "Create AI Model",
"actionDeleteAiModel": "Delete AI Model",
"actionGetAiModel": "Get AI Model",
"actionListAiModels": "List AI Models",
"actionUpdateAiModel": "Update AI Model",
"actionCreateAiBudget": "Create AI Budget",
"actionDeleteAiBudget": "Delete AI Budget",
"actionGetAiBudget": "Get AI Budget",
"actionListAiBudgets": "List AI Budgets",
"actionUpdateAiBudget": "Update AI Budget",
"actionListResourceAiModels": "List Resource AI Models",
"actionSetResourceAiModels": "Set Resource AI Models",
"actionCreateVirtualApiKey": "Create Virtual API Key",
"actionDeleteVirtualApiKey": "Delete Virtual API Key",
"actionGetVirtualApiKey": "Get Virtual API Key",
"actionListVirtualApiKeys": "List Virtual API Keys",
"actionUpdateVirtualApiKey": "Update Virtual API Key",
"actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint",
@@ -1449,8 +1493,11 @@
"actionSetResourcePincode": "Set Resource Pincode",
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
"actionListResourcePolicies": "List Resource Policies",
"actionCreateResourcePolicy": "Create Resource Policy",
"actionGetResourcePolicy": "Get Resource Policy",
"actionUpdateResourcePolicy": "Update Resource Policy",
"actionDeleteResourcePolicy": "Delete Resource Policy",
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
@@ -1622,6 +1669,369 @@
"sidebarInvitations": "Invitations",
"sidebarRoles": "Roles",
"sidebarShareableLinks": "Shareable Links",
"sidebarAiGateway": "AI Gateway",
"sidebarAiProviders": "Providers",
"commandAiProviders": "AI Providers",
"sidebarVirtualApiKeys": "Virtual API Keys",
"sidebarMyApiKeys": "Your API Keys",
"sidebarAccount": "Launcher",
"commandVirtualApiKeys": "Virtual API Keys",
"virtualApiKeysTitle": "Manage Virtual API Keys",
"virtualApiKeysDescription": "Create and manage manual API keys for AI Gateway access to public AI gateways",
"virtualApiKeysBannerTitle": "Identity Keys for Every User",
"virtualApiKeysBannerDescription": "Every user already has an identity key available at {keysUrl}. You can also manually generate keys here that grant direct access to public AI gateways.",
"virtualApiKeysBannerButtonText": "View Identity Keys",
"virtualApiKeys": "Virtual API Keys",
"virtualApiKeysSearch": "Search keys...",
"virtualApiKeysCreate": "Create Virtual API Key",
"virtualApiKeysCreateDescription": "Mint a manual key that can call public AI gateways in this organization",
"virtualApiKeysCreateButton": "Create Key",
"virtualApiKeysEmpty": "No virtual API keys yet",
"virtualApiKeysName": "Name",
"virtualApiKeysDescriptionOptional": "Description (optional)",
"virtualApiKeysAssociateUserOptional": "Associate User (optional)",
"virtualApiKeysAssociateUserDescription": "Associate this key with a user to track usage. Once created, this key immediately grants access to the selected public AI gateways. You do not need to associate a user to manually to the resource. The key will also show up in the user's profile.",
"virtualApiKeysAllResources": "All public AI gateways",
"virtualApiKeysAllResourcesDescription": "Allow this key to access every public AI gateway in the organization",
"virtualApiKeysSelectResources": "Public AI Gateways",
"virtualApiKeysSelectResourcesPlaceholder": "Select resources",
"virtualApiKeysSelectResourcesDescription": "Choose which public AI gateways this key can access",
"virtualApiKeysNoResources": "No resources",
"virtualApiKeysSecret": "Key",
"virtualApiKeysCopyKey": "Copy this key. You can view it again later from the table or when editing.",
"virtualApiKeysViewSecret": "View Secret",
"virtualApiKeysViewSecretTitle": "Virtual API Key Secret",
"virtualApiKeysViewSecretDescription": "This secret grants access to the public AI gateways assigned to this key",
"virtualApiKeysEdit": "Edit Virtual API Key",
"virtualApiKeysEditDescription": "Update the associated user and public AI gateway access for this key",
"virtualApiKeysSaveButton": "Save Changes",
"virtualApiKeysSelectResourcesRequired": "Select at least one public AI gateway, or enable all public AI gateways",
"virtualApiKeysUpdated": "Virtual API key updated",
"virtualApiKeysUpdatedDescription": "The virtual API key has been updated",
"virtualApiKeysErrorUpdate": "Error updating virtual API key",
"virtualApiKeysErrorUpdateDescription": "Failed to update virtual API key",
"virtualApiKeysErrorCreate": "Error creating virtual API key",
"virtualApiKeysErrorCreateDescription": "Failed to create virtual API key",
"virtualApiKeysErrorDelete": "Error deleting virtual API key",
"virtualApiKeysErrorDeleteMessage": "Failed to delete virtual API key",
"virtualApiKeysDeleted": "Virtual API key deleted",
"virtualApiKeysDeletedDescription": "The virtual API key has been deleted",
"virtualApiKeysDelete": "Delete Virtual API Key",
"virtualApiKeysDeleteConfirm": "Delete Key",
"virtualApiKeysQuestionRemove": "Are you sure you want to delete this virtual API key?",
"virtualApiKeysMessageRemove": "Clients using this key will lose access immediately.",
"virtualApiKeysErrorFetchSecret": "Error loading secret",
"virtualApiKeysErrorFetchSecretDescription": "Failed to load the virtual API key secret",
"virtualApiKeysFilterUnassigned": "Unassigned",
"virtualApiKeysInferenceBudget": "Budget",
"virtualApiKeysInferenceBudgetDescription": "Configure how this key restricts AI usage based on spending or token limits",
"myVirtualApiKeysTitle": "Your API Keys",
"myVirtualApiKeysDescription": "View your identity key and any virtual API keys attributed to you in this organization",
"myVirtualApiKeysResourceTitle": "Your API Keys for {resourceName}",
"myVirtualApiKeysResourceDescription": "View your identity key and virtual API keys attributed to you that can access {resourceName}",
"myVirtualApiKeysIdentityTitle": "Identity Key",
"myVirtualApiKeysIdentityHeadline": "Your Personal API Key",
"myVirtualApiKeysIdentityDescription": "Your personal key for this organization. It is unique to your account and used to identify you when calling AI Gateway resources.",
"myVirtualApiKeysIdentityResourceHeadline": "Your Personal API Key for {resourceName}",
"myVirtualApiKeysIdentityResourceDescription": "Your personal key for this organization. Use it to call {resourceName}.",
"myVirtualApiKeysManualTitle": "Attributed Keys",
"myVirtualApiKeysManualDescription": "Manual virtual API keys an admin associated with your account",
"myVirtualApiKeysManualResourceDescription": "Manual virtual API keys associated with your account that can access {resourceName}",
"myVirtualApiKeysManualEmpty": "No attributed keys yet",
"myVirtualApiKeysKindUser": "Identity",
"myVirtualApiKeysKindManual": "Manual",
"myVirtualApiKeysUnnamed": "Unnamed key",
"myVirtualApiKeysRevealSecret": "Reveal Secret",
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources",
"aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
"aiProvidersBannerTitle": "Connect Model Providers",
"aiProvidersBannerDescription": "Providers are the model backends Pangolin uses for AI workloads. Connect OpenAI, Anthropic, and other providers here, then attach them to public AI gateways so users can call models with identity-aware access, budgets, and logging.",
"aiProvidersAdd": "Add Provider",
"aiProvidersSearch": "Search providers...",
"aiProvidersEmpty": "No AI providers yet",
"aiProviderCreate": "Create AI Provider",
"aiProviderCreateDescription": "Add a model provider for this organization",
"aiProviderSeeAll": "See All Providers",
"aiProviderSetting": "Provider Settings for {providerName}",
"aiProviderSettingDescription": "Configure this AI provider",
"aiProviderGeneral": "General",
"aiProviderGeneralDescription": "Basic settings for this provider",
"aiProviderConfiguration": "Configuration",
"aiProviderConfigurationDescription": "Network routing and authentication for this provider",
"aiProviderNetworkSettings": "Network Settings",
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
"aiProviderAuthSettings": "Authentication",
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
"aiProviderBudgetSettings": "Budget",
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
"aiBudgetAdd": "Add Budget",
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
"aiBudgetUnit": "Spend Type",
"aiBudgetPeriod": "Reset Period",
"aiBudgetAmount": "Maximum Spend",
"aiBudgetAmountPlaceholder": "Maximum spend",
"aiBudgetPeriodHourly": "Hourly",
"aiBudgetPeriodDaily": "Daily",
"aiBudgetPeriodWeekly": "Weekly",
"aiBudgetPeriodMonthly": "Monthly",
"aiBudgetPeriodYearly": "Yearly",
"aiBudgetPeriodLifetime": "Lifetime",
"aiBudgetUnitUsd": "USD",
"aiBudgetUnitTokens": "Tokens",
"aiBudgetConflictError": "A budget for this reset period and spend type already exists",
"aiBudgetInvalidAmountError": "Enter a maximum spend greater than 0",
"aiBudgetUpdated": "Budgets updated",
"aiBudgetErrorSave": "Failed to update budgets",
"aiProviderType": "Provider Type",
"aiProviderTypeSearch": "Search providers...",
"aiProviderTypeNotFound": "No provider type found",
"aiProviderTypeOpenai": "OpenAI",
"aiProviderTypeAnthropic": "Anthropic",
"aiProviderTypeGoogleGemini": "Google Gemini",
"aiProviderTypeVertexAi": "Vertex AI",
"aiProviderTypeBedrock": "Amazon Bedrock",
"aiProviderTypeMicrosoftFoundry": "Microsoft Foundry",
"aiProviderTypeOpenRouter": "OpenRouter",
"aiProviderTypeVercelAiGateway": "Vercel AI Gateway",
"aiProviderTypeCustom": "Custom",
"aiProviderTypeOpenaiDescription": "OpenAI API",
"aiProviderTypeAnthropicDescription": "Anthropic API",
"aiProviderTypeGoogleGeminiDescription": "Google Gemini API",
"aiProviderTypeVertexAiDescription": "Google Vertex AI API",
"aiProviderTypeBedrockDescription": "Amazon Bedrock Runtime API",
"aiProviderTypeMicrosoftFoundryDescription": "Microsoft Foundry API",
"aiProviderTypeOpenRouterDescription": "OpenRouter API",
"aiProviderTypeVercelAiGatewayDescription": "Vercel AI Gateway API",
"aiProviderTypeCustomDescription": "Bring your own endpoint or route via site targets",
"aiProviderUpstreamUrl": "Upstream URL",
"aiProviderUpstreamUrlDescription": "Base URL for the provider API",
"aiProviderUpstreamUrlOptionalDescription": "Leave blank to use the default upstream URL for this provider",
"aiProviderEffectiveUpstreamUrl": "Effective Upstream URL",
"aiProviderApiKey": "API Key",
"aiProviderApiKeyDescription": "API key used to authenticate requests to this provider",
"aiProviderCustomHeadersDescription": "Headers sent on every request to this provider. Newline separated: Header-Name: value",
"aiProviderApiKeyLastChars": "API Key",
"aiProviderAuthType": "Auth Type",
"aiProviderAuthTypeSearch": "Search auth types...",
"aiProviderAuthTypeNotFound": "No auth type found",
"aiProviderAuthTypeBearer": "Bearer",
"aiProviderAuthTypeBearerDescription": "Authorization: Bearer key. Used by OpenAI and most providers",
"aiProviderAuthTypeXApiKey": "x-api-key",
"aiProviderAuthTypeXApiKeyDescription": "x-api-key header. Used by Anthropic",
"aiProviderAuthTypeXGoogApiKey": "x-goog-api-key",
"aiProviderAuthTypeXGoogApiKeyDescription": "x-goog-api-key header. Used by Google Gemini",
"aiProviderAuthTypeHec": "Splunk HEC",
"aiProviderAuthTypeHecDescription": "Authorization: Splunk key. Used by Splunk HTTP Event Collector",
"aiProviderAuthTypeCfAigAuthorization": "Cloudflare AI Gateway",
"aiProviderAuthTypeCfAigAuthorizationDescription": "cf-aig-authorization: Bearer key. Used by Cloudflare AI Gateway",
"aiProviderAuthTypeNone": "No Auth",
"aiProviderAuthTypePassthrough": "Passthrough",
"aiProviderAuthTypeDescription": "How the upstream API authenticates requests",
"aiProviderAuthTypePassthroughDescription": "Forward the caller's API key headers to the upstream",
"aiProviderAuthTypeNoneDescription": "Do not send authentication headers to the upstream",
"aiProviderRoutingMode": "Routing Mode",
"aiProviderRoutingModeDescription": "Send traffic to an upstream URL or to HTTP targets on your sites",
"aiProviderRoutingModeUrl": "Upstream URL",
"aiProviderRoutingModeUrlDescription": "Call a public or private API base URL",
"aiProviderRoutingModeTarget": "Site Targets",
"aiProviderRoutingModeTargetDescription": "Route through targets on your sites",
"aiProviderRoutingModeTargetNote": "After creating this provider, configure site targets on the Network Settings tab.",
"aiProviderTargetNoOne": "This provider doesn't have any targets. Add a target to route requests through your sites.",
"aiProviderSkipTlsVerification": "Skip TLS Verification",
"aiProviderSkipTlsVerificationDescription": "Disable TLS certificate verification for the upstream connection",
"aiProviderBudget": "Budget",
"aiProviderBudgetDescription": "Optional spending or token budget for this provider",
"aiProviderBudgetAmount": "Budget Amount",
"aiProviderBudgetUnit": "Budget Unit",
"aiProviderBudgetUnitUsd": "USD",
"aiProviderBudgetUnitTokens": "Tokens",
"aiProviderEnabled": "Enabled",
"aiProviderEnabledDescription": "Fully disable this provider across all resources",
"aiProviderErrorCreate": "Failed to create AI provider",
"aiProviderErrorUpdate": "Failed to update AI provider",
"aiProviderErrorDelete": "Failed to delete AI provider",
"aiProviderErrorLoad": "Failed to load AI provider",
"aiProviderErrorUpstreamUrlInvalid": "Enter a valid upstream URL",
"aiProviderErrorUpstreamUrlRequired": "Upstream URL is required for this provider",
"aiProviderErrorAuthTypeRequired": "Auth type is required",
"aiProviderErrorApiKeyRequired": "API key is required",
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
"aiProviderCapabilities": "API Capabilities",
"aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.",
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
"aiProviderCapabilitiesSelect": "Select capabilities",
"aiProviderCapabilitiesEmpty": "No capabilities found",
"aiProviderCapabilitiesSearch": "Search capabilities...",
"aiCapabilityOpenaiChat": "OpenAI Chat Completions",
"aiCapabilityOpenaiChatDescription": "Supports /v1/chat/completions",
"aiCapabilityOpenaiResponses": "OpenAI Responses",
"aiCapabilityOpenaiResponsesDescription": "Supports /v1/responses",
"aiCapabilityAnthropicMessages": "Anthropic Messages",
"aiCapabilityAnthropicMessagesDescription": "Supports /v1/messages",
"aiCapabilityGeminiGenerateContent": "Gemini Generate Content",
"aiCapabilityGeminiGenerateContentDescription": "Supports the direct Gemini API",
"aiCapabilityBedrockModelInvoke": "Bedrock Model Invoke",
"aiCapabilityBedrockModelInvokeDescription": "Supports Amazon Bedrock InvokeModel",
"aiCapabilityGoogleGenerateContent": "Vertex Generate Content",
"aiCapabilityGoogleGenerateContentDescription": "Supports Vertex AI Gemini format",
"aiCapabilityGoogleRawPredict": "Vertex Raw Predict",
"aiCapabilityGoogleRawPredictDescription": "Supports Vertex AI rawPredict for Anthropic models",
"aiCapabilityBedrockConverse": "Bedrock Converse",
"aiCapabilityBedrockConverseDescription": "Supports Amazon Bedrock Converse API",
"aiProviderCreated": "AI provider created",
"aiProviderUpdated": "AI provider updated",
"aiProviderDeleted": "AI provider deleted",
"aiProviderDelete": "Delete Provider",
"aiProviderDeleteConfirm": "Delete Provider",
"aiProviderQuestionRemove": "Are you sure you want to delete this AI provider?",
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
"aiProviderErrorNoUpdate": "AI provider is not available to update",
"aiProviderModels": "Models",
"aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.",
"aiProviderCreateModelsDescription": "Choose which models this provider can serve. An empty allow list denies all traffic. You can add or change models later.",
"aiProviderModelsPlaceholder": "Search models or type a custom key",
"aiProviderModelsAllow": "Allow List",
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
"aiProviderModelsAllowPlaceholder": "Enter model key",
"aiProviderModelsAllowEmpty": "No models added yet.",
"aiProviderModelsBlock": "Block List",
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.",
"aiProviderModelsBlockPlaceholder": "Enter model key",
"aiProviderModelsBlockEmpty": "No models added yet.",
"aiProviderModelsAdd": "Add Models",
"aiProviderModelsClearAll": "Clear All",
"aiProviderModelsAddCustom": "Add \"{key}\"",
"aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.",
"aiProviderModelsAddBulk": "Add {count} custom keys",
"aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.",
"aiProviderModelsAddOne": "Add {key} now",
"aiProviderModelsAddSelected": "Add Selected",
"aiProviderModelsSelectedCount": "{count} selected",
"aiProviderModelsSelectAll": "Select all",
"aiProviderModelsClearSelected": "Clear",
"aiProviderModelsBulkHint": "Select known models or type a custom key.",
"aiProviderModelsCatalogEmpty": "No matching catalog models.",
"aiProviderModelsCatalogHeading": "Known Models",
"aiProviderModelsAllLabel": "All models",
"aiProviderModelsAllPatternHint": "Wildcard: *",
"aiProviderModelsAddAllAllow": "Allow all models",
"aiProviderModelsAddAllBlock": "Block all models",
"aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.",
"aiProviderModelsViewMore": "View more ({count})",
"aiProviderModelsViewLess": "View less",
"aiProviderModelsRemove": "Remove model",
"aiProviderModelsEditHint": "Click to edit model settings",
"aiProviderModelsSourceCatalog": "Known catalog model",
"aiProviderModelsSourceCustom": "Custom model key",
"aiProviderModelsSourcePattern": "Wildcard pattern",
"aiProviderModelsSourceAll": "Matches every model key",
"aiProviderModelsBudgetConfigured": "Budget configured",
"aiProviderModelsEditTitle": "Edit Model",
"aiProviderModelsEditDescription": "Update the model key or configure its budget.",
"aiProviderModelsBudgetTab": "Budget",
"aiProviderModelsKeyLabel": "Model Key",
"aiProviderModelsKeyRequired": "Enter a model key",
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
"aiProviderModelsUpdated": "Models updated",
"aiProviderModelsErrorUpdate": "Failed to update models",
"aiResourceProviders": "Providers",
"aiResourceProvidersDescription": "Choose which AI providers this AI gateway can use",
"aiResourceProvidersHelp": "Attach providers and choose inherit (use each provider's lists) or select (pick an allow list for this resource). Allow patterns that conflict across attached providers are not allowed.",
"aiResourceProvidersSelect": "Select providers",
"aiResourceProvidersEmpty": "No AI providers found",
"aiResourceProvidersNoneAttached": "No providers attached yet.",
"aiResourceProvidersAdd": "Add provider",
"aiResourceProvidersRemove": "Remove provider",
"aiResourceProviderToggleEnabled": "Enable or disable this provider on the resource",
"aiResourceProviderDisabled": "Disabled",
"aiResourceProvidersUpdated": "Providers updated",
"aiResourceProvidersErrorUpdate": "Failed to update providers",
"aiResourceProviderEditDescription": "Choose how this provider's models are exposed on this resource.",
"viewProviderSettings": "View Provider Settings",
"aiResourceProviderMode": "Access mode",
"aiResourceProviderModeInherit": "Inherit",
"aiResourceProviderModeSelect": "Select",
"aiResourceProviderModeSelectSummary": "Select · {count} models",
"aiResourceProviderModeInheritHelp": "Use this provider's allow and block lists as configured on the provider.",
"aiResourceProviderModeSelectHelp": "Choose a subset of this provider's allow-list models for this resource.",
"aiResourceProviderAllowModels": "Allow list",
"aiResourceProviderAllowModelsSelect": "Select models",
"aiResourceProviderAllowModelsSearch": "Search models...",
"aiResourceProviderAllowModelsEmpty": "No models found",
"aiResourceProviderAllowModelsHelp": "Only models from this provider's allow list can be selected.",
"aiResourceAliasRequired": "Alias is required for AI gateways",
"aiResourceDomainConfiguration": "Domain Configuration",
"aiResourceDomainConfigurationDescription": "Choose the domain clients will use to reach this AI gateway.",
"aiUsageAnalyticsTitle": "AI Usage Analytics",
"aiUsageAnalyticsDescription": "Analyze AI gateway cost, token usage, and activity across providers, resources, roles, and users",
"aiUsageTabOverview": "Overview",
"aiUsageTabProviders": "Providers",
"aiUsageTabResources": "Resources",
"aiUsageFilterProvider": "Provider",
"aiUsageFilterModel": "Model",
"aiUsageFilterResource": "Resource",
"aiUsageFilterRole": "Role",
"aiUsageFilterUser": "User",
"aiUsageFilterAllProviders": "All Providers",
"aiUsageFilterAllModels": "All Models",
"aiUsageFilterAllResources": "All Resources",
"aiUsageFilterAllRoles": "All Roles",
"aiUsageFilterAllUsers": "All Users",
"aiUsageFilterSearch": "Search...",
"aiUsageFilterNotFound": "No options found",
"aiUsageResetFilters": "Reset Filters",
"aiUsageRefresh": "Refresh",
"aiUsageTokenTypePrompt": "Prompt",
"aiUsageTokenTypeCacheRead": "Cache read",
"aiUsageTokenTypeCacheWrite": "Cache write",
"aiUsageTokenTypeCompletion": "Completion",
"aiUsageTokenTypeReasoning": "Reasoning",
"aiUsageRequests": "Requests",
"aiUsageCost": "Cost",
"aiUsageTokens": "Tokens",
"aiUsageOther": "Other",
"aiUsageTotalRequests": "Total Requests",
"aiUsageTotalTokens": "Total Tokens",
"aiUsageTotalCost": "Total Cost",
"aiUsageEstimated": "Estimated",
"aiUsageRequestVolume": "Request Volume",
"aiUsageTokenUsage": "Token Usage",
"aiUsageModelCost": "Model Cost",
"aiUsageModelTokens": "Model Tokens",
"aiUsageTopModels": "Top Models",
"aiUsageTopProviders": "Top Providers",
"aiUsageProviderCost": "Provider Cost",
"aiUsageProviderTokenUsage": "Provider Token Usage",
"aiUsageTopResources": "Top Resources",
"aiUsageResourceCost": "Resource Cost",
"aiUsageResourceTokenUsage": "Resource Token Usage",
"aiUsageResourceTypePublic": "Public Resource",
"aiUsageResourceTypeSite": "Private Resource",
"aiUsageNoResource": "No resource",
"aiUsageRolesTab": "Roles",
"aiUsageUsersTab": "Users",
"aiUsageTopRoles": "Top Roles",
"aiUsageRoleCost": "Role Cost",
"aiUsageRoleTokenUsage": "Role Token Usage",
"aiUsageTopUsers": "Top Users",
"aiUsageUserCost": "User Cost",
"aiUsageUserTokenUsage": "User Token Usage",
"aiUsageUnknownUser": "Unknown user",
"aiUsageVirtualApiKeysTab": "Virtual API Keys",
"aiUsageFilterVirtualApiKey": "Virtual API Key",
"aiUsageFilterAllVirtualApiKeys": "All Virtual API Keys",
"aiUsageTopVirtualApiKeys": "Top Virtual API Keys",
"aiUsageVirtualApiKeyCost": "Virtual API Key Cost",
"aiUsageVirtualApiKeyTokenUsage": "Virtual API Key Token Usage",
"aiUsageUnknownVirtualApiKey": "No virtual API key",
"aiUsageUnnamedVirtualApiKey": "Unnamed key",
"aiUsageLoading": "Loading...",
"aiUsageNoData": "No data",
"resourceBudgetSettings": "Budget",
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
@@ -1995,6 +2405,9 @@
"domainPickerSubdomain": "Subdomain: {subdomain}",
"domainPickerNamespace": "Namespace: {namespace}",
"domainPickerShowMore": "Show More",
"domainPickerNoDomainsAvailableTitle": "No domains available",
"domainPickerNoDomainsAvailableDescription": "You don't have any domains set up yet. Create a domain to continue.",
"domainPickerNoDomainsAvailableAction": "Go to Domains",
"regionSelectorTitle": "Select Region",
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
@@ -2190,6 +2603,8 @@
"subnetPlaceholder": "Subnet",
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
"selectSites": "Select sites",
"selectResources": "Select resources",
"multiResourcesSelectorResourcesCount": "{count, plural, one {# resource} other {# resources}}",
"selectLabels": "Select labels",
"sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Machine Client",
@@ -2242,10 +2657,11 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshSettings": "SSH Settings",
"sshSettings": "SSH",
"inferenceSettings": "AI Gateway",
"sshAccess": "SSH Access",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
"rdpSettings": "RDP",
"vncSettings": "VNC",
"sshServer": "SSH Server",
"rdpServer": "RDP Server",
"vncServer": "VNC Server",
@@ -2358,7 +2774,7 @@
"resourcesTableProxyResources": "Public",
"resourcesTableClientResources": "Private",
"resourcesTableNoProxyResourcesFound": "No proxy resources found.",
"resourcesTableNoInternalResourcesFound": "No internal resources found.",
"resourcesTableNoInternalResourcesFound": "No private resources found.",
"resourcesTableDestination": "Destination",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias Address",
@@ -2381,9 +2797,9 @@
"editInternalResourceDialogCancel": "Cancel",
"editInternalResourceDialogSaveResource": "Save Resource",
"editInternalResourceDialogSuccess": "Success",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Private resource updated successfully",
"editInternalResourceDialogError": "Error",
"editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource",
"editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update private resource",
"editInternalResourceDialogNameRequired": "Name is required",
"editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
"editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1",
@@ -2398,6 +2814,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeInference": "AI Gateway",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS",
@@ -2409,7 +2826,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.",
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create private resources.",
"createInternalResourceDialogClose": "Close",
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
@@ -2418,8 +2835,8 @@
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
"privateResourceNetworkAccess": "Network Access",
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
"hostSettings": "Host Settings",
"cidrSettings": "CIDR Settings",
"hostSettings": "Host",
"cidrSettings": "CIDR",
"createInternalResourceDialogResourceProperties": "Resource Properties",
"createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site",
@@ -2438,9 +2855,9 @@
"createInternalResourceDialogCancel": "Cancel",
"createInternalResourceDialogCreateResource": "Create Resource",
"createInternalResourceDialogSuccess": "Success",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Private resource created successfully",
"createInternalResourceDialogError": "Error",
"createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource",
"createInternalResourceDialogFailedToCreateInternalResource": "Failed to create private resource",
"createInternalResourceDialogNameRequired": "Name is required",
"createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters",
"createInternalResourceDialogPleaseSelectSite": "Please select a site",
@@ -2457,6 +2874,7 @@
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"createInternalResourceDialogModeInference": "AI Gateway",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable TLS",
@@ -3048,6 +3466,7 @@
"validPassword": "Valid Password",
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"validVirtualAPIKey": "Valid Virtual API Key",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
@@ -3069,6 +3488,38 @@
"logRetention": "Log Retention",
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
"requestLogsDescription": "View detailed request logs for HTTPS resources in this organization",
"aiSessionLogs": "AI Gateway Session Logs",
"aiSessionLogsDescription": "View prompt and response transcripts for AI gateway requests in this organization",
"sidebarLogsAi": "Session Logs",
"commandLogsAi": "Session Logs",
"sidebarLogsAiUsage": "Usage Analytics",
"commandLogsAiUsage": "Usage Analytics",
"provider": "Provider",
"capability": "Capability",
"model": "Model",
"virtualApiKey": "Virtual API Key",
"noVirtualApiKey": "No virtual API key",
"stream": "Stream",
"streaming": "Streaming",
"nonStreaming": "Non-streaming",
"statusCode": "Status Code",
"aiSessionId": "Session ID",
"aiSessionRequest": "Request",
"aiSessionResponse": "Response",
"aiSessionNoData": "No data captured",
"aiSessionCouldNotParse": "(raw, could not parse transcript)",
"aiSessionLogTruncated": "This session was truncated before storage and may be incomplete.",
"aiSessionViewRaw": "View Raw JSON",
"aiSessionViewChat": "View Chat",
"cost": "Cost",
"estimated": "Estimated",
"tokenUsage": "Token Usage",
"promptTokens": "Prompt Tokens",
"cacheReadTokens": "Cache Read Tokens",
"cacheWriteTokens": "Cache Write Tokens",
"completionTokens": "Completion Tokens",
"reasoningTokens": "Reasoning Tokens",
"totalTokens": "Total Tokens",
"requestAnalyticsDescription": "View detailed request analytics for resources in this organization",
"logRetentionRequestLabel": "HTTP Request Log Retention",
"logRetentionRequestDescription": "How long to retain request logs",
@@ -3078,6 +3529,8 @@
"logRetentionActionDescription": "How long to retain action logs",
"logRetentionConnectionLabel": "Network Log Retention",
"logRetentionConnectionDescription": "How long to retain connection logs",
"logRetentionAISessionsLabel": "AI Gateway Session Log Retention",
"logRetentionAISessionsDescription": "How long to retain AI gateway prompt/response session logs",
"logRetentionDisabled": "Disabled",
"logRetention3Days": "3 days",
"logRetention7Days": "7 days",
@@ -3095,8 +3548,8 @@
"sourceAddress": "Source Address",
"destinationAddress": "Destination Address",
"duration": "Duration",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"licenseRequiredToUse": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or trial</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition license</enterpriseEditionLink> is required. <bookADemoLink>Book a demo or trial</bookADemoLink>",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver",
@@ -3294,9 +3747,9 @@
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
"createInternalResourceDialogHttpConfiguration": "Domain Configuration",
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
"editInternalResourceDialogHttpConfiguration": "Domain Configuration",
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
"editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP",
@@ -3732,7 +4185,18 @@
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherAiGateway": "AI Gateway",
"resourceLauncherNoSite": "No Site",
"resourceLauncherAvailableModels": "Available Models",
"resourceLauncherAvailableModelsDescription": "Models you can use with this AI gateway.",
"resourceLauncherAvailableModelsEmpty": "No models are available for this resource.",
"resourceLauncherAvailableModelsError": "Could not load available models.",
"resourceLauncherApiKeys": "API Keys",
"resourceLauncherApiKeysDescription": "Use your identity key or an attributed key to authenticate with this resource.",
"resourceLauncherApiKeysIdentity": "Identity Key",
"resourceLauncherApiKeysManual": "Attributed Keys",
"resourceLauncherApiKeysEmpty": "No API keys are available for this resource.",
"resourceLauncherApiKeysError": "Could not load API keys.",
"resourceLauncherNoResourcesInGroup": "No resources in this group",
"resourceLauncherEmptyStateTitle": "No Resources Available",
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
@@ -3759,9 +4223,9 @@
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
"memberPortalPrevious": "Previous",
"memberPortalNext": "Next",
"httpSettings": "HTTP Settings",
"tcpSettings": "TCP Settings",
"udpSettings": "UDP Settings",
"httpSettings": "HTTP",
"tcpSettings": "TCP",
"udpSettings": "UDP",
"sshTitle": "SSH",
"sshConnectingDescription": "Establishing a secure connection…",
"sshConnecting": "Connecting…",
@@ -3823,4 +4287,4 @@
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals"
}
}
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Confirmar Eliminación de Enlace Compartible",
"shareQuestionRemove": "¿Está seguro de que desea borrar este enlace compartido?",
"shareMessageRemove": "Una vez borrado, el enlace dejará de funcionar y cualquier persona que lo use perderá acceso al recurso.",
"shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.",
"shareTokenDescription": "El token de acceso se puede pasar como un parámetro de consulta o en los encabezados de la solicitud. Por defecto, se debe enviar en cada solicitud. Si la persistencia de sesión está habilitada, la primera solicitud lo intercambia por una cookie de sesión.",
"accessToken": "Token de acceso",
"usageExamples": "Ejemplos de uso",
"tokenId": "ID de token",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Título (opcional)",
"sharePathOptional": "Ruta (opcional)",
"sharePathDescription": "El enlace redirigirá a los usuarios a esta ruta tras la autenticación.",
"shareAssociateUserOptional": "Asociar Usuario (opcional)",
"shareAssociateUserDescription": "Cuando está configurado, las solicitudes que usan este enlace se atribuyen al usuario en los registros de acceso y encabezados de identidad. El enlace se elimina si el usuario abandona la organización.",
"userSelect": "Seleccione usuario",
"usersNotFound": "No se encontraron usuarios",
"expireIn": "Caduca en",
"neverExpire": "Nunca expirar",
"sharePersistSession": "Persistir sesión después del primer uso",
"sharePersistSessionDescription": "Cuando está habilitado, la primera solicitud con este token mediante un parámetro de consulta o encabezado configura una cookie de sesión, por lo que las solicitudes posteriores no necesitan el token. Dejar desactivado para clientes de API que deben enviar el token en cada solicitud.",
"shareExpireDescription": "El tiempo de caducidad es cuánto tiempo el enlace será utilizable y proporcionará acceso al recurso. Después de este tiempo, el enlace ya no funcionará, y los usuarios que usaron este enlace perderán el acceso al recurso.",
"shareSeeOnce": "Sólo podrás ver este enlace una vez. Asegúrate de copiarlo.",
"shareAccessHint": "Cualquiera con este enlace puede acceder al recurso. Compártelo con cuidado.",
@@ -928,7 +934,7 @@
"newtVersion": "Versión",
"architecture": "Arquitectura",
"sites": "Sitios",
"siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.",
"siteWgAnyClients": "Usa cualquier cliente de WireGuard para conectarte. Tendrás que dirigirte a recursos privados usando la IP del par.",
"siteWgCompatibleAllClients": "Compatible con todos los clientes de Wirex Guard",
"siteWgManualConfigurationRequired": "Configuración manual requerida",
"userErrorNotAdminOrOwner": "El usuario no es un administrador o propietario",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdominio: {subdomain}",
"domainPickerNamespace": "Espacio de nombres: {namespace}",
"domainPickerShowMore": "Mostrar más",
"domainPickerNoDomainsAvailableTitle": "No hay dominios disponibles",
"domainPickerNoDomainsAvailableDescription": "Aún no tiene ningún dominio configurado. Cree un dominio para continuar.",
"domainPickerNoDomainsAvailableAction": "Ir a Dominios",
"regionSelectorTitle": "Seleccionar Región",
"domainPickerRemoteExitNodeWarning": "Los dominios suministrados no son compatibles cuando los sitios se conectan a nodos de salida remotos. Para que los recursos estén disponibles en nodos remotos, utilice un dominio personalizado en su lugar.",
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Público",
"resourcesTableClientResources": "Privado",
"resourcesTableNoProxyResourcesFound": "No se encontraron recursos de proxy.",
"resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.",
"resourcesTableNoInternalResourcesFound": "No se encontraron recursos privados.",
"resourcesTableDestination": "Destino",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Dirección del alias",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Cancelar",
"editInternalResourceDialogSaveResource": "Guardar recurso",
"editInternalResourceDialogSuccess": "Éxito",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno actualizado con éxito",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso privado actualizado con éxito",
"editInternalResourceDialogError": "Error",
"editInternalResourceDialogFailedToUpdateInternalResource": "Error al actualizar el recurso interno",
"editInternalResourceDialogFailedToUpdateInternalResource": "Error al actualizar el recurso privado",
"editInternalResourceDialogNameRequired": "El nombre es requerido",
"editInternalResourceDialogNameMaxLength": "El nombre no debe tener más de 255 caracteres",
"editInternalResourceDialogProxyPortMin": "El puerto del proxy debe ser al menos 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.",
"createInternalResourceDialogNoSitesAvailable": "No hay sitios disponibles",
"createInternalResourceDialogNoSitesAvailableDescription": "Necesita tener al menos un sitio de Newt con una subred configurada para crear recursos internos.",
"createInternalResourceDialogNoSitesAvailableDescription": "Necesita tener al menos un sitio de Newt con una subred configurada para crear recursos privados.",
"createInternalResourceDialogClose": "Cerrar",
"createInternalResourceDialogCreateClientResource": "Crear recurso privado",
"createInternalResourceDialogCreateClientResourceDescription": "Crear un nuevo recurso que sólo será accesible a los clientes conectados a la organización",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Cancelar",
"createInternalResourceDialogCreateResource": "Crear recurso",
"createInternalResourceDialogSuccess": "Éxito",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno creado con éxito",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso privado creado con éxito",
"createInternalResourceDialogError": "Error",
"createInternalResourceDialogFailedToCreateInternalResource": "Error al crear recurso interno",
"createInternalResourceDialogFailedToCreateInternalResource": "Error al crear recurso privado",
"createInternalResourceDialogNameRequired": "El nombre es requerido",
"createInternalResourceDialogNameMaxLength": "El nombre debe ser menor de 255 caracteres",
"createInternalResourceDialogPleaseSelectSite": "Por favor seleccione un sitio",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Dirección de origen",
"destinationAddress": "Dirección de destino",
"duration": "Duración",
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> para usar esta función. <bookADemoLink>Reserve una demostración o prueba POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserva una demostración o prueba POC</bookADemoLink>.",
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> para usar esta función. <bookADemoLink>Reserve una demostración gratuita o una prueba POC para saber más.</bookADemoLink>",
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserva una demostración gratuita o prueba POC para saber más.</bookADemoLink>",
"certResolver": "Resolver certificado",
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
"selectCertResolver": "Seleccionar Resolver Certificado",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Confirmer la suppression du lien partageable",
"shareQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?",
"shareMessageRemove": "Une fois supprimé, le lien ne fonctionnera plus et toute personne l'utilisant perdra l'accès à la ressource.",
"shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.",
"shareTokenDescription": "Le jeton d'accès peut être transmis comme paramètre de requête ou dans les en-têtes de requête. Par défaut, il doit être envoyé à chaque requête. Si la persistance de session est activée, la première requête l'échange contre un cookie de session.",
"accessToken": "Jeton d'accès",
"usageExamples": "Exemples d'utilisation",
"tokenId": "ID du jeton",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Titre (facultatif)",
"sharePathOptional": "Chemin (optionnel)",
"sharePathDescription": "Le lien redirigera les utilisateurs vers ce chemin après l'authentification.",
"shareAssociateUserOptional": "Associer un utilisateur (facultatif)",
"shareAssociateUserDescription": "Lorsqu'il est défini, les requêtes utilisant ce lien sont attribuées à l'utilisateur dans les journaux d'accès et les en-têtes d'identité. Le lien est supprimé si l'utilisateur quitte l'organisation.",
"userSelect": "Sélectionner un utilisateur",
"usersNotFound": "Aucun utilisateur trouvé",
"expireIn": "Expire dans",
"neverExpire": "N'expire jamais",
"sharePersistSession": "Persister la session après la première utilisation",
"sharePersistSessionDescription": "Lorsqu'elle est activée, la première requête avec ce jeton via un paramètre de requête ou un en-tête définit un cookie de session afin que les requêtes ultérieures n'aient pas besoin du jeton. Désactivez-le pour les clients API qui doivent envoyer le jeton à chaque requête.",
"shareExpireDescription": "Le délai d'expiration correspond à la période pendant laquelle le lien sera utilisable et permettra d'accéder à la ressource. Passé ce délai, le lien ne fonctionnera plus et les utilisateurs qui l'ont utilisé perdront l'accès à la ressource.",
"shareSeeOnce": "Vous ne pourrez voir ce lien qu'une seule fois. N'oubliez pas de le copier.",
"shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec précaution.",
@@ -928,7 +934,7 @@
"newtVersion": "Version",
"architecture": "Architecture",
"sites": "Nœuds",
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.",
"siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser les ressources privées en utilisant l'IP du pair.",
"siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard",
"siteWgManualConfigurationRequired": "Configuration manuelle requise",
"userErrorNotAdminOrOwner": "L'utilisateur n'est pas un administrateur ou un propriétaire",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Sous-domaine : {subdomain}",
"domainPickerNamespace": "Espace de noms : {namespace}",
"domainPickerShowMore": "Afficher plus",
"domainPickerNoDomainsAvailableTitle": "Aucun domaine disponible",
"domainPickerNoDomainsAvailableDescription": "Vous n'avez pas encore configuré de domaine. Créez un domaine pour continuer.",
"domainPickerNoDomainsAvailableAction": "Aller aux domaines",
"regionSelectorTitle": "Sélectionner Région",
"domainPickerRemoteExitNodeWarning": "Les domaines fournis ne sont pas pris en charge lorsque les sites se connectent à des nœuds de sortie distants. Pour que les ressources soient disponibles sur des nœuds distants, utilisez un domaine personnalisé à la place.",
"regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Publique",
"resourcesTableClientResources": "Privé",
"resourcesTableNoProxyResourcesFound": "Aucune ressource proxy trouvée.",
"resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.",
"resourcesTableNoInternalResourcesFound": "Aucune ressource privée trouvée.",
"resourcesTableDestination": "Destination",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adresse de l'alias",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Abandonner",
"editInternalResourceDialogSaveResource": "Enregistrer la ressource",
"editInternalResourceDialogSuccess": "Succès",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Ressource interne mise à jour avec succès",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Ressource privée mise à jour avec succès",
"editInternalResourceDialogError": "Erreur",
"editInternalResourceDialogFailedToUpdateInternalResource": "Échec de la mise à jour de la ressource interne",
"editInternalResourceDialogFailedToUpdateInternalResource": "Échec de la mise à jour de la ressource privée",
"editInternalResourceDialogNameRequired": "Le nom est requis",
"editInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères",
"editInternalResourceDialogProxyPortMin": "Le port proxy doit être d'au moins 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.",
"createInternalResourceDialogNoSitesAvailable": "Aucun site disponible",
"createInternalResourceDialogNoSitesAvailableDescription": "Vous devez avoir au moins un site Newt avec un sous-réseau configuré pour créer des ressources internes.",
"createInternalResourceDialogNoSitesAvailableDescription": "Vous devez avoir au moins un site Newt avec un sous-réseau configuré pour créer des ressources privées.",
"createInternalResourceDialogClose": "Fermer",
"createInternalResourceDialogCreateClientResource": "Créer une ressource privée",
"createInternalResourceDialogCreateClientResourceDescription": "Créer une nouvelle ressource qui ne sera accessible qu'aux clients connectés à l'organisation",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Abandonner",
"createInternalResourceDialogCreateResource": "Créer une ressource",
"createInternalResourceDialogSuccess": "Succès",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Ressource interne créée avec succès",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Ressource privée créée avec succès",
"createInternalResourceDialogError": "Erreur",
"createInternalResourceDialogFailedToCreateInternalResource": "Échec de la création de la ressource interne",
"createInternalResourceDialogFailedToCreateInternalResource": "Échec de la création de la ressource privée",
"createInternalResourceDialogNameRequired": "Le nom est requis",
"createInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères",
"createInternalResourceDialogPleaseSelectSite": "Veuillez sélectionner un site",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Adresse source",
"destinationAddress": "Adresse de destination",
"duration": "Durée",
"licenseRequiredToUse": "Une <enterpriseLicenseLink>licence Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> est requise pour utiliser cette fonctionnalité. <bookADemoLink>Réservez une démonstration ou une évaluation de POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Réservez une démo ou un essai POC</bookADemoLink>.",
"licenseRequiredToUse": "Une <enterpriseLicenseLink>licence Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> est requise pour utiliser cette fonctionnalité. <bookADemoLink>Réservez une démo gratuite ou un essai POC pour en savoir plus.</bookADemoLink>",
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Réservez une démo gratuite ou un essai POC pour en savoir plus.</bookADemoLink>",
"certResolver": "Résolveur de certificat",
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
"selectCertResolver": "Sélectionnez le résolveur de certificat",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Conferma Eliminazione Collegamento Condivisibile",
"shareQuestionRemove": "Sei sicuro di voler eliminare questo link di condivisione?",
"shareMessageRemove": "Una volta eliminato, il link non funzionerà più e chiunque lo utilizzi perderà l'accesso alla risorsa.",
"shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.",
"shareTokenDescription": "Il token di accesso può essere passato come parametro di query o nei header delle richieste. Per impostazione predefinita deve essere inviato a ogni richiesta. Se la persistenza della sessione è abilitata, la prima richiesta lo scambia per un cookie di sessione.",
"accessToken": "Token Di Accesso",
"usageExamples": "Esempi Di Utilizzo",
"tokenId": "ID del Token",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Titolo (facoltativo)",
"sharePathOptional": "Percorso (opzionale)",
"sharePathDescription": "Il link reindirizzerà gli utenti a questo percorso dopo l'autenticazione.",
"shareAssociateUserOptional": "Associa utente (opzionale)",
"shareAssociateUserDescription": "Quando impostato, le richieste utilizzando questo link sono attribuite all'utente nei log di accesso e negli header di identità. Il link viene rimosso se l'utente lascia l'organizzazione.",
"userSelect": "Seleziona utente",
"usersNotFound": "Nessun utente trovato",
"expireIn": "Scadenza In",
"neverExpire": "Nessuna scadenza",
"sharePersistSession": "Mantieni la sessione dopo il primo utilizzo",
"sharePersistSessionDescription": "Quando abilitato, la prima richiesta con questo token tramite un parametro di query o header imposta un cookie di sessione quindi le richieste successive non hanno bisogno del token. Disattivare per i client API che devono inviare il token a ogni richiesta.",
"shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
"shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.",
"shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.",
@@ -928,7 +934,7 @@
"newtVersion": "Versione",
"architecture": "Architettura",
"sites": "Siti",
"siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.",
"siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse private utilizzando l'IP del peer.",
"siteWgCompatibleAllClients": "Compatibile con tutti i client WireGuard",
"siteWgManualConfigurationRequired": "Configurazione manuale richiesta",
"userErrorNotAdminOrOwner": "L'utente non è un amministratore o proprietario",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Sottodominio: {subdomain}",
"domainPickerNamespace": "Namespace: {namespace}",
"domainPickerShowMore": "Mostra Altro",
"domainPickerNoDomainsAvailableTitle": "Nessun dominio disponibile",
"domainPickerNoDomainsAvailableDescription": "Non hai ancora configurato alcun dominio. Crea un dominio per continuare.",
"domainPickerNoDomainsAvailableAction": "Vai ai Domini",
"regionSelectorTitle": "Seleziona regione",
"domainPickerRemoteExitNodeWarning": "I domini forniti non sono supportati quando i siti si connettono a nodi di uscita remoti. Affinché le risorse siano disponibili su nodi remoti, utilizza invece un dominio personalizzato.",
"regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Pubblico",
"resourcesTableClientResources": "Privato",
"resourcesTableNoProxyResourcesFound": "Nessuna risorsa proxy trovata.",
"resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.",
"resourcesTableNoInternalResourcesFound": "Nessuna risorsa privata trovata.",
"resourcesTableDestination": "Destinazione",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Indirizzo Alias",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Annulla",
"editInternalResourceDialogSaveResource": "Salva Risorsa",
"editInternalResourceDialogSuccess": "Successo",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Risorsa interna aggiornata con successo",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Risorsa privata aggiornata con successo",
"editInternalResourceDialogError": "Errore",
"editInternalResourceDialogFailedToUpdateInternalResource": "Impossibile aggiornare la risorsa interna",
"editInternalResourceDialogFailedToUpdateInternalResource": "Impossibile aggiornare la risorsa privata",
"editInternalResourceDialogNameRequired": "Il nome è obbligatorio",
"editInternalResourceDialogNameMaxLength": "Il nome deve essere inferiore a 255 caratteri",
"editInternalResourceDialogProxyPortMin": "La porta proxy deve essere almeno 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.",
"createInternalResourceDialogNoSitesAvailable": "Nessun Sito Disponibile",
"createInternalResourceDialogNoSitesAvailableDescription": "Devi avere almeno un sito Newt con una subnet configurata per creare risorse interne.",
"createInternalResourceDialogNoSitesAvailableDescription": "Devi avere almeno un sito Newt con una subnet configurata per creare risorse private.",
"createInternalResourceDialogClose": "Chiudi",
"createInternalResourceDialogCreateClientResource": "Crea Risorsa Privata",
"createInternalResourceDialogCreateClientResourceDescription": "Crea una nuova risorsa che sarà accessibile solo ai client connessi all'organizzazione",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Annulla",
"createInternalResourceDialogCreateResource": "Crea Risorsa",
"createInternalResourceDialogSuccess": "Successo",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Risorsa interna creata con successo",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Risorsa privata creata con successo",
"createInternalResourceDialogError": "Errore",
"createInternalResourceDialogFailedToCreateInternalResource": "Impossibile creare la risorsa interna",
"createInternalResourceDialogFailedToCreateInternalResource": "Impossibile creare la risorsa privata",
"createInternalResourceDialogNameRequired": "Il nome è obbligatorio",
"createInternalResourceDialogNameMaxLength": "Il nome non deve superare i 255 caratteri",
"createInternalResourceDialogPleaseSelectSite": "Si prega di selezionare un sito",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Indirizzo Di Origine",
"destinationAddress": "Indirizzo Di Destinazione",
"duration": "Durata",
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Prenota una demo o una prova POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Prenota una demo o una prova POC</bookADemoLink>.",
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> o <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Prenota una demo gratuita o una prova POC per saperne di più.</bookADemoLink>",
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Prenota una demo gratuita o una prova POC per saperne di più.</bookADemoLink>",
"certResolver": "Risolutore Di Certificato",
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
"selectCertResolver": "Seleziona Risolutore Di Certificato",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "공유 가능한 링크 삭제 확인",
"shareQuestionRemove": "이 공유 링크를 삭제하시겠습니까?",
"shareMessageRemove": "삭제되면 링크가 더 이상 작동하지 않으며, 이를 사용하는 모든 사용자는 자원에 대한 접근을 잃게 됩니다.",
"shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.",
"shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더 전달될 수 있습니다. 기본적으로 모든 요청에 포함되어야 합니다. 세션 지속이 활성화된 경우, 첫 번째 요청이 세션 쿠키로 교환됩니다.",
"accessToken": "액세스 토큰",
"usageExamples": "사용 예",
"tokenId": "토큰 ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "제목 (선택 사항)",
"sharePathOptional": "경로 (선택 사항)",
"sharePathDescription": "링크는 인증 후 이 경로로 사용자를 리디렉션합니다.",
"shareAssociateUserOptional": "사용자 연관 (선택 사항)",
"shareAssociateUserDescription": "설정 시, 이 링크를 사용하는 요청은 액세스 로그와 ID 헤더에서 사용자로 기록됩니다. 사용자가 조직을 떠나면 링크가 제거됩니다.",
"userSelect": "사용자 선택",
"usersNotFound": "사용자를 찾을 수 없습니다",
"expireIn": "만료됨",
"neverExpire": "만료되지 않음",
"sharePersistSession": "첫 사용 후 세션 지속",
"sharePersistSessionDescription": "활성화된 경우, 쿼리 매개변수나 헤더를 통해 이 토큰으로 첫 요청 시 세션 쿠키가 설정되어 이후 요청에는 토큰이 필요하지 않습니다. 모든 요청에 토큰을 포함해야 하는 API 클라이언트의 경우, 이 옵션을 해제하세요.",
"shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.",
"shareSeeOnce": "이 링크는 한 번만 볼 수 있습니다. 반드시 복사해 두세요.",
"shareAccessHint": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다. 주의해서 공유하세요.",
@@ -928,7 +934,7 @@
"newtVersion": "버전",
"architecture": "아키텍처",
"sites": "사이트",
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.",
"siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 개인 리소스에 접근해야 합니다.",
"siteWgCompatibleAllClients": "모든 WireGuard 클라이언트와 호환",
"siteWgManualConfigurationRequired": "수동 구성이 필요합니다.",
"userErrorNotAdminOrOwner": "사용자는 관리자 또는 소유자가 아닙니다.",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "서브도메인: {subdomain}",
"domainPickerNamespace": "이름 공간: {namespace}",
"domainPickerShowMore": "더보기",
"domainPickerNoDomainsAvailableTitle": "사용 가능한 도메인이 없습니다",
"domainPickerNoDomainsAvailableDescription": "설정된 도메인이 아직 없습니다. 계속하려면 도메인을 생성하세요.",
"domainPickerNoDomainsAvailableAction": "도메인으로 이동",
"regionSelectorTitle": "지역 선택",
"domainPickerRemoteExitNodeWarning": "제공된 도메인은 원격 종료 노드에 연결된 사이트에서 지원되지 않습니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.",
"regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "공유",
"resourcesTableClientResources": "비공개",
"resourcesTableNoProxyResourcesFound": "프록시 리소스를 찾을 수 없습니다.",
"resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.",
"resourcesTableNoInternalResourcesFound": "개인 리소스를 찾을 수 없습니다.",
"resourcesTableDestination": "대상지",
"resourcesTableAlias": "별칭",
"resourcesTableAliasAddress": "별칭 주소",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "취소",
"editInternalResourceDialogSaveResource": "리소스 저장",
"editInternalResourceDialogSuccess": "성공",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "내부 리소스가 성공적으로 업데이트되었습니다",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "개인 리소스가 성공적으로 업데이트되었습니다",
"editInternalResourceDialogError": "오류",
"editInternalResourceDialogFailedToUpdateInternalResource": "내부 리소스 업데이트 실패",
"editInternalResourceDialogFailedToUpdateInternalResource": "개인 리소스 업데이트 실패",
"editInternalResourceDialogNameRequired": "이름은 필수입니다.",
"editInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.",
"editInternalResourceDialogProxyPortMin": "프록시 포트는 최소 1이어야 합니다.",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "별칭",
"editInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.",
"createInternalResourceDialogNoSitesAvailable": "사용 가능한 사이트가 없습니다.",
"createInternalResourceDialogNoSitesAvailableDescription": "내부 리소스를 생성하려면 서브넷이 구성된 최소 하나의 Newt 사이트가 필요합니다.",
"createInternalResourceDialogNoSitesAvailableDescription": "개인 리소스를 생성하려면 서브넷이 구성된 최소 하나의 Newt 사이트가 필요합니다.",
"createInternalResourceDialogClose": "닫기",
"createInternalResourceDialogCreateClientResource": "사이트 리소스 생성",
"createInternalResourceDialogCreateClientResourceDescription": "선택한 사이트에 연결된 클라이언트에 접근할 새 리소스를 생성합니다",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "취소",
"createInternalResourceDialogCreateResource": "리소스 생성",
"createInternalResourceDialogSuccess": "성공",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "내부 리소스가 성공적으로 생성되었습니다.",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "개인 리소스가 성공적으로 생성되었습니다",
"createInternalResourceDialogError": "오류",
"createInternalResourceDialogFailedToCreateInternalResource": "내부 리소스 생성 실패",
"createInternalResourceDialogFailedToCreateInternalResource": "개인 리소스 생성 실패",
"createInternalResourceDialogNameRequired": "이름은 필수입니다.",
"createInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.",
"createInternalResourceDialogPleaseSelectSite": "사이트를 선택하세요",
@@ -3089,8 +3098,8 @@
"sourceAddress": "소스 주소",
"destinationAddress": "대상 주소",
"duration": "지속 시간",
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>.",
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이(가) 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>.",
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>",
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다. <bookADemoLink>데모 또는 POC 체험을 예약하세요</bookADemoLink>",
"certResolver": "인증서 해결사",
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
"selectCertResolver": "인증서 해결사 선택",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Bekreft sletting av delbar lenke",
"shareQuestionRemove": "Er du sikker på at du vil slette denne delingslenken?",
"shareMessageRemove": "Når slettet, vil lenken ikke lenger fungere, og alle som bruker den vil miste tilgang til ressursen.",
"shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.",
"shareTokenDescription": "Tilgangstokenn kan sendes som en spørringsparameter eller i forespørselshoder. Som standard må det sendes med hver forespørsel. Hvis sesjonsvedholdenhet er aktivert, byttes den første forespørselen mot en sesjons-cookie.",
"accessToken": "Tilgangsnøkkel",
"usageExamples": "Brukseksempler",
"tokenId": "Token-ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Tittel (valgfritt)",
"sharePathOptional": "Bane (valgfritt)",
"sharePathDescription": "Lenken vil videresende brukere til denne stien etter autentisering.",
"shareAssociateUserOptional": "Tilknytt bruker (valgfritt)",
"shareAssociateUserDescription": "Når den er satt, blir forespørsler som bruker denne koblingen tilskrevet brukeren i tilgangslogger og identitetshoder. Koblingen fjernes hvis brukeren forlater organisasjonen.",
"userSelect": "Velg bruker",
"usersNotFound": "Ingen brukere funnet",
"expireIn": "Utløper om",
"neverExpire": "Utløper aldri",
"sharePersistSession": "Behold sesjonen etter første bruk",
"sharePersistSessionDescription": "Når den er aktivert, setter den første forespørselen med dette tokenet via en spørringsparameter eller et hode en sesjons-cookie slik at senere forespørsler ikke trenger tokenet. La det være av for API-klienter som skal sende tokenet ved hver forespørsel.",
"shareExpireDescription": "Utløpstid er hvor lenge lenken vil være brukbar og gi tilgang til ressursen. Etter denne tiden vil lenken ikke lenger fungere, og brukere som brukte denne lenken vil miste tilgangen til ressursen.",
"shareSeeOnce": "Du vil bare kunne se denne linken én gang. Pass på å kopiere den.",
"shareAccessHint": "Alle med denne lenken kan få tilgang til ressursen. Del forsiktig.",
@@ -928,7 +934,7 @@
"newtVersion": "Versjon",
"architecture": "Arkitektur",
"sites": "Områder",
"siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.",
"siteWgAnyClients": "Bruk hvilken som helst WireGuard-klient for å koble til. Du må adressere private ressurser ved å bruke peer-IP.",
"siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter",
"siteWgManualConfigurationRequired": "Manuell konfigurasjon påkrevd",
"userErrorNotAdminOrOwner": "Bruker er ikke administrator eller eier",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Underdomene: {subdomain}",
"domainPickerNamespace": "Navnerom: {namespace}",
"domainPickerShowMore": "Vis mer",
"domainPickerNoDomainsAvailableTitle": "Ingen domener tilgjengelig",
"domainPickerNoDomainsAvailableDescription": "Du har ikke satt opp noen domener ennå. Opprett et domene for å fortsette.",
"domainPickerNoDomainsAvailableAction": "Gå til domener",
"regionSelectorTitle": "Velg Region",
"domainPickerRemoteExitNodeWarning": "Tilbudte domener støttes ikke når sider kobles til eksterne avkjøringsnoder. For ressurser som skal være tilgjengelige på eksterne noder, brukes et egendefinert domene i stedet.",
"regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Offentlig",
"resourcesTableClientResources": "Privat",
"resourcesTableNoProxyResourcesFound": "Ingen proxy-ressurser funnet.",
"resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.",
"resourcesTableNoInternalResourcesFound": "Ingen private ressurser funnet.",
"resourcesTableDestination": "Destinasjon",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias adresse",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Avbryt",
"editInternalResourceDialogSaveResource": "Lagre ressurs",
"editInternalResourceDialogSuccess": "Suksess",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressurs oppdatert vellykket",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privat ressurs oppdatert vellykket",
"editInternalResourceDialogError": "Feil",
"editInternalResourceDialogFailedToUpdateInternalResource": "Mislyktes å oppdatere intern ressurs",
"editInternalResourceDialogFailedToUpdateInternalResource": "Feil ved oppdatering av privat ressurs",
"editInternalResourceDialogNameRequired": "Navn er påkrevd",
"editInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn",
"editInternalResourceDialogProxyPortMin": "Proxy-port må være minst 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.",
"createInternalResourceDialogNoSitesAvailable": "Ingen tilgjengelige steder",
"createInternalResourceDialogNoSitesAvailableDescription": "Du må ha minst ett Newt-område med et konfigureret delnett for å lage interne ressurser.",
"createInternalResourceDialogNoSitesAvailableDescription": "Du må ha minst ett Newt-område med et subnett konfigurert for å opprette private ressurser.",
"createInternalResourceDialogClose": "Lukk",
"createInternalResourceDialogCreateClientResource": "Opprett privat ressurs",
"createInternalResourceDialogCreateClientResourceDescription": "Opprett en ny ressurs som bare vil være tilgjengelig for kunder som er koblet til organisasjonen",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Avbryt",
"createInternalResourceDialogCreateResource": "Opprett ressurs",
"createInternalResourceDialogSuccess": "Suksess",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressurs opprettet vellykket",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privat ressurs opprettet vellykket",
"createInternalResourceDialogError": "Feil",
"createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke opprette intern ressurs",
"createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke opprette privat ressurs",
"createInternalResourceDialogNameRequired": "Navn er påkrevd",
"createInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn",
"createInternalResourceDialogPleaseSelectSite": "Vennligst velg et område",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Kilde adresse",
"destinationAddress": "Måladresse (Automatic Translation)",
"duration": "Varighet",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens eller <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> er påkrevd for å bruke denne funksjonen. <bookADemoLink>Bestill en demo eller POC prøveversjon</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Bestill en demo eller POC studie</bookADemoLink>.",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens eller <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> er nødvendig for å bruke denne funksjonen. <bookADemoLink>Bestill en gratis demo eller POC prøve for å lære mer.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Bestill en gratis demo eller POC prøve for å lære mer.</bookADemoLink>",
"certResolver": "Sertifikat løser",
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
"selectCertResolver": "Velg sertifikatløser",
+61 -52
View File
@@ -13,7 +13,7 @@
"orgDisplayName": "Dit is de weergavenaam van de organisatie.",
"orgId": "Organisatie ID",
"setupIdentifierMessage": "Dit is de unieke identificatie voor de organisatie.",
"setupErrorIdentifier": "Organisatie-ID is al in gebruik. Kies een andere.",
"setupErrorIdentifier": "Organisatie ID is al in gebruik, verzin iets anders.",
"componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.",
"componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.",
"welcome": "Welkom bij Pangolin!",
@@ -78,7 +78,7 @@
"siteManageSites": "Sites beheren",
"siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen",
"sitesBannerTitle": "Verbind elk netwerk",
"sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt tot bronnen, zowel openbaar als privé, aan gebruikers overal. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.",
"sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt voor gebruikers voor bronnen, zowel openbaar als privé. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.",
"sitesBannerButtonText": "Site installeren",
"approvalsBannerTitle": "Toegang tot het apparaat goedkeuren of weigeren",
"approvalsBannerDescription": "Bekijk en keur toestelverzoeken goed of weiger toegang van gebruikers. Wanneer apparaatgoedkeuringen vereist zijn, moeten gebruikers de goedkeuring van beheerders krijgen voordat hun apparaten verbinding kunnen maken met de bronnen van uw organisatie.",
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Bevestig Verwijdering Deelbare Link",
"shareQuestionRemove": "Weet u zeker dat u deze deel link wilt verwijderen?",
"shareMessageRemove": "Zodra verwijderd, zal de link niet meer werken en zal iedereen die het gebruikt de toegang tot de bron verliezen.",
"shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.",
"shareTokenDescription": "Het toegangstoken kan als queryparameter of in aanvraagheaders worden meegegeven. Standaard moet het bij elke aanvraag worden verzonden. Als sessie-persistentie is ingeschakeld, ruilt de eerste aanvraag deze in voor een sessiecookie.",
"accessToken": "Toegangs-token",
"usageExamples": "Voorbeelden van gebruik",
"tokenId": "Token ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Titel (optioneel)",
"sharePathOptional": "Pad (optioneel)",
"sharePathDescription": "De link zal gebruikers naar dit pad doorsturen na authenticatie.",
"shareAssociateUserOptional": "Gebruiker koppelen (optioneel)",
"shareAssociateUserDescription": "Wanneer ingesteld, worden aanvragen met deze link toegeschreven aan de gebruiker in toegangslogboeken en identiteitsheaders. De link wordt verwijderd als de gebruiker de organisatie verlaat.",
"userSelect": "Selecteer gebruiker",
"usersNotFound": "Geen gebruikers gevonden",
"expireIn": "Vervalt in",
"neverExpire": "Nooit verlopen",
"sharePersistSession": "Sessie behouden na eerste gebruik",
"sharePersistSessionDescription": "Wanneer ingeschakeld, zet de eerste aanvraag met dit token via een queryparameter of header een sessiecookie zodat latere aanvragen het token niet nodig hebben. Laat dit uit voor API-clients die het token bij elke aanvraag moeten verzenden.",
"shareExpireDescription": "Vervaltijd is hoe lang de link bruikbaar is en geeft toegang tot de bron. Na deze tijd zal de link niet meer werken en zullen gebruikers die deze link hebben gebruikt de toegang tot de pagina verliezen.",
"shareSeeOnce": "U kunt deze link slechts één keer zien. Zorg ervoor dat u deze kopieert.",
"shareAccessHint": "Iedereen met deze link heeft toegang tot de bron. Deel deze met zorg.",
@@ -219,12 +225,12 @@
"shareErrorSelectResource": "Selecteer een bron",
"proxyResourceTitle": "Openbare bronnen beheren",
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
"publicResourcesBannerTitle": "Web-gebaseerde Openbare Toegang",
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot privébronnen hoeven ze geen client-software te hebben en kunnen ze identiteit- en context bewuste toegangsmiddelen bevatten.",
"publicResourcesBannerTitle": "Webgebaseerde openbare bronnen",
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS proxy's die te bereiken zijn voor iedereen op het internet via een webbrowser. Het is mogelijk om deze te beveiligen door identiteitscontrole of een toegangsbeleid. In tegenstelling tot privébronnen hoeven ze geen clientsoftware te installeren.",
"clientResourceTitle": "Privébronnen beheren",
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Privé bronnen maken gebruik van zero-trust-beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot middelen die jij specifiek toestaat. Verbind gebruikersapparaten of machineclients om deze middelen te benaderen via een veilig virtueel privnetwerk.",
"privateResourcesBannerTitle": "Privébronnen via Zero Trust",
"privateResourcesBannerDescription": "Privébronnen maken gebruik van zero trust beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot resources die je specifiek toestaat. Verbind gebruikersapparaten of machines om deze middelen te benaderen via een veilig, virtueel privénetwerk.",
"resourcesSearch": "Zoek bronnen...",
"resourceAdd": "Bron toevoegen",
"resourceErrorDelte": "Fout bij verwijderen document",
@@ -253,7 +259,7 @@
"resourcePolicyUsersRolesDescription": "Bepaal welke gebruikers en rollen geassocieerde bronnen kunnen bezoeken",
"rulesResourcePolicyDescription": "Stel regels in om toegang te regelen tot bronnen die zijn gekoppeld aan dit beleid",
"authentication": "Authenticatie",
"protected": "Beschermd",
"protected": "Beveiligd",
"notProtected": "Niet beveiligd",
"resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.",
"resourceQuestionRemove": "Weet u zeker dat u het document van de organisatie wilt verwijderen?",
@@ -324,7 +330,7 @@
"labelEdit": "Label bewerken",
"editLabelDialogTitle": "Label bijwerken",
"editLabelDialogDescription": "Bewerk een nieuw label dat aan deze organisatie kan worden gekoppeld",
"labelDeleteConfirm": "Bevestigen Verwijderen Label",
"labelDeleteConfirm": "Label verwijderen",
"labelErrorDelete": "Kan label niet verwijderen",
"labelMessageRemove": "Deze handeling is definitief. Alle sites, bronnen en klanten met dit label zullen worden onttakeld.",
"labelQuestionRemove": "Weet u zeker dat u het label uit de organisatie wilt verwijderen?",
@@ -409,40 +415,40 @@
"years": "Jaar",
"day": "{count, plural, one {# dag} other {# dagen}}",
"apiKeysTitle": "API Key Informatie",
"apiKeysConfirmCopy2": "Bevestig dat u de API-sleutel hebt gekopieerd.",
"apiKeysErrorCreate": "Fout bij maken API-sleutel",
"apiKeysConfirmCopy2": "Bevestig dat u de API sleutel hebt gekopieerd.",
"apiKeysErrorCreate": "Fout bij maken API sleutel",
"apiKeysErrorSetPermission": "Fout instellen permissies",
"apiKeysCreate": "API-sleutel genereren",
"apiKeysCreateDescription": "Een nieuwe API-sleutel voor de organisatie genereren",
"apiKeysCreate": "API sleutel genereren",
"apiKeysCreateDescription": "Een nieuwe API sleutel voor de organisatie genereren",
"apiKeysGeneralSettings": "Machtigingen",
"apiKeysGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen",
"apiKeysList": "Nieuwe API-sleutel",
"apiKeysSave": "De API-sleutel opslaan",
"apiKeysGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen",
"apiKeysList": "Nieuwe API sleutel",
"apiKeysSave": "De API sleutel opslaan",
"apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plek.",
"apiKeysInfo": "De API-sleutel is:",
"apiKeysConfirmCopy": "Ik heb de API-sleutel gekopieerd",
"apiKeysInfo": "De API sleutel is:",
"apiKeysConfirmCopy": "Ik heb de API sleutel gekopieerd",
"generate": "Genereren",
"done": "Voltooid",
"apiKeysSeeAll": "Alle API-sleutels bekijken",
"apiKeysSeeAll": "Alle API sleutels bekijken",
"apiKeysPermissionsErrorLoadingActions": "Fout bij het laden van API key acties",
"apiKeysPermissionsErrorUpdate": "Fout instellen permissies",
"apiKeysPermissionsUpdated": "Permissies bijgewerkt",
"apiKeysPermissionsUpdatedDescription": "De bevoegdheden zijn bijgewerkt.",
"apiKeysPermissionsGeneralSettings": "Machtigingen",
"apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen",
"apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API sleutel kan doen",
"apiKeysPermissionsSave": "Rechten opslaan",
"apiKeysPermissionsTitle": "Machtigingen",
"apiKeys": "API sleutels",
"searchApiKeys": "API-sleutels zoeken...",
"apiKeysAdd": "API-sleutel genereren",
"apiKeysErrorDelete": "Fout bij verwijderen API-sleutel",
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API-sleutel",
"apiKeysQuestionRemove": "Weet u zeker dat u de API-sleutel van de organisatie wilt verwijderen?",
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de API-sleutel niet meer worden gebruikt.",
"apiKeysDeleteConfirm": "Bevestig Verwijderen API-sleutel",
"apiKeysDelete": "API-sleutel verwijderen",
"apiKeysManage": "API-sleutels beheren",
"apiKeysDescription": "API-sleutels worden gebruikt om te verifiëren met de integratie-API",
"searchApiKeys": "API sleutels zoeken...",
"apiKeysAdd": "API sleutel genereren",
"apiKeysErrorDelete": "Fout bij verwijderen API sleutel",
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API sleutel",
"apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?",
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de API sleutel niet meer worden gebruikt.",
"apiKeysDeleteConfirm": "Bevestig Verwijderen API sleutel",
"apiKeysDelete": "API sleutel verwijderen",
"apiKeysManage": "API sleutels beheren",
"apiKeysDescription": "API sleutels worden gebruikt om toegang te verifiëren met de integratie API ",
"provisioningKeysTitle": "Vertrekkende sleutel",
"provisioningKeysManage": "Beheren van Provisioning Sleutels",
"provisioningKeysDescription": "Provisionerende sleutels worden gebruikt om geautomatiseerde sitebepaling voor uw organisatie te verifiëren.",
@@ -906,7 +912,7 @@
"resourcesUsersRolesAccess": "Gebruiker en rol-gebaseerde toegangsbeheer",
"resourcesErrorUpdate": "Bron wisselen mislukt",
"resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document",
"access": "Toegangsrechten",
"access": "Link",
"accessControl": "Toegangs controle",
"shareLink": "{resource} Deelbare Link",
"resourceSelect": "Selecteer resource",
@@ -928,7 +934,7 @@
"newtVersion": "Versie",
"architecture": "Architectuur",
"sites": "Sites",
"siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.",
"siteWgAnyClients": "Gebruik een willekeurige WireGuard-client om verbinding te maken. U moet privébronnen benaderen met de peer IP.",
"siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients",
"siteWgManualConfigurationRequired": "Handmatige configuratie vereist",
"userErrorNotAdminOrOwner": "Gebruiker is geen beheerder of eigenaar",
@@ -1480,11 +1486,11 @@
"actionCheckOrgId": "ID controleren",
"actionCreateOrg": "Nieuwe organisatie aanmaken",
"actionDeleteOrg": "Verwijder organisatie",
"actionListApiKeys": "API-sleutels weergeven",
"actionListApiKeys": "API sleutels weergeven",
"actionListApiKeyActions": "Lijst van API Key Acties",
"actionSetApiKeyActions": "Stel API Key Toegestane Acties",
"actionCreateApiKey": "API-sleutel aanmaken",
"actionDeleteApiKey": "API-sleutel verwijderen",
"actionCreateApiKey": "API sleutel aanmaken",
"actionDeleteApiKey": "API sleutel verwijderen",
"actionCreateIdp": "IDP aanmaken",
"actionUpdateIdp": "IDP bijwerken",
"actionDeleteIdp": "Verwijder IDP",
@@ -1550,7 +1556,7 @@
"commandPaletteCreateProxyResource": "Maak Openbare Bron",
"commandPaletteCreatePrivateResource": "Maak Privébron",
"commandPaletteCreateUser": "Gebruiker aanmaken",
"commandPaletteCreateApiKey": "API-sleutel aanmaken",
"commandPaletteCreateApiKey": "API sleutel aanmaken",
"commandPaletteCreateMachineClient": "Machineclient aanmaken",
"commandPaletteCreateAlertRule": "Alarmregel aanmaken",
"commandPaletteCreateIdentityProvider": "Identiteitsprovider aanmaken",
@@ -1598,10 +1604,10 @@
"authErrorNoUpdate": "Geen authenticatie informatie om bij te werken",
"orgErrorNoUpdate": "Geen org om bij te werken",
"orgErrorNoProvided": "Geen org opgegeven",
"apiKeysErrorNoUpdate": "Geen API-sleutel om bij te werken",
"apiKeysErrorNoUpdate": "Geen API sleutel om bij te werken",
"sidebarOverview": "Overzicht.",
"sidebarHome": "Startpagina",
"sidebarSites": "Werkruimtes",
"sidebarSites": "Sites",
"sidebarApprovals": "Goedkeuringsverzoeken",
"sidebarResources": "Bronnen",
"sidebarProxyResources": "Openbaar",
@@ -1671,8 +1677,8 @@
"commandBilling": "Facturering",
"commandEnterpriseLicenses": "Licentie",
"commandSettings": "Instellingen",
"commandLauncher": "Startprogramma",
"commandResourceLauncher": "Bron Startprogramma",
"commandLauncher": "Startpagina",
"commandResourceLauncher": "Bron startpagina",
"commandSearchResults": "Zoekresultaten",
"alertingTitle": "Waarschuwingen",
"alertingDescription": "Definieer bronnen, triggers en acties voor meldingen",
@@ -1830,7 +1836,7 @@
"standaloneHcFilterSiteIdFallback": "Site {id}",
"standaloneHcFilterResourceIdFallback": "Bron {id}",
"blueprints": "Blauwdrukken",
"blueprintsLog": "Log Blueprints",
"blueprintsLog": "Log blauwafdrukken",
"blueprintsDescription": "Bekijk eerdere blauwdruktoepassingen en hun resultaten of pas een nieuwe blauwdruk toe",
"blueprintAdd": "Blauwdruk toevoegen",
"blueprintGoBack": "Bekijk alle Blauwdrukken",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdomein: {subdomain}",
"domainPickerNamespace": "Naamruimte: {namespace}",
"domainPickerShowMore": "Meer weergeven",
"domainPickerNoDomainsAvailableTitle": "Geen domeinen beschikbaar",
"domainPickerNoDomainsAvailableDescription": "Je hebt nog geen domeinen ingesteld. Maak een domein aan om door te gaan.",
"domainPickerNoDomainsAvailableAction": "Ga naar Domeinen",
"regionSelectorTitle": "Selecteer Regio",
"domainPickerRemoteExitNodeWarning": "Opgegeven domeinen worden niet ondersteund wanneer websites verbinding maken met externe sluitnodes. Gebruik in plaats daarvan een aangepast domein. Om bronnen beschikbaar te maken op externe nodes.",
"regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Openbaar",
"resourcesTableClientResources": "Privé",
"resourcesTableNoProxyResourcesFound": "Geen proxybronnen gevonden.",
"resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.",
"resourcesTableNoInternalResourcesFound": "Geen privébronnen gevonden.",
"resourcesTableDestination": "Bestemming",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Alias adres",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Annuleren",
"editInternalResourceDialogSaveResource": "Sla bron op",
"editInternalResourceDialogSuccess": "Succes",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne bron succesvol bijgewerkt",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Privébron succesvol bijgewerkt",
"editInternalResourceDialogError": "Fout",
"editInternalResourceDialogFailedToUpdateInternalResource": "Het bijwerken van de interne bron is mislukt",
"editInternalResourceDialogFailedToUpdateInternalResource": "Fout bij het bijwerken van de privébron",
"editInternalResourceDialogNameRequired": "Naam is verplicht",
"editInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens",
"editInternalResourceDialogProxyPortMin": "Proxy poort moet minstens 1 zijn",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.",
"createInternalResourceDialogNoSitesAvailable": "Geen sites beschikbaar",
"createInternalResourceDialogNoSitesAvailableDescription": "U moet ten minste één Newt-site hebben met een geconfigureerd subnet om interne bronnen aan te maken.",
"createInternalResourceDialogNoSitesAvailableDescription": "U moet ten minste één Newt-site hebben met een geconfigureerd subnet om privébronnen aan te maken.",
"createInternalResourceDialogClose": "Sluiten",
"createInternalResourceDialogCreateClientResource": "Privé bron maken",
"createInternalResourceDialogCreateClientResourceDescription": "Maak een nieuwe bron aan die alleen toegankelijk is voor klanten die verbonden zijn met de organisatie",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Annuleren",
"createInternalResourceDialogCreateResource": "Bron aanmaken",
"createInternalResourceDialogSuccess": "Succes",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne bron succesvol aangemaakt",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Privébron succesvol aangemaakt",
"createInternalResourceDialogError": "Fout",
"createInternalResourceDialogFailedToCreateInternalResource": "Het aanmaken van de interne bron is mislukt",
"createInternalResourceDialogFailedToCreateInternalResource": "Fout bij het aanmaken van de privébron",
"createInternalResourceDialogNameRequired": "Naam is verplicht",
"createInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens",
"createInternalResourceDialogPleaseSelectSite": "Selecteer alstublieft een site",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Bron adres",
"destinationAddress": "Adres bestemming",
"duration": "Duur",
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie of <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is vereist om deze functie te gebruiken. <bookADemoLink>Boek een demo of POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Boek een demo of POC trial</bookADemoLink>.",
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie of <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is vereist om deze functie te gebruiken. <bookADemoLink>Boek een gratis demo of POC trial om meer te leren.</bookADemoLink>",
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Boek een gratis demo of POC trial om meer te leren.</bookADemoLink>",
"certResolver": "Certificaat Resolver",
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
"selectCertResolver": "Certificaat Resolver selecteren",
@@ -3311,7 +3320,7 @@
"internalResourceAuthDaemonPort": "Daemon poort (optioneel)",
"orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?",
"learnMore": "Meer informatie",
"backToHome": "Ga terug naar startpagina",
"backToHome": "Ga terug naar de startpagina",
"needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?",
"maintenanceMode": "Onderhoudspagina",
"maintenanceModeDescription": "Toon een onderhoudspagina aan bezoekers",
@@ -3524,7 +3533,7 @@
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
"httpDestAuthBearerTitle": "Betere Token",
"httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '<token>' header toe aan elk verzoek.",
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
"httpDestAuthBearerPlaceholder": "Uw API sleutel of token",
"httpDestAuthBasicTitle": "Basis authenticatie",
"httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.",
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
@@ -3678,7 +3687,7 @@
"memberPortalResourceDisabled": "Bron Uitgeschakeld",
"memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen",
"resourceLauncherTitle": "Bron Launcher",
"resourceSidebarLauncherTitle": "Startprogramma",
"resourceSidebarLauncherTitle": "Startpagina",
"resourceLauncherDescription": "Bekijk alle beschikbare bronnen en start ze vanuit één centraal punt",
"resourceLauncherSearchPlaceholder": "Zoek naar bronnen...",
"resourceLauncherDefaultView": "Standaard",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Potwierdź usunięcie linku do udostępnienia",
"shareQuestionRemove": "Czy na pewno chcesz usunąć ten link udostępniania?",
"shareMessageRemove": "Po usunięciu, link przestanie działać i wszyscy korzystający z niego stracą dostęp do zasobu.",
"shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.",
"shareTokenDescription": "Token dostępu można przekaz jako parametr zapytania lub w nagłówkach żądania. Domyślnie musi być wysyłany w każdym żądaniu. Jeśli trwałość sesji jest włączona, pierwsze żądanie wymienia go na ciasteczko sesji.",
"accessToken": "Token dostępu",
"usageExamples": "Przykłady użycia",
"tokenId": "Identyfikator tokena",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Tytuł (opcjonalnie)",
"sharePathOptional": "Ścieżka (opcjonalnie)",
"sharePathDescription": "Link przekieruje użytkowników do tej ścieżki po uwierzytelnieniu.",
"shareAssociateUserOptional": "Powiąż użytkownika (opcjonalnie)",
"shareAssociateUserDescription": "Po ustawieniu, żądania korzystające z tego linku są przypisywane do użytkownika w logach dostępu i nagłówkach tożsamości. Link jest usuwany, jeśli użytkownik opuszcza organizację.",
"userSelect": "Wybierz użytkownika",
"usersNotFound": "Nie znaleziono użytkowników",
"expireIn": "Wygasa za",
"neverExpire": "Nigdy nie wygasa",
"sharePersistSession": "Utrzymaj sesję po pierwszym użyciu",
"sharePersistSessionDescription": "Gdy ta opcja jest włączona, pierwsze żądanie z tym tokenem przez parametr zapytania lub nagłówek ustawia ciasteczko sesji, dzięki czemu późniejsze żądania nie wymagają tokena. Pomijaj dla klienta API, który powinien wysyłać token w każdym żądaniu.",
"shareExpireDescription": "Czas wygaśnięcia to jak długo link będzie mógł być użyty i zapewni dostęp do zasobu. Po tym czasie link nie będzie już działał, a użytkownicy, którzy użyli tego linku, utracą dostęp do zasobu.",
"shareSeeOnce": "Możesz zobaczyć ten link tylko raz. Pamiętaj, aby go skopiować.",
"shareAccessHint": "Każdy z tym linkiem może uzyskać dostęp do zasobu. Podziel się nim ostrożnie.",
@@ -928,7 +934,7 @@
"newtVersion": "Wersja",
"architecture": "Architektura",
"sites": "Witryny",
"siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połącz. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.",
"siteWgAnyClients": "Użyj dowolnego klienta WireGuard do połączenia. Będziesz musiał adresować prywatne zasoby używając IP współpracownika.",
"siteWgCompatibleAllClients": "Kompatybilny ze wszystkimi klientami WireGuard",
"siteWgManualConfigurationRequired": "Wymagana konfiguracja ręczna",
"userErrorNotAdminOrOwner": "Użytkownik nie jest administratorem ani właścicielem",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdomena: {subdomain}",
"domainPickerNamespace": "Przestrzeń nazw: {namespace}",
"domainPickerShowMore": "Pokaż więcej",
"domainPickerNoDomainsAvailableTitle": "Brak dostępnych domen",
"domainPickerNoDomainsAvailableDescription": "Nie masz jeszcze skonfigurowanych żadnych domen. Utwórz domenę, aby kontynuować.",
"domainPickerNoDomainsAvailableAction": "Przejdź do Domena",
"regionSelectorTitle": "Wybierz region",
"domainPickerRemoteExitNodeWarning": "Podane domeny nie są obsługiwane, gdy witryny łączą się ze zdalnymi węzłami wyjścia. Aby zasoby były dostępne w węzłach zdalnych, użyj domeny niestandardowej.",
"regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Publiczne",
"resourcesTableClientResources": "Prywatny",
"resourcesTableNoProxyResourcesFound": "Nie znaleziono zasobów proxy.",
"resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.",
"resourcesTableNoInternalResourcesFound": "Nie znaleziono prywatnych zasobów.",
"resourcesTableDestination": "Miejsce docelowe",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Adres aliasu",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Anuluj",
"editInternalResourceDialogSaveResource": "Zapisz zasób",
"editInternalResourceDialogSuccess": "Sukces",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Wewnętrzny zasób zaktualizowany pomyślnie",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Prywatny zasób został pomyślnie zaktualizowany",
"editInternalResourceDialogError": "Błąd",
"editInternalResourceDialogFailedToUpdateInternalResource": "Nie udało się zaktualizować wewnętrznego zasobu",
"editInternalResourceDialogFailedToUpdateInternalResource": "Nie udało się zaktualizować prywatnego zasobu",
"editInternalResourceDialogNameRequired": "Nazwa jest wymagana",
"editInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków",
"editInternalResourceDialogProxyPortMin": "Port proxy musi wynosić przynajmniej 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.",
"createInternalResourceDialogNoSitesAvailable": "Brak dostępnych stron",
"createInternalResourceDialogNoSitesAvailableDescription": "Musisz mieć co najmniej jedną stronę Newt z skonfigurowanym podsiecią, aby tworzyć wewnętrzne zasoby.",
"createInternalResourceDialogNoSitesAvailableDescription": "Musisz mieć co najmniej jedną lokalizację Newt z skonfigurowaną podsiecią, aby tworzyć prywatne zasoby.",
"createInternalResourceDialogClose": "Zamknij",
"createInternalResourceDialogCreateClientResource": "Utwórz zasób prywatny",
"createInternalResourceDialogCreateClientResourceDescription": "Utwórz nowy zasób, który będzie dostępny tylko dla klientów podłączonych do organizacji",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Anuluj",
"createInternalResourceDialogCreateResource": "Utwórz zasób",
"createInternalResourceDialogSuccess": "Sukces",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Wewnętrzny zasób utworzony pomyślnie",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Prywatny zasób został pomyślnie utworzony",
"createInternalResourceDialogError": "Błąd",
"createInternalResourceDialogFailedToCreateInternalResource": "Nie udało się utworzyć wewnętrznego zasobu",
"createInternalResourceDialogFailedToCreateInternalResource": "Nie udało się utworzyć prywatnego zasobu",
"createInternalResourceDialogNameRequired": "Nazwa jest wymagana",
"createInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków",
"createInternalResourceDialogPleaseSelectSite": "Proszę wybrać stronę",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Adres źródłowy",
"destinationAddress": "Adres docelowy",
"duration": "Czas trwania",
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lub <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> . <bookADemoLink>Zarezerwuj wersję demonstracyjną lub wersję prób POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Zarezerwuj demo lub okres próbny POC</bookADemoLink>.",
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lub <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Zarezerwuj darmową wersję demo lub próbę POC, aby dowiedzieć się więcej.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Zarezerwuj darmową wersję demo lub próbę POC, aby dowiedzieć się więcej.</bookADemoLink>",
"certResolver": "Rozwiązywanie certyfikatów",
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
"selectCertResolver": "Wybierz Resolver certyfikatów",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Confirmar exclusão do Link Compartilhável",
"shareQuestionRemove": "Tem certeza de que deseja excluir este link de compartilhamento?",
"shareMessageRemove": "Uma vez excluído, o link não funcionará mais e qualquer pessoa que o utilizar perderá o acesso ao recurso.",
"shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.",
"shareTokenDescription": "O token de acesso pode ser passado como um parâmetro de consulta ou nos cabeçalhos da solicitação. Por padrão, ele deve ser enviado em todas as solicitações. Se a persistência da sessão estiver ativada, a primeira solicitação o troca por um cookie de sessão.",
"accessToken": "Token de acesso",
"usageExamples": "Exemplos de uso",
"tokenId": "ID do Token",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Título (opcional)",
"sharePathOptional": "Caminho (opcional)",
"sharePathDescription": "O link redirecionará os usuários para este caminho após a autenticação.",
"shareAssociateUserOptional": "Associar Usuário (opcional)",
"shareAssociateUserDescription": "Quando definido, as solicitações usando este link são atribuídas ao usuário nos registros de acesso e cabeçalhos de identidade. O link é removido se o usuário sair da organização.",
"userSelect": "Selecionar um usuário",
"usersNotFound": "Nenhum usuário encontrado",
"expireIn": "Expira em",
"neverExpire": "Nunca expirar",
"sharePersistSession": "Persistir sessão após o primeiro uso",
"sharePersistSessionDescription": "Quando ativado, a primeira solicitação com este token por meio de um parâmetro de consulta ou cabeçalho define um cookie de sessão, para que solicitações posteriores não precisem do token. Mantenha desativado para clientes de API que devem enviar o token em todas as solicitações.",
"shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os utilizadores que usaram este link perderão acesso ao recurso.",
"shareSeeOnce": "Você só poderá ver este link uma vez. Certifique-se de copiá-lo.",
"shareAccessHint": "Qualquer um com este link pode aceder o recurso. Compartilhe com cuidado.",
@@ -928,7 +934,7 @@
"newtVersion": "Versão",
"architecture": "Arquitetura",
"sites": "sites",
"siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.",
"siteWgAnyClients": "Use qualquer cliente WireGuard para conectar-se. Você terá que endereçar recursos privados usando o IP do par.",
"siteWgCompatibleAllClients": "Compatível com todos os clientes WireGuard",
"siteWgManualConfigurationRequired": "Configuração manual necessária",
"userErrorNotAdminOrOwner": "Usuário não é administrador ou proprietário",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Subdomínio: {subdomain}",
"domainPickerNamespace": "Namespace: {namespace}",
"domainPickerShowMore": "Mostrar Mais",
"domainPickerNoDomainsAvailableTitle": "Nenhum domínio disponível",
"domainPickerNoDomainsAvailableDescription": "Você ainda não configurou nenhum domínio. Crie um domínio para continuar.",
"domainPickerNoDomainsAvailableAction": "Ir para Domínios",
"regionSelectorTitle": "Selecionar Região",
"domainPickerRemoteExitNodeWarning": "Domínios fornecidos não são suportados quando os sites se conectam a nós de saída remota. Para recursos disponíveis em nós remotos, use um domínio personalizado.",
"regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Público",
"resourcesTableClientResources": "Privado",
"resourcesTableNoProxyResourcesFound": "Nenhum recurso de proxy encontrado.",
"resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.",
"resourcesTableNoInternalResourcesFound": "Nenhum recurso privado encontrado.",
"resourcesTableDestination": "Destino",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Endereço do Pseudônimo",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Cancelar",
"editInternalResourceDialogSaveResource": "Guardar Recurso",
"editInternalResourceDialogSuccess": "Sucesso",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno atualizado com sucesso",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso privado atualizado com sucesso",
"editInternalResourceDialogError": "Erro",
"editInternalResourceDialogFailedToUpdateInternalResource": "Falha ao atualizar recurso interno",
"editInternalResourceDialogFailedToUpdateInternalResource": "Falha ao atualizar recurso privado",
"editInternalResourceDialogNameRequired": "Nome é obrigatório",
"editInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres",
"editInternalResourceDialogProxyPortMin": "Porta de proxy deve ser pelo menos 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.",
"createInternalResourceDialogNoSitesAvailable": "Nenhum Site Disponível",
"createInternalResourceDialogNoSitesAvailableDescription": "Você precisa ter pelo menos um site Newt com uma sub-rede configurada para criar recursos internos.",
"createInternalResourceDialogNoSitesAvailableDescription": "Você precisa ter pelo menos um site Newt com uma sub-rede configurada para criar recursos privados.",
"createInternalResourceDialogClose": "Fechar",
"createInternalResourceDialogCreateClientResource": "Criar Recurso Privado",
"createInternalResourceDialogCreateClientResourceDescription": "Criar um novo recurso que só será acessível para clientes conectados à organização",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Cancelar",
"createInternalResourceDialogCreateResource": "Criar Recurso",
"createInternalResourceDialogSuccess": "Sucesso",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno criado com sucesso",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso privado criado com sucesso",
"createInternalResourceDialogError": "Erro",
"createInternalResourceDialogFailedToCreateInternalResource": "Falha ao criar recurso interno",
"createInternalResourceDialogFailedToCreateInternalResource": "Falha ao criar recurso privado",
"createInternalResourceDialogNameRequired": "Nome é obrigatório",
"createInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres",
"createInternalResourceDialogPleaseSelectSite": "Por favor, selecione um site",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Endereço de origem",
"destinationAddress": "Endereço de destino",
"duration": "Duração",
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> é necessária para usar este recurso. <bookADemoLink>Reserve um teste de demonstração ou POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserve uma demonstração ou avaliação POC</bookADemoLink>.",
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> ou <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> é necessária para usar este recurso. <bookADemoLink>Reserve um teste de demonstração ou POC para saber mais.</bookADemoLink>",
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Reserve uma demonstração ou avaliação POC para saber mais.</bookADemoLink>",
"certResolver": "Resolvedor de Certificado",
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
"selectCertResolver": "Selecionar solucionador de certificado",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Подтвердить удаление общей ссылки",
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
"shareTokenDescription": "Токен доступа может быть передан в виде параметра запроса или в заголовках запроса. По умолчанию, он должен отправляться при каждом запросе. Если включена устойчивость сеанса, первый запрос обменяет его на сеансовый файл cookie.",
"accessToken": "Токен доступа",
"usageExamples": "Примеры использования",
"tokenId": "ID токена",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Заголовок (необязательно)",
"sharePathOptional": "Путь (необязательно)",
"sharePathDescription": "Ссылка перенаправит пользователей на этот путь после аутентификации.",
"shareAssociateUserOptional": "Связать пользователя (необязательно)",
"shareAssociateUserDescription": "При установке, запросы с этой ссылкой записываются за пользователем в журналах доступа и заголовках идентификации. Ссылка удаляется, если пользователь покидает организацию.",
"userSelect": "Выберите пользователя",
"usersNotFound": "Пользователи не найдены",
"expireIn": "Срок действия",
"neverExpire": "Бессрочный доступ",
"sharePersistSession": "Сохранять сеанс после первого использования",
"sharePersistSessionDescription": "При включении, первый запрос с этим токеном через параметр запроса или заголовок устанавливает сеансовый файл cookie, так что последующие запросы не требуют токена. Оставьте для API-клиентов, которые должны отправлять токен при каждом запросе.",
"shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.",
"shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте ее.",
"shareAccessHint": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу. Делитесь ею с осторожностью.",
@@ -928,7 +934,7 @@
"newtVersion": "Версия",
"architecture": "Архитектура",
"sites": "Сайты",
"siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.",
"siteWgAnyClients": "Используйте любой клиент WireGuard для подключения. Вам придётся обращаться к частным ресурсам, используя IP узла.",
"siteWgCompatibleAllClients": "Совместим со всеми клиентами WireGuard",
"siteWgManualConfigurationRequired": "Требуется ручная настройка",
"userErrorNotAdminOrOwner": "Пользователь не является администратором или владельцем",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Поддомен: {subdomain}",
"domainPickerNamespace": "Пространство имен: {namespace}",
"domainPickerShowMore": "Показать еще",
"domainPickerNoDomainsAvailableTitle": "Нет доступных доменов",
"domainPickerNoDomainsAvailableDescription": "У вас еще не настроены домены. Создайте домен, чтобы продолжить.",
"domainPickerNoDomainsAvailableAction": "Перейти к доменам",
"regionSelectorTitle": "Выберите регион",
"domainPickerRemoteExitNodeWarning": "Предоставленные домены не поддерживаются при подключении сайтов к удаленным узлам. Для доступа к ресурсам на удаленных узлах используйте пользовательский домен.",
"regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Публичный",
"resourcesTableClientResources": "Приватный",
"resourcesTableNoProxyResourcesFound": "Проксированных ресурсов не найдено.",
"resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.",
"resourcesTableNoInternalResourcesFound": "Частные ресурсы не найдены.",
"resourcesTableDestination": "Пункт назначения",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "Псевдоним адреса",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "Отмена",
"editInternalResourceDialogSaveResource": "Сохранить ресурс",
"editInternalResourceDialogSuccess": "Успешно",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Внутренний ресурс успешно обновлен",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Частный ресурс успешно обновлен",
"editInternalResourceDialogError": "Ошибка",
"editInternalResourceDialogFailedToUpdateInternalResource": "Не удалось обновить внутренний ресурс",
"editInternalResourceDialogFailedToUpdateInternalResource": "Не удалось обновить частный ресурс",
"editInternalResourceDialogNameRequired": "Имя обязательно",
"editInternalResourceDialogNameMaxLength": "Имя не должно быть длиннее 255 символов",
"editInternalResourceDialogProxyPortMin": "Порт прокси должен быть не менее 1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
"createInternalResourceDialogNoSitesAvailable": "Нет доступных сайтов",
"createInternalResourceDialogNoSitesAvailableDescription": "Вам необходимо иметь хотя бы один сайт Newt с настроенной подсетью для создания внутреннего ресурса.",
"createInternalResourceDialogNoSitesAvailableDescription": "Вам необходимо иметь хотя бы один сайт Newt с настроенной подсетью для создания частных ресурсов.",
"createInternalResourceDialogClose": "Закрыть",
"createInternalResourceDialogCreateClientResource": "Создать приватный ресурс",
"createInternalResourceDialogCreateClientResourceDescription": "Создать новый ресурс, который будет доступен только клиентам, подключенным к организации",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "Отмена",
"createInternalResourceDialogCreateResource": "Создать ресурс",
"createInternalResourceDialogSuccess": "Успешно",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Внутренний ресурс успешно создан",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Частный ресурс успешно создан",
"createInternalResourceDialogError": "Ошибка",
"createInternalResourceDialogFailedToCreateInternalResource": "Не удалось создать внутренний ресурс",
"createInternalResourceDialogFailedToCreateInternalResource": "Не удалось создать частный ресурс",
"createInternalResourceDialogNameRequired": "Имя обязательно",
"createInternalResourceDialogNameMaxLength": "Имя должно содержать менее 255 символов",
"createInternalResourceDialogPleaseSelectSite": "Пожалуйста, выберите сайт",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Адрес источника",
"destinationAddress": "Адрес назначения",
"duration": "Продолжительность",
"licenseRequiredToUse": "Требуется лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> для использования этой функции. <bookADemoLink>Забронируйте демонстрацию или пробный POC</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Забронируйте демонстрацию или пробный POC</bookADemoLink>.",
"licenseRequiredToUse": "Требуется лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> или <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> для использования этой функции. <bookADemoLink>Забронируйте демонстрацию или пробный POC, чтобы узнать больше.</bookADemoLink>",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Забронируйте демонстрацию или пробный POC, чтобы узнать больше.</bookADemoLink>",
"certResolver": "Резольвер сертификата",
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
"selectCertResolver": "Выберите резолвер сертификата",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Paylaşılabilir Bağlantıyı Silmeyi Onayla",
"shareQuestionRemove": "Bu paylaşım bağlantısını silmek istediğinizden emin misiniz?",
"shareMessageRemove": "Silindikten sonra, bağlantı artık çalışmayacak ve kullanan herkes kaynağa erişimini kaybedecek.",
"shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.",
"shareTokenDescription": "Erişim belirteci bir sorgu parametresi olarak veya istek başlıkları içinden gönderilebilir. Varsayılan olarak her istekte gönderilmelidir. Oturum kalıcılığı etkinse, ilk istek oturum çereziyle değiştirilir.",
"accessToken": "Erişim Jetonu",
"usageExamples": "Kullanım Örnekleri",
"tokenId": "Jeton ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Başlık (isteğe bağlı)",
"sharePathOptional": "Yol (isteğe bağlı)",
"sharePathDescription": "Bağlantıdan sonra kullanıcıları bu yola yönlendirecek bağlantıyı tanımlayın.",
"shareAssociateUserOptional": "Kullanıcıyla İlişkilendir (isteğe bağlı)",
"shareAssociateUserDescription": "Ayarladığında, bu bağlantıyı kullanan istekler erişim günlüklerinde ve kimlik başlıklarında kullanıcıya atanır. Kullanıcı kuruluşu terk ederse bağlantı kaldırılır.",
"userSelect": "Kullanıcı seçin",
"usersNotFound": "Kullanıcı bulunamadı",
"expireIn": "Süresi Dolacak",
"neverExpire": "Hiçbir Zaman Sona Ermez",
"sharePersistSession": "İlk kullanım sonrası oturumu kalıcı yap",
"sharePersistSessionDescription": "Etkinleştirildiğinde, bu belirteçle yapılan ilk istek, sorgu parametresi veya başlık üzerinden, bir oturum çerezi ayarlayarak sonraki isteklerde belirteç gerektirmeyecek. Her istek için belirteç göndermesi gereken API müşterileri için bırakın.",
"shareExpireDescription": "Son kullanma süresi, bağlantının kullanılabilir ve kaynağa erişim sağlayacak süresidir. Bu süreden sonra bağlantı çalışmayı durduracak ve bu bağlantıyı kullanan kullanıcılar kaynağa erişimini kaybedecektir.",
"shareSeeOnce": "Bu bağlantıyı yalnızca bir kez görebileceksiniz. Kopyaladığınızdan emin olun.",
"shareAccessHint": "Bu bağlantıya sahip olan herkes kaynağa erişebilir. Dikkatle paylaşın.",
@@ -928,7 +934,7 @@
"newtVersion": "Sürüm",
"architecture": "Mimari",
"sites": "Siteler",
"siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.",
"siteWgAnyClients": "Bağlanmak için herhangi bir WireGuard istemcisi kullanın. Özel kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.",
"siteWgCompatibleAllClients": "Tüm WireGuard istemcileriyle uyumlu",
"siteWgManualConfigurationRequired": "Manuel yapılandırma gerekli",
"userErrorNotAdminOrOwner": "Kullanıcı yönetici veya sahibi değil",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "Alt Alan: {subdomain}",
"domainPickerNamespace": "Ad Alanı: {namespace}",
"domainPickerShowMore": "Daha Fazla Göster",
"domainPickerNoDomainsAvailableTitle": "Kullanılacak alan adı yok",
"domainPickerNoDomainsAvailableDescription": "Henüz ayarlanmış bir alan adınız yok. Devam etmek için bir alan adı oluşturun.",
"domainPickerNoDomainsAvailableAction": "Alan Adlarına Git",
"regionSelectorTitle": "Bölge Seç",
"domainPickerRemoteExitNodeWarning": "Belirtilen alan adları, siteler uzak çıkış düğümlerine bağlandığında desteklenmez. Kaynakların uzak düğümlerde kullanılabilir olması için özel bir alan adı kullanın.",
"regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "Herkese Açık",
"resourcesTableClientResources": "Özel",
"resourcesTableNoProxyResourcesFound": "Hiçbir proxy kaynağı bulunamadı.",
"resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.",
"resourcesTableNoInternalResourcesFound": "Özel kaynak bulunamadı.",
"resourcesTableDestination": "Hedef",
"resourcesTableAlias": "Takma Ad",
"resourcesTableAliasAddress": "Alias Adresi",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "İptal",
"editInternalResourceDialogSaveResource": "Kaynağı Kaydet",
"editInternalResourceDialogSuccess": "Başarı",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Dahili kaynak başarıyla güncellendi",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Özel kaynak başarıyla güncellendi",
"editInternalResourceDialogError": "Hata",
"editInternalResourceDialogFailedToUpdateInternalResource": "Dahili kaynak güncellenemedi",
"editInternalResourceDialogFailedToUpdateInternalResource": "Özel kaynak güncellenemedi",
"editInternalResourceDialogNameRequired": "Ad gerekli",
"editInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır",
"editInternalResourceDialogProxyPortMin": "Proxy bağlantı noktası en az 1 olmalıdır",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Takma Ad",
"editInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.",
"createInternalResourceDialogNoSitesAvailable": "Site Bulunamadı",
"createInternalResourceDialogNoSitesAvailableDescription": "Dahili kaynak oluşturmak için en az bir Newt sitesine ve alt ağa sahip olmalısınız.",
"createInternalResourceDialogNoSitesAvailableDescription": "Özel kaynaklar oluşturmak için alt ağı yapılandırılmış en az bir Newt sitesine sahip olmalısınız.",
"createInternalResourceDialogClose": "Kapat",
"createInternalResourceDialogCreateClientResource": "Özel Kaynak Oluştur",
"createInternalResourceDialogCreateClientResourceDescription": "Seçilen siteye bağlı istemcilere erişilebilir olacak yeni bir kaynak oluşturun",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "İptal",
"createInternalResourceDialogCreateResource": "Kaynak Oluştur",
"createInternalResourceDialogSuccess": "Başarı",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Dahili kaynak başarıyla oluşturuldu",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "Özel kaynak başarıyla oluşturuldu",
"createInternalResourceDialogError": "Hata",
"createInternalResourceDialogFailedToCreateInternalResource": "Dahili kaynak oluşturulamadı",
"createInternalResourceDialogFailedToCreateInternalResource": "Özel kaynak oluşturulamadı",
"createInternalResourceDialogNameRequired": "Ad gerekli",
"createInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır",
"createInternalResourceDialogPleaseSelectSite": "Lütfen bir site seçin",
@@ -3089,8 +3098,8 @@
"sourceAddress": "Kaynak Adresi",
"destinationAddress": "Hedef Adresi",
"duration": "Süre",
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı veya <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> gereklidir. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>da da mevcuttur. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>.",
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı veya <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> gereklidir. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>",
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur. <bookADemoLink>Tanıtım veya POC denemesi ayarlayın</bookADemoLink>",
"certResolver": "Sertifika Çözücü",
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
"selectCertResolver": "Sertifika Çözücü Seçin",
+19 -10
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "确认删除共享链接",
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
"shareTokenDescription": "访问令牌可以作为查询参数或请求头传递。默认情况下,它必须在每次请求时发送。如果启用了会话持久性,则第一个请求会将其交换为会话cookie。",
"accessToken": "访问令牌",
"usageExamples": "用法示例",
"tokenId": "令牌 ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "标题 (可选)",
"sharePathOptional": "路径(可选)",
"sharePathDescription": "认证后,链接将把用户重定向到此路径。",
"shareAssociateUserOptional": "关联用户(可选)",
"shareAssociateUserDescription": "设置后,使用此链接的请求将在访问日志和身份头中归属于用户。如果用户离开组织,则该链接将被删除。",
"userSelect": "选择用户",
"usersNotFound": "未找到用户",
"expireIn": "过期时间",
"neverExpire": "永不过期",
"sharePersistSession": "首次使用后会话持久化",
"sharePersistSessionDescription": "启用后,通过查询参数或头部携带此令牌的首次请求会设置会话cookie,因此后续请求不需要令牌。对于应在每次请求中发送令牌的API客户端,请保持关闭。",
"shareExpireDescription": "过期时间是链接可以使用并提供对资源的访问时间。 此时间后,链接将不再工作,使用此链接的用户将失去对资源的访问。",
"shareSeeOnce": "您只能看到一次此链接。请确保复制它。",
"shareAccessHint": "任何具有此链接的人都可以访问该资源。小心地分享它。",
@@ -928,7 +934,7 @@
"newtVersion": "版本",
"architecture": "架构",
"sites": "站点",
"siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。",
"siteWgAnyClients": "使用任何WireGuard客户端进行连接。您必须使用对等IP来处理私有资源。",
"siteWgCompatibleAllClients": "与所有WireGuard客户端兼容",
"siteWgManualConfigurationRequired": "需要手动配置",
"userErrorNotAdminOrOwner": "用户不是管理员或所有者",
@@ -1989,6 +1995,9 @@
"domainPickerSubdomain": "子域:{subdomain}",
"domainPickerNamespace": "命名空间:{namespace}",
"domainPickerShowMore": "显示更多",
"domainPickerNoDomainsAvailableTitle": "无可用域",
"domainPickerNoDomainsAvailableDescription": "您还没有设置任何域。创建一个域以继续。",
"domainPickerNoDomainsAvailableAction": "转到域",
"regionSelectorTitle": "选择区域",
"domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。",
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
@@ -2352,7 +2361,7 @@
"resourcesTableProxyResources": "",
"resourcesTableClientResources": "私有资源",
"resourcesTableNoProxyResourcesFound": "未找到代理资源。",
"resourcesTableNoInternalResourcesFound": "未找到内部资源。",
"resourcesTableNoInternalResourcesFound": "未找到私有资源。",
"resourcesTableDestination": "目标",
"resourcesTableAlias": "Alias",
"resourcesTableAliasAddress": "别名地址",
@@ -2375,9 +2384,9 @@
"editInternalResourceDialogCancel": "取消",
"editInternalResourceDialogSaveResource": "保存资源",
"editInternalResourceDialogSuccess": "成功",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "内部资源更新成功",
"editInternalResourceDialogInternalResourceUpdatedSuccessfully": "私有资源更新成功",
"editInternalResourceDialogError": "错误",
"editInternalResourceDialogFailedToUpdateInternalResource": "更新内部资源失败",
"editInternalResourceDialogFailedToUpdateInternalResource": "更新私有资源失败",
"editInternalResourceDialogNameRequired": "名称为必填项",
"editInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符",
"editInternalResourceDialogProxyPortMin": "代理端口必须至少为1",
@@ -2403,7 +2412,7 @@
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
"createInternalResourceDialogNoSitesAvailable": "暂无可用站点",
"createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一个子网的Newt站点来创建内部资源。",
"createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一个子网的Newt站点来创建私有资源。",
"createInternalResourceDialogClose": "关闭",
"createInternalResourceDialogCreateClientResource": "创建私有资源",
"createInternalResourceDialogCreateClientResourceDescription": "创建一个新资源只能为连接到组织的客户端访问",
@@ -2432,9 +2441,9 @@
"createInternalResourceDialogCancel": "取消",
"createInternalResourceDialogCreateResource": "创建资源",
"createInternalResourceDialogSuccess": "成功",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "内部资源创建成功",
"createInternalResourceDialogInternalResourceCreatedSuccessfully": "私有资源创建成功",
"createInternalResourceDialogError": "错误",
"createInternalResourceDialogFailedToCreateInternalResource": "创建内部资源失败",
"createInternalResourceDialogFailedToCreateInternalResource": "创建私有资源失败",
"createInternalResourceDialogNameRequired": "名称为必填项",
"createInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符",
"createInternalResourceDialogPleaseSelectSite": "请选择一个站点",
@@ -3089,8 +3098,8 @@
"sourceAddress": "源地址",
"destinationAddress": "目的地址",
"duration": "期限",
"licenseRequiredToUse": "使用此功能需要<enterpriseLicenseLink>企业版</enterpriseLicenseLink>许可证或<pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>。<bookADemoLink>预约演示或POC试用</bookADemoLink>",
"ossEnterpriseEditionRequired": "需要 <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 才能使用此功能。 此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>上获取。 <bookADemoLink>预演示或POC 试用</bookADemoLink>",
"licenseRequiredToUse": "使用此功能需要<enterpriseLicenseLink>企业版</enterpriseLicenseLink>许可证或<pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>。<bookADemoLink>预约演示或POC试用以了解更多。</bookADemoLink>",
"ossEnterpriseEditionRequired": "需要<enterpriseEditionLink>企业版</enterpriseEditionLink>才能使用此功能。此功能也可在<pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>上获取。<bookADemoLink>预演示或POC试用以了解更多。</bookADemoLink>",
"certResolver": "证书解决器",
"certResolverDescription": "选择用于此资源的证书解析器。",
"selectCertResolver": "选择证书解析",
+311 -60
View File
@@ -51,7 +51,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.18.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -65,17 +65,18 @@
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"glob": "13.0.6",
"gpt-tokenizer": "^3.4.0",
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.2.0",
"js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.6",
"next": "16.2.11",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
@@ -2574,9 +2575,9 @@
}
},
"node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz",
"integrity": "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -2590,9 +2591,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.11.tgz",
"integrity": "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw==",
"cpu": [
"arm64"
],
@@ -2606,9 +2607,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.11.tgz",
"integrity": "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg==",
"cpu": [
"x64"
],
@@ -2622,12 +2623,15 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.11.tgz",
"integrity": "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2638,12 +2642,15 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.11.tgz",
"integrity": "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2654,12 +2661,15 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.11.tgz",
"integrity": "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2670,12 +2680,15 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.11.tgz",
"integrity": "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2686,9 +2699,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.11.tgz",
"integrity": "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g==",
"cpu": [
"arm64"
],
@@ -2702,9 +2715,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.11.tgz",
"integrity": "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w==",
"cpu": [
"x64"
],
@@ -6460,6 +6473,225 @@
"next": "16.2.6"
}
},
"node_modules/@react-email/ui/node_modules/@next/env": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz",
"integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==",
"dev": true,
"license": "MIT"
},
"node_modules/@react-email/ui/node_modules/@next/swc-darwin-arm64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz",
"integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-darwin-x64": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz",
"integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz",
"integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz",
"integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz",
"integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz",
"integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz",
"integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz",
"integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@react-email/ui/node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@react-email/ui/node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/env": "16.2.6",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/@react-types/shared": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz",
@@ -9078,9 +9310,9 @@
}
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -9214,20 +9446,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -9237,6 +9469,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -11925,9 +12170,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true,
"funding": [
{
@@ -12516,6 +12761,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gpt-tokenizer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz",
"integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==",
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -13389,9 +13640,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
@@ -14349,12 +14600,12 @@
}
},
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
"integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==",
"version": "16.2.11",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.11.tgz",
"integrity": "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.6",
"@next/env": "16.2.11",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -14368,14 +14619,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.6",
"@next/swc-darwin-x64": "16.2.6",
"@next/swc-linux-arm64-gnu": "16.2.6",
"@next/swc-linux-arm64-musl": "16.2.6",
"@next/swc-linux-x64-gnu": "16.2.6",
"@next/swc-linux-x64-musl": "16.2.6",
"@next/swc-win32-arm64-msvc": "16.2.6",
"@next/swc-win32-x64-msvc": "16.2.6",
"@next/swc-darwin-arm64": "16.2.11",
"@next/swc-darwin-x64": "16.2.11",
"@next/swc-linux-arm64-gnu": "16.2.11",
"@next/swc-linux-arm64-musl": "16.2.11",
"@next/swc-linux-x64-gnu": "16.2.11",
"@next/swc-linux-x64-musl": "16.2.11",
"@next/swc-win32-arm64-msvc": "16.2.11",
"@next/swc-win32-x64-msvc": "16.2.11",
"sharp": "^0.34.5"
},
"peerDependencies": {
+5 -4
View File
@@ -33,9 +33,9 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.5.0",
"@aws-sdk/client-s3": "3.1056.0",
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0",
@@ -74,7 +74,7 @@
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.16.1",
"axios": "1.18.0",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -88,17 +88,18 @@
"express": "5.2.1",
"express-rate-limit": "8.5.2",
"glob": "13.0.6",
"gpt-tokenizer": "^3.4.0",
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.2.0",
"js-yaml": "4.3.0",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.6",
"next": "16.2.11",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
+39
View File
@@ -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;
}
+23 -1
View File
@@ -50,6 +50,8 @@ export enum ActionsEnum {
setResourceUsers = "setResourceUsers",
setResourceRoles = "setResourceRoles",
listResourceUsers = "listResourceUsers",
listResourceAiModels = "listResourceAiModels",
setResourceAiModels = "setResourceAiModels",
// removeRoleSite = "removeRoleSite",
// addRoleAction = "addRoleAction",
// removeRoleAction = "removeRoleAction",
@@ -182,7 +184,27 @@ export enum ActionsEnum {
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView"
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(
+311
View File
@@ -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" };
}
+4
View File
@@ -3,12 +3,16 @@ import { flushConnectionLogToDb } from "#dynamic/routers/newt";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
async function cleanup() {
await stopPingAccumulator();
await flushBandwidthToDb();
await flushConnectionLogToDb();
await flushSiteBandwidthToDb();
await shutdownUsageRecorder();
await shutdownAiSessionLogger();
await wsCleanup();
process.exit(0);
+27
View File
@@ -1,6 +1,7 @@
import { join } from "path";
import { readFileSync } from "fs";
import {
aiProviders,
clients,
db,
resourcePolicies,
@@ -113,6 +114,32 @@ 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> {
+2 -21
View File
@@ -29,25 +29,6 @@ import {
labels
} 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", {
dnsChallengeId: serial("dnsChallengeId").primaryKey(),
domain: varchar("domain", { length: 255 }).notNull(),
@@ -95,7 +76,8 @@ export const subscriptions = pgTable("subscriptions", {
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }),
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", {
@@ -632,7 +614,6 @@ export const trialNotifications = pgTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+521 -8
View File
@@ -3,6 +3,7 @@ import { InferSelectModel, sql } from "drizzle-orm";
import {
bigint,
boolean,
check,
index,
integer,
pgTable,
@@ -11,6 +12,7 @@ import {
serial,
text,
unique,
uniqueIndex,
varchar
} from "drizzle-orm/pg-core";
@@ -18,7 +20,7 @@ export const domains = pgTable("domains", {
domainId: varchar("domainId").primaryKey(),
baseDomain: varchar("baseDomain").notNull(),
configManaged: boolean("configManaged").notNull().default(false),
type: varchar("type"), // "ns", "cname", "wildcard"
type: varchar("type").$type<"ns" | "cname" | "wildcard">(),
verified: boolean("verified").notNull().default(false),
failed: boolean("failed").notNull().default(false),
tries: integer("tries").notNull().default(0),
@@ -63,6 +65,11 @@ export const orgs = pgTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
@@ -99,6 +106,7 @@ export const sites = pgTable(
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet"),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node and INCLUDES THE CIDR
megabytesIn: real("bytesIn").default(0),
megabytesOut: real("bytesOut").default(0),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -194,7 +202,12 @@ export const resources = pgTable(
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<
"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"
>()
.notNull(),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
@@ -215,6 +228,41 @@ export const resources = pgTable(
]
);
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = pgTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
@@ -317,11 +365,18 @@ export const targets = pgTable(
"targets",
{
targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
resourceId: integer("resourceId").references(
() => resources.resourceId,
{
onDelete: "cascade"
})
.notNull(),
}
),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{
onDelete: "cascade"
}
),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -345,6 +400,7 @@ export const targets = pgTable(
},
(t) => [
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
index("idx_targets_providerid_siteid").on(t.providerId, t.siteId),
index("idx_targets_site_enabled_priority_target_resource")
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
.where(sql`${t.enabled} = true`)
@@ -424,11 +480,14 @@ export const siteResources = pgTable(
onDelete: "restrict"
}
),
requiresExitNodeConnection: boolean("requiresExitNodeConnection")
.notNull()
.default(false),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh">()
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
@@ -463,6 +522,45 @@ export const siteResources = pgTable(
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);
export const siteResourceAiProviders = pgTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: varchar("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: boolean("enabled").notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = pgTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = pgTable(
"networks",
{
@@ -1150,6 +1248,52 @@ export const apiKeyOrg = pgTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = pgTable(
"virtualApiKeys",
{
virtualApiKeyId: varchar("virtualApiKeyId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: varchar("kind").$type<"user" | "manual">().notNull(),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: varchar("name"),
description: varchar("description"),
token: varchar("token").notNull(),
lastChars: varchar("lastChars").notNull(),
allResources: boolean("allResources").notNull().default(false),
expiresAt: bigint("expiresAt", { mode: "number" }),
lastUsedAt: bigint("lastUsedAt", { mode: "number" }),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
createdByUserId: varchar("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = pgTable(
"virtualApiKeyResources",
{
virtualApiKeyId: varchar("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = pgTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1181,7 +1325,7 @@ export const clients = pgTable(
olmId: text("olmId"), // to lock it to a specific olm optionally
name: varchar("name").notNull(),
pubKey: varchar("pubKey"),
subnet: varchar("subnet").notNull(),
exitNodeSubnet: varchar("exitNodeSubnet").notNull(), // INCLUDES THE CIDR
megabytesIn: real("bytesIn"),
megabytesOut: real("bytesOut"),
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
@@ -1540,6 +1684,358 @@ export const statusHistory = pgTable(
]
);
export const aiProviders = pgTable(
"aiProviders",
{
providerId: serial("providerId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: varchar("name").notNull(),
niceId: varchar("niceId").notNull(),
type: varchar("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: varchar("apiKeyLastChars"),
authType: varchar("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: varchar("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: boolean("skipTlsVerification")
.notNull()
.default(false),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
);
export const aiModels = pgTable(
"aiModels",
{
modelId: serial("modelId").primaryKey(),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: varchar("modelKey").notNull(),
name: varchar("name").notNull(),
listType: varchar("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = pgTable(
"aiBudgets",
{
budgetId: serial("budgetId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: varchar("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = pgTable(
"aiUsageRecords",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: varchar("sessionId"),
requestedModel: varchar("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: boolean("estimated").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = pgTable(
"aiBudgetBreachEvents",
{
id: serial("id").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: varchar("enforcement").$type<"hard" | "soft">().notNull(),
unit: varchar("unit").$type<"usd" | "tokens">().notNull(),
period: varchar("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: boolean("blocked").notNull(),
requestUserId: varchar("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: bigint("createdAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = pgTable(
"aiSessionLog",
{
id: serial("id").primaryKey(),
sessionId: varchar("sessionId").notNull(),
orgId: varchar("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: varchar("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: varchar("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: varchar("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: varchar("requestedModel"),
isStream: boolean("isStream").notNull().default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
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 type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1595,6 +2091,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type Client = InferSelectModel<typeof clients>;
export type ClientSite = InferSelectModel<typeof clientSitesAssociationsCache>;
export type Olm = InferSelectModel<typeof olms>;
@@ -1624,3 +2124,16 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
+33 -1
View File
@@ -33,7 +33,9 @@ import {
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
ResourcePolicyHeaderAuth,
resourceWhitelist,
resourcePolicyWhiteList
} from "@server/db";
import { alias } from "@server/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
@@ -448,6 +450,36 @@ export async function getResourceRules(
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;
}
/**
* Get organization login page
*/
+6 -1
View File
@@ -5,6 +5,7 @@ import path from "path";
import fs from "fs";
import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
import logger from "@server/logger";
export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = checkFileExists(location);
@@ -12,7 +13,11 @@ export const exists = checkFileExists(location);
bootstrapVolume();
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") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
+2 -21
View File
@@ -23,25 +23,6 @@ import {
users
} from "./schema";
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", {
dnsChallengeId: integer("dnsChallengeId").primaryKey({
autoIncrement: true
@@ -89,7 +70,8 @@ export const subscriptions = sqliteTable("subscriptions", {
expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false),
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", {
@@ -627,7 +609,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type Certificate = InferSelectModel<typeof certificates>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
+534 -11
View File
@@ -1,12 +1,15 @@
import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
check,
index,
integer,
primaryKey,
real,
sqliteTable,
text,
unique
unique,
uniqueIndex
} from "drizzle-orm/sqlite-core";
export const domains = sqliteTable("domains", {
@@ -15,7 +18,7 @@ export const domains = sqliteTable("domains", {
configManaged: integer("configManaged", { mode: "boolean" })
.notNull()
.default(false),
type: text("type"), // "ns", "cname", "wildcard"
type: text("type").$type<"ns" | "cname" | "wildcard">(),
verified: integer("verified", { mode: "boolean" }).notNull().default(false),
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0),
@@ -61,6 +64,11 @@ export const orgs = sqliteTable("orgs", {
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(0),
settingsLogRetentionDaysAISessions: integer(
"settingsLogRetentionDaysAISessions"
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
.notNull()
.default(7),
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
@@ -107,7 +115,7 @@ export const sites = sqliteTable("sites", {
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
subnet: text("subnet"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -203,7 +211,10 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
@@ -214,6 +225,41 @@ export const resources = sqliteTable("resources", {
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.resourceId, t.providerId] })]
);
export const resourceAiModels = sqliteTable(
"resourceAiModels",
{
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
export const labels = sqliteTable("labels", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
@@ -322,11 +368,12 @@ export const clientLabels = sqliteTable(
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(() => aiProviders.providerId, {
onDelete: "cascade"
}),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
@@ -422,10 +469,17 @@ export const siteResources = sqliteTable("siteResources", {
() => networks.networkId,
{ onDelete: "restrict" }
),
requiresExitNodeConnection: integer("requiresExitNodeConnection", {
mode: "boolean"
})
.notNull()
.default(false),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
mode: text("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
@@ -453,6 +507,45 @@ export const siteResources = sqliteTable("siteResources", {
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const siteResourceAiProviders = sqliteTable(
"siteResourceAiProviders",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
accessMode: text("accessMode")
.$type<"inherit" | "select">()
.notNull()
.default("inherit"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.providerId] })]
);
export const siteResourceAiModels = sqliteTable(
"siteResourceAiModels",
{
siteResourceId: integer("siteResourceId")
.notNull()
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
}),
modelId: integer("modelId")
.notNull()
.references(() => aiModels.modelId, { onDelete: "cascade" }),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow")
},
(t) => [primaryKey({ columns: [t.siteResourceId, t.modelId] })]
);
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"),
@@ -599,6 +692,7 @@ export const clients = sqliteTable("clients", {
pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
@@ -1411,6 +1505,54 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", {
.notNull()
});
export const virtualApiKeys = sqliteTable(
"virtualApiKeys",
{
virtualApiKeyId: text("virtualApiKeyId").primaryKey(),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
kind: text("kind").$type<"user" | "manual">().notNull(),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name"),
description: text("description"),
token: text("token").notNull(),
lastChars: text("lastChars").notNull(),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
expiresAt: integer("expiresAt"),
lastUsedAt: integer("lastUsedAt"),
createdAt: integer("createdAt").notNull(),
createdByUserId: text("createdByUserId").references(
() => users.userId,
{ onDelete: "set null" }
)
},
(t) => [
uniqueIndex("virtual_api_key_user_identity_uniq")
.on(t.orgId, t.userId)
.where(sql`${t.kind} = 'user'`)
]
);
export const virtualApiKeyResources = sqliteTable(
"virtualApiKeyResources",
{
virtualApiKeyId: text("virtualApiKeyId")
.notNull()
.references(() => virtualApiKeys.virtualApiKeyId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" })
},
(t) => [primaryKey({ columns: [t.virtualApiKeyId, t.resourceId] })]
);
export const idpOrg = sqliteTable("idpOrg", {
idpId: integer("idpId")
.notNull()
@@ -1526,6 +1668,370 @@ export const statusHistory = sqliteTable(
]
);
export const aiProviders = sqliteTable(
"aiProviders",
{
providerId: integer("providerId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
name: text("name").notNull(),
niceId: text("niceId").notNull(),
type: text("type")
.$type<
| "openai"
| "anthropic"
| "googleGemini"
| "vertexAi"
| "bedrock"
| "microsoftFoundry"
| "openRouter"
| "vercelAiGateway"
| "custom"
>()
.notNull(),
upstreamUrl: text("upstreamUrl"),
apiKey: text("apiKey"),
apiKeyLastChars: text("apiKeyLastChars"),
authType: text("authType")
.$type<
| "bearer"
| "x-api-key"
| "x-goog-api-key"
| "hec"
| "cf-aig-authorization"
| "none"
| "passthrough"
>()
.notNull(),
routingMode: text("routingMode")
.$type<"url" | "target">()
.notNull()
.default("url"),
capabilities: text("capabilities").notNull().default("[]"),
headers: text("headers"), // JSON array of { name, value }
skipTlsVerification: integer("skipTlsVerification", { mode: "boolean" })
.notNull()
.default(false),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [index("idx_aiProviders_orgId_niceId").on(t.orgId, t.niceId)]
);
export const aiModels = sqliteTable(
"aiModels",
{
modelId: integer("modelId").primaryKey({ autoIncrement: true }),
providerId: integer("providerId")
.notNull()
.references(() => aiProviders.providerId, { onDelete: "cascade" }),
modelKey: text("modelKey").notNull(),
name: text("name").notNull(),
listType: text("listType")
.$type<"allow" | "block">()
.notNull()
.default("allow"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [unique("ai_model_provider_key_uniq").on(t.providerId, t.modelKey)]
);
export const aiBudgets = sqliteTable(
"aiBudgets",
{
budgetId: integer("budgetId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
modelId: integer("modelId").references(() => aiModels.modelId, {
onDelete: "cascade"
}),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "cascade" }
),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "cascade" }
),
amount: real("amount").notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull()
.default("monthly"),
enforcement: text("enforcement")
.$type<"hard" | "soft">()
.notNull()
.default("hard"),
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [
unique("ai_budget_provider_uniq").on(t.providerId, t.unit, t.period),
unique("ai_budget_model_uniq").on(t.modelId, t.unit, t.period),
unique("ai_budget_resource_uniq").on(t.resourceId, t.unit, t.period),
unique("ai_budget_site_resource_uniq").on(
t.siteResourceId,
t.unit,
t.period
),
unique("ai_budget_role_uniq").on(t.roleId, t.unit, t.period),
unique("ai_budget_virtual_api_key_uniq").on(
t.virtualApiKeyId,
t.unit,
t.period
)
]
);
export const aiUsageRecords = sqliteTable(
"aiUsageRecords",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
// Links this usage record back to the aiSessionLog row for the same
// request (aiSessionLog.sessionId), so token/cost usage can be shown
// alongside the session transcript. Not a DB-level FK - aiSessionLog
// lives in the separate logs database. Nullable because the session
// log may be disabled (retention set to 0) while usage tracking
// stays on.
sessionId: text("sessionId"),
requestedModel: text("requestedModel").notNull(),
promptTokens: integer("promptTokens").notNull().default(0),
cacheReadTokens: integer("cacheReadTokens").notNull().default(0),
cacheWriteTokens: integer("cacheWriteTokens").notNull().default(0),
completionTokens: integer("completionTokens").notNull().default(0),
reasoningTokens: integer("reasoningTokens").notNull().default(0),
totalTokens: integer("totalTokens").notNull().default(0),
costUsd: real("costUsd"),
estimated: integer("estimated", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_usage_records_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_usage_records_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_usage_records_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_usage_records_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_usage_records_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_usage_records_session").on(t.sessionId)
]
);
export const aiBudgetBreachEvents = sqliteTable(
"aiBudgetBreachEvents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
budgetId: integer("budgetId")
.notNull()
.references(() => aiBudgets.budgetId, { onDelete: "cascade" }),
enforcement: text("enforcement").$type<"hard" | "soft">().notNull(),
unit: text("unit").$type<"usd" | "tokens">().notNull(),
period: text("period")
.$type<
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly"
>()
.notNull(),
amount: real("amount").notNull(),
usageAmount: real("usageAmount").notNull(),
blocked: integer("blocked", { mode: "boolean" }).notNull(),
requestUserId: text("requestUserId").references(() => users.userId, {
onDelete: "set null"
}),
createdAt: integer("createdAt").notNull()
},
(t) => [
index("idx_ai_budget_breach_events_budget_created").on(
t.budgetId,
t.createdAt
)
]
);
// Logs the aggregated prompt + response for a single AI gateway request, for
// session replay. One row per request (not per streaming chunk). `sessionId`
// is a fresh random id per row for now - no cross-request correlation yet,
// but the column exists so a future pass can link multiple rows into a real
// multi-turn session.
export const aiSessionLog = sqliteTable(
"aiSessionLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: text("sessionId").notNull(),
orgId: text("orgId").references(() => orgs.orgId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "set null" }
),
capability: text("capability").notNull(),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "set null" }
),
siteResourceId: integer("siteResourceId").references(
() => siteResources.siteResourceId,
{ onDelete: "set null" }
),
userId: text("userId").references(() => users.userId, {
onDelete: "set null"
}),
virtualApiKeyId: text("virtualApiKeyId").references(
() => virtualApiKeys.virtualApiKeyId,
{ onDelete: "set null" }
),
requestedModel: text("requestedModel"),
isStream: integer("isStream", { mode: "boolean" })
.notNull()
.default(false),
requestBody: text("requestBody"),
responseBody: text("responseBody"),
// Capability-agnostic message transcript (JSON-encoded
// NormalizedAiMessage[] from server/lib/aiMessageNormalization.ts),
// computed at write time so search/display never need per-capability
// parsing logic. Null when normalization couldn't recognize the
// shape - callers fall back to requestBody/responseBody.
normalizedRequest: text("normalizedRequest"),
normalizedResponse: text("normalizedResponse"),
// True if any of the request/response (raw or normalized) fields
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: integer("truncated", { mode: "boolean" })
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch ms
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
index("idx_ai_session_log_org_provider_created").on(
t.orgId,
t.providerId,
t.createdAt
),
index("idx_ai_session_log_org_resource_created").on(
t.orgId,
t.resourceId,
t.createdAt
),
index("idx_ai_session_log_org_site_resource_created").on(
t.orgId,
t.siteResourceId,
t.createdAt
),
index("idx_ai_session_log_org_user_created").on(
t.orgId,
t.userId,
t.createdAt
),
index("idx_ai_session_log_org_virtual_api_key_created").on(
t.orgId,
t.virtualApiKeyId,
t.createdAt
),
index("idx_ai_session_log_session").on(t.sessionId)
]
);
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 type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -1577,6 +2083,10 @@ export type Idp = InferSelectModel<typeof idp>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type VirtualApiKey = InferSelectModel<typeof virtualApiKeys>;
export type VirtualApiKeyResource = InferSelectModel<
typeof virtualApiKeyResources
>;
export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>;
@@ -1608,3 +2118,16 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
export type AiUsageRecord = InferSelectModel<typeof aiUsageRecords>;
export type AiBudgetBreachEvent = InferSelectModel<typeof aiBudgetBreachEvents>;
export type AiSessionLog = InferSelectModel<typeof aiSessionLog>;
export type ResourceAiProvider = InferSelectModel<typeof resourceAiProviders>;
export type SiteResourceAiProvider = InferSelectModel<
typeof siteResourceAiProviders
>;
export type ResourceAiModel = InferSelectModel<typeof resourceAiModels>;
export type SiteResourceAiModel = InferSelectModel<typeof siteResourceAiModels>;
export type Certificate = InferSelectModel<typeof certificates>;
+13 -11
View File
@@ -30,14 +30,14 @@ export const NotifyTrialExpiring = ({
const isLastDay = daysRemaining === 1;
const previewText = hasEnded
? `Your trial for ${orgName} has ended.`
? `Your cloud trial for ${orgName} has ended.`
: isLastDay
? `Your trial for ${orgName} ends tomorrow.`
: `Your trial for ${orgName} ends in ${daysRemaining} days.`;
? `Your cloud trial for ${orgName} ends tomorrow.`
: `Your cloud trial for ${orgName} ends in ${daysRemaining} days.`;
const heading = hasEnded
? "Your Trial Ended"
: "Your Trial is Ending Soon";
? "Your Cloud Trial Ended"
: "Your Cloud Trial is Ending Soon";
return (
<Html>
@@ -55,7 +55,7 @@ export const NotifyTrialExpiring = ({
{hasEnded ? (
<>
<EmailText>
Your free trial for{" "}
Your cloud free trial for{" "}
<strong>{orgName}</strong> ended on{" "}
<strong>{trialEndsAt}</strong>. Your account
has been moved to the free plan, which
@@ -64,10 +64,11 @@ export const NotifyTrialExpiring = ({
<EmailText>
Some features and resources may now be
restricted. To restore full
access and continue using all the features
you had during your trial, please upgrade to
a paid plan.
restricted. To restore full access and
continue using all the features you had
during your trial, please upgrade to a paid
plan. This does not effect any self hosted
licenses.
</EmailText>
<EmailText>
@@ -93,7 +94,8 @@ export const NotifyTrialExpiring = ({
<EmailText>
After your trial ends, your account will be
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>
+15 -2
View File
@@ -5,15 +5,20 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { createAiGatewayServer } from "./aiGatewayServer";
import { createIntegrationApiServer } from "./integrationApiServer";
import {
ApiKey,
ApiKeyOrg,
AiBudget,
AiModel,
AiProvider,
RemoteExitNode,
Session,
SiteResource,
User,
UserOrg
UserOrg,
VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import { setHostMeta } from "@server/lib/hostMeta";
@@ -22,9 +27,10 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
import { initCleanup } from "#dynamic/cleanup";
import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { initAcmeCertSync } from "@server/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
async function startServers() {
await setHostMeta();
@@ -43,10 +49,12 @@ async function startServers() {
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
await initAiModelCatalog();
// Start all servers
const apiServer = createApiServer();
const internalServer = createInternalServer();
const aiGatewayServer = createAiGatewayServer();
const nextServer = await createNextServer();
if (config.getRawConfig().traefik.file_mode) {
@@ -65,6 +73,7 @@ async function startServers() {
apiServer,
nextServer,
internalServer,
aiGatewayServer,
integrationServer
};
}
@@ -83,6 +92,10 @@ declare global {
userOrgIds?: string[];
remoteExitNode?: RemoteExitNode;
siteResource?: SiteResource;
aiProvider?: AiProvider;
aiModel?: AiModel;
aiBudget?: AiBudget;
virtualApiKey?: VirtualApiKey;
orgPolicyAllowed?: boolean;
}
}
+3 -2
View File
@@ -12,7 +12,7 @@ import { logIncomingMiddleware } from "./middlewares/logIncoming";
import helmet from "helmet";
import swaggerUi from "swagger-ui-express";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { registry } from "./openApi";
import { registry, openApiTags } from "./openApi";
import fs from "fs";
import path from "path";
import { APP_PATH } from "./lib/consts";
@@ -181,7 +181,8 @@ function getOpenApiDocumentation() {
version: "v1",
title: "Pangolin Integration API"
},
servers: [{ url: "/v1" }]
servers: [{ url: "/v1" }],
tags: openApiTags
});
if (!process.env.DISABLE_GEN_OPENAPI) {
+865 -2
View File
@@ -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 {
// 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);
}
+613
View File
@@ -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 });
}
}
+339
View File
@@ -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);
}
+139
View File
@@ -0,0 +1,139 @@
import {
AI_CAPABILITY_DEFS,
type AiCapability,
type AiProtocolFamily
} from "@server/lib/aiCapabilities";
import HttpCode from "@server/types/HttpCode";
export type ClientErrorResponse = {
statusCode?: number;
contentType?: string;
body: string;
};
export type AiCapabilityErrorKind =
| "authentication"
| "invalid_request"
| "not_found"
| "permission"
| "rate_limit"
| "internal";
const AUTH_MESSAGE = "Invalid API key provided.";
type KindFields = {
openaiType: string;
openaiCode: string | null;
anthropicType: string;
googleStatus: string;
};
const KIND_FIELDS: Record<AiCapabilityErrorKind, KindFields> = {
authentication: {
openaiType: "authentication_error",
openaiCode: "invalid_api_key",
anthropicType: "authentication_error",
googleStatus: "UNAUTHENTICATED"
},
invalid_request: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "invalid_request_error",
googleStatus: "INVALID_ARGUMENT"
},
not_found: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "not_found_error",
googleStatus: "NOT_FOUND"
},
permission: {
openaiType: "invalid_request_error",
openaiCode: null,
anthropicType: "permission_error",
googleStatus: "PERMISSION_DENIED"
},
rate_limit: {
openaiType: "rate_limit_error",
openaiCode: "rate_limit_exceeded",
anthropicType: "rate_limit_error",
googleStatus: "RESOURCE_EXHAUSTED"
},
internal: {
openaiType: "api_error",
openaiCode: null,
anthropicType: "api_error",
googleStatus: "INTERNAL"
}
};
function resolveProtocolFamily(
capability: AiCapability | null
): AiProtocolFamily {
if (capability == null) {
return "openai";
}
return AI_CAPABILITY_DEFS[capability].protocolFamily;
}
/**
* Build a protocol-native error body for the given capability.
* Message stays contextual; only the envelope/machine fields follow the
* capability's native API shape.
*/
export function buildAiCapabilityErrorBody(
capability: AiCapability | null,
kind: AiCapabilityErrorKind,
message: string,
httpStatus?: number
): Record<string, unknown> {
const family = resolveProtocolFamily(capability);
const fields = KIND_FIELDS[kind];
switch (family) {
case "openai":
return {
error: {
message,
type: fields.openaiType,
param: null,
code: fields.openaiCode
}
};
case "anthropic":
return {
type: "error",
error: {
type: fields.anthropicType,
message
}
};
case "google":
return {
error: {
code: httpStatus ?? HttpCode.BAD_REQUEST,
message,
status: fields.googleStatus
}
};
case "bedrock":
return { message };
}
}
export function buildInferenceAuthClientError(
capability: AiCapability | null
): ClientErrorResponse {
return {
statusCode: HttpCode.UNAUTHORIZED,
contentType: "application/json",
body: JSON.stringify(
buildAiCapabilityErrorBody(
capability,
"authentication",
AUTH_MESSAGE,
HttpCode.UNAUTHORIZED
)
)
};
}
+69
View File
@@ -0,0 +1,69 @@
import { createHash } from "crypto";
import config from "@server/lib/config";
export const AI_GATEWAY_TRUST_HEADER = "X-Pangolin-Ai-Gateway-Auth";
// Injected by the same Traefik trust middleware as AI_GATEWAY_TRUST_HEADER,
// but its value differs per router (public inference resource vs. private
// siteResource) so the gateway can tell which kind of resource a trusted
// request arrived on without re-deriving it from resourceId/siteResourceId.
export const AI_GATEWAY_RESOURCE_TYPE_HEADER =
"X-Pangolin-Ai-Gateway-Resource-Type";
export type AiGatewayResourceType = "resource" | "site-resource";
// Opt-in (server.enable_ai_gateway_client_ip_header): carries the client IP
// that Badger resolved at the Traefik hop, so it survives an intermediary
// proxy between Traefik and the AI gateway that overwrites
// X-Forwarded-For/X-Real-Ip instead of appending to them. Set by a
// disableForwardAuth Badger middleware instance (see getTraefikConfig.ts)
// on the site-resource inference router only, since that's the sole path
// that resolves request identity from the client IP.
export const AI_GATEWAY_CLIENT_IP_HEADER = "X-Pangolin-Client-Ip";
/**
* Derive a Traefik-injected trust token from the server secret.
* Traefik overwrites this header on inference routes so the AI gateway can
* trust Badger-injected Remote-* identity without re-validating credentials.
*/
export function deriveAiGatewayTrustToken(secret: string): string {
return createHash("sha256")
.update(`ai-gateway-trust:${secret}`)
.digest("hex");
}
export function getAiGatewayTrustToken(): string {
const secret = config.getRawConfig().server.secret;
if (!secret) {
throw new Error("Server secret is required for AI gateway trust token");
}
return deriveAiGatewayTrustToken(secret);
}
export function isAiGatewayTrustHeaderValid(
headers: Record<string, string | string[] | undefined> | undefined,
expectedToken?: string
): boolean {
if (!headers) {
return false;
}
const expected = expectedToken ?? getAiGatewayTrustToken();
const raw =
headers[AI_GATEWAY_TRUST_HEADER] ??
headers[AI_GATEWAY_TRUST_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return typeof value === "string" && value === expected;
}
export function getAiGatewayResourceType(
headers: Record<string, string | string[] | undefined> | undefined
): AiGatewayResourceType | null {
if (!headers) {
return null;
}
const raw =
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER] ??
headers[AI_GATEWAY_RESOURCE_TYPE_HEADER.toLowerCase()];
const value = Array.isArray(raw) ? raw[0] : raw;
return value === "resource" || value === "site-resource" ? value : null;
}
+82
View File
@@ -0,0 +1,82 @@
import http from "node:http";
import https from "node:https";
import { Readable } from "node:stream";
type UpstreamFetchInit = {
method: string;
headers: Record<string, string>;
body?: string;
skipTlsVerification?: boolean;
signal?: AbortSignal;
};
const insecureHttpsAgent = new https.Agent({
rejectUnauthorized: false,
keepAlive: true
});
export function aiGatewayUpstreamFetch(
url: string,
init: UpstreamFetchInit
): Promise<Response> {
const parsed = new URL(url);
const isHttps = parsed.protocol === "https:";
const lib = isHttps ? https : http;
const agent =
isHttps && init.skipTlsVerification ? insecureHttpsAgent : undefined;
return new Promise((resolve, reject) => {
if (init.signal?.aborted) {
reject(init.signal.reason ?? new Error("Request aborted"));
return;
}
const req = lib.request(
url,
{
method: init.method,
headers: init.headers,
agent
},
(res) => {
const headers = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(key, entry);
}
} else {
headers.set(key, value);
}
}
const body = Readable.toWeb(res) as ReadableStream<Uint8Array>;
resolve(
new Response(body, {
status: res.statusCode ?? 502,
statusText: res.statusMessage,
headers
})
);
}
);
req.on("error", reject);
if (init.signal) {
const onAbort = () => req.destroy(init.signal!.reason);
init.signal.addEventListener("abort", onAbort, { once: true });
req.on("close", () =>
init.signal!.removeEventListener("abort", onAbort)
);
}
if (init.body !== undefined) {
req.write(init.body);
}
req.end();
});
}
+719
View File
@@ -0,0 +1,719 @@
import { and, eq, inArray } from "drizzle-orm";
import {
aiModels,
aiProviders,
db,
resourceAiModels,
resourceAiProviders,
siteResourceAiModels,
siteResourceAiProviders,
type Transaction
} from "@server/db";
import { z } from "zod";
type DbOrTrx = Transaction | typeof db;
export const modelListTypeSchema = z.enum(["allow", "block"]);
export type ModelListType = z.infer<typeof modelListTypeSchema>;
export const accessModeSchema = z.enum(["inherit", "select"]);
export type AccessMode = z.infer<typeof accessModeSchema>;
export const resourceAiProviderAttachmentSchema = z.strictObject({
providerId: z.number().int().positive(),
accessMode: accessModeSchema.optional().default("inherit"),
enabled: z.boolean().optional().default(true)
});
export type ResourceAiProviderInput = z.infer<
typeof resourceAiProviderAttachmentSchema
>;
export type ResourceAiProviderAttachment = {
providerId: number;
accessMode: AccessMode;
enabled: boolean;
};
export const resourceAiModelEntrySchema = z.strictObject({
modelId: z.number().int().positive(),
listType: modelListTypeSchema
});
export type ResourceAiModelEntry = z.infer<typeof resourceAiModelEntrySchema>;
export type InferenceFieldsError = {
error: string;
};
export function isInferenceFieldsError(
value: { error: string } | object
): value is InferenceFieldsError {
return "error" in value;
}
/**
* Resolve which allow/block patterns apply for an attachment.
* inherit provider lists; select resource-selected lists (replace).
*/
export function resolveEffectiveLists(input: {
accessMode: AccessMode;
providerAllows: string[];
providerBlocks: string[];
resourceAllows: string[];
resourceBlocks: string[];
}): { allows: string[]; blocks: string[] } {
if (input.accessMode === "select") {
return {
allows: input.resourceAllows,
blocks: input.resourceBlocks
};
}
return {
allows: input.providerAllows,
blocks: input.providerBlocks
};
}
function normalizeAttachments(
inputs: ResourceAiProviderInput[]
): ResourceAiProviderAttachment[] {
const byProviderId = new Map<
number,
{ accessMode: AccessMode; enabled: boolean }
>();
for (const input of inputs) {
byProviderId.set(input.providerId, {
accessMode: input.accessMode ?? "inherit",
enabled: input.enabled ?? true
});
}
return [...byProviderId.entries()].map(
([providerId, { accessMode, enabled }]) => ({
providerId,
accessMode,
enabled
})
);
}
/**
* Validate provider attachments for an org.
*/
export async function resolveProviderAttachments(input: {
orgId: string;
attachments: ResourceAiProviderInput[];
requireAtLeastOne: boolean;
}): Promise<ResourceAiProviderAttachment[] | InferenceFieldsError> {
const attachments = normalizeAttachments(input.attachments);
if (input.requireAtLeastOne && attachments.length === 0) {
return {
error: "At least one AI provider is required for inference-mode resources"
};
}
if (attachments.length === 0) {
return [];
}
const providerIds = attachments.map((a) => a.providerId);
const providers = await db
.select({
providerId: aiProviders.providerId,
orgId: aiProviders.orgId,
enabled: aiProviders.enabled
})
.from(aiProviders)
.where(
and(
inArray(aiProviders.providerId, providerIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (providers.length !== providerIds.length) {
return {
error: "One or more AI providers were not found in this organization"
};
}
const disabled = providers.find((p) => !p.enabled);
if (disabled) {
return {
error: `AI provider with ID ${disabled.providerId} is disabled`
};
}
return attachments;
}
export async function assertInferenceModeAllowsProviderFields(input: {
mode: string;
hasProviderAttachments: boolean;
}): Promise<InferenceFieldsError | null> {
if (input.mode === "inference") {
return null;
}
if (input.hasProviderAttachments) {
return {
error: "AI providers can only be attached to inference-mode resources"
};
}
return null;
}
/**
* Attach providers to a resource. Inherit attachments use the provider lists
* as-is (resource model rows for those providers are pruned). Select
* attachments keep resource-selected allow/block subsets.
*/
export async function setPublicResourceAiProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
if (attachments.length > 0) {
await trx.insert(resourceAiProviders).values(
attachments.map((a) => ({
resourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await prunePublicResourceModelsToSelectProviders(
resourceId,
attachments,
trx
);
}
export async function setSiteResourceAiProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
if (attachments.length > 0) {
await trx.insert(siteResourceAiProviders).values(
attachments.map((a) => ({
siteResourceId,
providerId: a.providerId,
accessMode: a.accessMode,
enabled: a.enabled
}))
);
}
await pruneSiteResourceModelsToSelectProviders(
siteResourceId,
attachments,
trx
);
}
/**
* Keep resource model rows only for providers in select mode.
*/
async function prunePublicResourceModelsToSelectProviders(
resourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
return;
}
const existing = await trx
.select({
modelId: resourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(aiModels, eq(resourceAiModels.modelId, aiModels.modelId))
.where(eq(resourceAiModels.resourceId, resourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(resourceAiModels)
.where(
and(
eq(resourceAiModels.resourceId, resourceId),
inArray(resourceAiModels.modelId, toRemove)
)
);
}
}
async function pruneSiteResourceModelsToSelectProviders(
siteResourceId: number,
attachments: ResourceAiProviderAttachment[],
trx: DbOrTrx
): Promise<void> {
const selectProviderIds = attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
return;
}
const existing = await trx
.select({
modelId: siteResourceAiModels.modelId,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(aiModels, eq(siteResourceAiModels.modelId, aiModels.modelId))
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
const allowed = new Set(selectProviderIds);
const toRemove = existing
.filter((row) => !allowed.has(row.providerId))
.map((row) => row.modelId);
if (toRemove.length > 0) {
await trx
.delete(siteResourceAiModels)
.where(
and(
eq(siteResourceAiModels.siteResourceId, siteResourceId),
inArray(siteResourceAiModels.modelId, toRemove)
)
);
}
}
export async function clearPublicResourceAiConfig(
resourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, resourceId));
await trx
.delete(resourceAiProviders)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function clearSiteResourceAiConfig(
siteResourceId: number,
trx: DbOrTrx = db
): Promise<void> {
await trx
.delete(siteResourceAiModels)
.where(eq(siteResourceAiModels.siteResourceId, siteResourceId));
await trx
.delete(siteResourceAiProviders)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export async function listPublicResourceAiProviders(resourceId: number) {
return db
.select({
providerId: resourceAiProviders.providerId,
name: aiProviders.name,
type: aiProviders.type,
enabled: resourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: resourceAiProviders.accessMode
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(resourceAiProviders.resourceId, resourceId));
}
export async function listSiteResourceAiProviders(siteResourceId: number) {
return db
.select({
providerId: siteResourceAiProviders.providerId,
name: aiProviders.name,
type: aiProviders.type,
enabled: siteResourceAiProviders.enabled,
providerEnabled: aiProviders.enabled,
accessMode: siteResourceAiProviders.accessMode
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(eq(siteResourceAiProviders.siteResourceId, siteResourceId));
}
export type EffectiveAllowModel = {
modelId: number;
modelKey: string;
name: string;
providerId: number;
providerName: string;
};
export async function listEffectiveAllowModels(options: {
resourceId?: number;
siteResourceId?: number;
}): Promise<EffectiveAllowModel[]> {
if (
options.resourceId === undefined &&
options.siteResourceId === undefined
) {
return [];
}
const attachments =
options.resourceId !== undefined
? await listPublicResourceAiProviders(options.resourceId)
: await listSiteResourceAiProviders(options.siteResourceId!);
const activeAttachments = attachments.filter(
(a) => a.enabled && a.providerEnabled
);
if (activeAttachments.length === 0) {
return [];
}
const inheritProviderIds = activeAttachments
.filter((a) => a.accessMode === "inherit")
.map((a) => a.providerId);
const selectProviderIds = activeAttachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
const providerNameById = new Map(
activeAttachments.map((a) => [a.providerId, a.name] as const)
);
const models: EffectiveAllowModel[] = [];
if (inheritProviderIds.length > 0) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(aiModels)
.where(
and(
inArray(aiModels.providerId, inheritProviderIds),
eq(aiModels.enabled, true),
eq(aiModels.listType, "allow")
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
if (selectProviderIds.length > 0) {
if (options.resourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(resourceAiModels)
.innerJoin(
aiModels,
eq(resourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(resourceAiModels.resourceId, options.resourceId),
inArray(aiModels.providerId, selectProviderIds),
eq(resourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
} else if (options.siteResourceId !== undefined) {
const rows = await db
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
name: aiModels.name,
providerId: aiModels.providerId
})
.from(siteResourceAiModels)
.innerJoin(
aiModels,
eq(siteResourceAiModels.modelId, aiModels.modelId)
)
.where(
and(
eq(
siteResourceAiModels.siteResourceId,
options.siteResourceId
),
inArray(aiModels.providerId, selectProviderIds),
eq(siteResourceAiModels.listType, "allow"),
eq(aiModels.enabled, true)
)
);
for (const row of rows) {
models.push({
...row,
providerName: providerNameById.get(row.providerId) ?? ""
});
}
}
}
models.sort((a, b) => {
const byProvider = a.providerName.localeCompare(
b.providerName,
undefined,
{
sensitivity: "base"
}
);
if (byProvider !== 0) {
return byProvider;
}
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
return models;
}
/**
* Model list APIs require an inference resource with at least one select-mode
* attached provider.
*/
export async function assertPublicModelListApiEligible(resource: {
resourceId: number;
mode: string;
}): Promise<string | null> {
if (resource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: resourceAiProviders.providerId })
.from(resourceAiProviders)
.where(
and(
eq(resourceAiProviders.resourceId, resource.resourceId),
eq(resourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
export async function assertSiteModelListApiEligible(siteResource: {
siteResourceId: number;
mode: string;
}): Promise<string | null> {
if (siteResource.mode !== "inference") {
return "AI model lists are only supported on inference-mode resources";
}
const [row] = await db
.select({ providerId: siteResourceAiProviders.providerId })
.from(siteResourceAiProviders)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
siteResource.siteResourceId
),
eq(siteResourceAiProviders.accessMode, "select")
)
)
.limit(1);
if (!row) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
return null;
}
/**
* Resource model entries must belong to select-mode attached providers, and
* listType must match the provider catalog entry (allowallow, blockblock).
*/
export async function assertPublicResourceModelEntriesValid(input: {
orgId: string;
resourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: resourceAiProviders.providerId,
accessMode: resourceAiProviders.accessMode,
enabled: resourceAiProviders.enabled
})
.from(resourceAiProviders)
.innerJoin(
aiProviders,
eq(resourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(resourceAiProviders.resourceId, input.resourceId),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "resource"
});
}
export async function assertSiteResourceModelEntriesValid(input: {
orgId: string;
siteResourceId: number;
models: ResourceAiModelEntry[];
}): Promise<string | null> {
const uniqueModels = dedupeModelEntries(input.models);
if (uniqueModels.length === 0) {
return null;
}
const attachments = await db
.select({
providerId: siteResourceAiProviders.providerId,
accessMode: siteResourceAiProviders.accessMode,
enabled: siteResourceAiProviders.enabled
})
.from(siteResourceAiProviders)
.innerJoin(
aiProviders,
eq(siteResourceAiProviders.providerId, aiProviders.providerId)
)
.where(
and(
eq(
siteResourceAiProviders.siteResourceId,
input.siteResourceId
),
eq(aiProviders.orgId, input.orgId)
)
);
return assertModelEntriesValid({
orgId: input.orgId,
modelEntries: uniqueModels,
attachments,
resourceLabel: "site resource"
});
}
function dedupeModelEntries(
models: ResourceAiModelEntry[]
): ResourceAiModelEntry[] {
const byModelId = new Map(
models.map((m) => [m.modelId, m.listType] as const)
);
return [...byModelId.entries()].map(([modelId, listType]) => ({
modelId,
listType
}));
}
async function assertModelEntriesValid(input: {
orgId: string;
modelEntries: ResourceAiModelEntry[];
attachments: ResourceAiProviderAttachment[];
resourceLabel: string;
}): Promise<string | null> {
const selectProviderIds = input.attachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
if (selectProviderIds.length === 0) {
return "Set at least one attached AI provider to select mode before managing model lists";
}
const modelIds = input.modelEntries.map((m) => m.modelId);
const catalogRows = await db
.select({
modelId: aiModels.modelId,
listType: aiModels.listType,
providerId: aiModels.providerId,
enabled: aiModels.enabled
})
.from(aiModels)
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
.where(
and(
inArray(aiModels.modelId, modelIds),
inArray(aiModels.providerId, selectProviderIds),
eq(aiProviders.orgId, input.orgId)
)
);
if (catalogRows.length !== modelIds.length) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
for (const entry of input.modelEntries) {
const catalog = catalogById.get(entry.modelId);
if (!catalog) {
return `One or more model IDs do not exist or do not belong to a select-mode provider on this ${input.resourceLabel}`;
}
if (catalog.listType !== entry.listType) {
return `Model ${entry.modelId} must use listType "${catalog.listType}" to match the provider catalog entry`;
}
if (!catalog.enabled) {
return `Model ${entry.modelId} is disabled on its provider`;
}
}
return null;
}
+538
View File
@@ -0,0 +1,538 @@
import type { AiCapability } from "@server/lib/aiCapabilities";
import { sseDataFrames, tryParseJson } from "@server/lib/aiUsageExtraction";
import logger from "@server/logger";
// Uniform, capability-agnostic representation of a chat message, used so
// the AI session log can be searched/displayed the same way regardless of
// which provider/capability produced it. Content is flattened to plain text
// - non-text parts (images, tool calls/results) are rendered as readable
// placeholders rather than preserved as structured data, which is enough for
// a transcript-style replay view without a per-capability renderer.
export type NormalizedRole = "system" | "user" | "assistant" | "tool";
export type NormalizedAiMessage = {
role: NormalizedRole;
content: string;
};
function normalizeRole(role: unknown): NormalizedRole {
if (
role === "system" ||
role === "user" ||
role === "assistant" ||
role === "tool"
) {
return role;
}
if (role === "model") return "assistant"; // Gemini
if (role === "function") return "tool"; // OpenAI legacy function role
return "user";
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value ?? {});
} catch {
return "";
}
}
/**
* Flattens one message "part"/"block" (OpenAI content parts, Anthropic
* content blocks, Gemini parts, Bedrock converse content blocks - they all
* follow the same rough shape) into readable text.
*/
function flattenContentPart(part: unknown): string {
if (typeof part === "string") return part;
if (part == null || typeof part !== "object") return "";
const p = part as Record<string, unknown>;
if (typeof p.text === "string") return p.text;
if (
p.type === "image_url" ||
p.type === "image" ||
p.type === "input_image" ||
p.type === "output_image" ||
"inlineData" in p
) {
return "[image]";
}
// Anthropic-style tool_use / tool_result blocks
if (p.type === "tool_use") {
const name = typeof p.name === "string" ? p.name : "tool";
return `[tool_call: ${name}(${safeJsonStringify(p.input)})]`;
}
if (p.type === "tool_result") {
const content = p.content;
const text =
typeof content === "string"
? content
: Array.isArray(content)
? flattenContentParts(content)
: "";
return `[tool_result: ${text}]`;
}
// Gemini-style functionCall / functionResponse parts
if (p.functionCall && typeof p.functionCall === "object") {
const fc = p.functionCall as Record<string, unknown>;
return `[tool_call: ${fc.name}(${safeJsonStringify(fc.args)})]`;
}
if (p.functionResponse && typeof p.functionResponse === "object") {
const fr = p.functionResponse as Record<string, unknown>;
return `[tool_result: ${fr.name}(${safeJsonStringify(fr.response)})]`;
}
// Bedrock converse-style toolUse / toolResult content blocks
if (p.toolUse && typeof p.toolUse === "object") {
const tu = p.toolUse as Record<string, unknown>;
return `[tool_call: ${tu.name}(${safeJsonStringify(tu.input)})]`;
}
if (p.toolResult && typeof p.toolResult === "object") {
const tr = p.toolResult as Record<string, unknown>;
const content = tr.content;
const text = Array.isArray(content) ? flattenContentParts(content) : "";
return `[tool_result: ${text}]`;
}
return "";
}
function flattenContentParts(parts: unknown[]): string {
return parts.map(flattenContentPart).join("");
}
function flattenContent(content: unknown): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) return flattenContentParts(content);
return "";
}
/**
* Best-effort scan for every `"text":"..."` JSON string value in raw text,
* concatenated in order. Fallback for streaming formats we can't fully parse
* as JSON/SSE (Gemini's array-JSON stream, Bedrock's binary event-stream
* framing) - same spirit as aiUsageExtraction's scanNumericFields.
*/
function scanTextFragments(text: string): string {
const out: string[] = [];
const re = /"text"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
try {
out.push(JSON.parse(`"${match[1]}"`));
} catch {
out.push(match[1]);
}
}
return out.join("");
}
// ---------------------------------------------------------------------------
// Request (input) normalizers - operate on the already-parsed outbound body.
// ---------------------------------------------------------------------------
function normalizeOpenAiChatRequest(body: any): NormalizedAiMessage[] {
const messages = Array.isArray(body?.messages) ? body.messages : [];
return messages.map((m: any) => ({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
}));
}
function normalizeOpenAiResponsesRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (typeof body?.instructions === "string" && body.instructions) {
out.push({ role: "system", content: body.instructions });
}
const input = body?.input;
if (typeof input === "string") {
out.push({ role: "user", content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
if (item?.role) {
out.push({
role: normalizeRole(item.role),
content: flattenContent(item.content)
});
} else if (typeof item?.type === "string") {
out.push({ role: "tool", content: `[${item.type}]` });
}
}
}
return out;
}
function normalizeAnthropicRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (body?.system) {
const sys = flattenContent(body.system);
if (sys) out.push({ role: "system", content: sys });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: flattenContent(m?.content)
});
}
return out;
}
function normalizeGeminiRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
const sysParts = body?.systemInstruction?.parts;
if (Array.isArray(sysParts)) {
const text = flattenContentParts(sysParts);
if (text) out.push({ role: "system", content: text });
}
const contents = Array.isArray(body?.contents) ? body.contents : [];
for (const c of contents) {
out.push({
role: normalizeRole(c?.role),
content: Array.isArray(c?.parts) ? flattenContentParts(c.parts) : ""
});
}
return out;
}
function normalizeBedrockConverseRequest(body: any): NormalizedAiMessage[] {
const out: NormalizedAiMessage[] = [];
if (Array.isArray(body?.system)) {
const text = flattenContentParts(body.system);
if (text) out.push({ role: "system", content: text });
}
const messages = Array.isArray(body?.messages) ? body.messages : [];
for (const m of messages) {
out.push({
role: normalizeRole(m?.role),
content: Array.isArray(m?.content)
? flattenContentParts(m.content)
: ""
});
}
return out;
}
/**
* bedrock_model_invoke and google_raw_predict are passthroughs - the body
* shape depends entirely on the underlying model, not the capability. Try
* the two shapes we're most likely to see (Anthropic Claude, then plain
* OpenAI-style) and give up otherwise, same fallback spirit
* aiUsageExtraction.ts uses for these two capabilities' usage extraction.
*/
function normalizeBestEffortRequest(body: any): NormalizedAiMessage[] | null {
if (!Array.isArray(body?.messages)) return null;
const looksAnthropicShaped = body.messages.some((m: any) =>
Array.isArray(m?.content)
);
return looksAnthropicShaped
? normalizeAnthropicRequest(body)
: normalizeOpenAiChatRequest(body);
}
// ---------------------------------------------------------------------------
// Response (output) normalizers - operate on the raw response text, which
// may be a single JSON document (non-streaming) or provider-framed streaming
// text (SSE `data:` frames, a JSON-array stream, or binary event-stream
// framing with JSON payloads embedded in it).
// ---------------------------------------------------------------------------
function normalizeOpenAiChatResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const delta = tryParseJson(frame)?.choices?.[0]?.delta;
if (!delta) continue;
found = true;
if (typeof delta.role === "string") role = delta.role;
if (typeof delta.content === "string") content += delta.content;
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const message = tryParseJson(text)?.choices?.[0]?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role),
content: flattenContent(message.content)
}
];
}
function extractOpenAiResponsesOutputText(response: any): string | null {
if (typeof response?.output_text === "string") return response.output_text;
const output = Array.isArray(response?.output) ? response.output : [];
const pieces: string[] = [];
for (const item of output) {
if (item?.type === "message" && Array.isArray(item.content)) {
pieces.push(flattenContentParts(item.content));
}
}
return pieces.length > 0 ? pieces.join("") : null;
}
function normalizeOpenAiResponsesResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (
parsed.type === "response.output_text.delta" &&
typeof parsed.delta === "string"
) {
content += parsed.delta;
found = true;
} else if (
parsed.type === "response.completed" &&
parsed.response
) {
const outputText = extractOpenAiResponsesOutputText(
parsed.response
);
if (outputText != null) {
content = outputText;
found = true;
}
}
}
return found ? [{ role: "assistant", content }] : null;
}
const parsed = tryParseJson(text);
const outputText = extractOpenAiResponsesOutputText(
parsed?.response ?? parsed
);
return outputText != null
? [{ role: "assistant", content: outputText }]
: null;
}
function normalizeAnthropicResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (isStream) {
let role: unknown = "assistant";
let content = "";
let found = false;
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
if (parsed.type === "message_start" && parsed.message?.role) {
role = parsed.message.role;
}
if (
parsed.type === "content_block_start" &&
parsed.content_block?.type === "tool_use"
) {
const name = parsed.content_block.name ?? "tool";
content += `[tool_call: ${name}]`;
found = true;
}
if (
parsed.type === "content_block_delta" &&
typeof parsed.delta?.text === "string"
) {
content += parsed.delta.text;
found = true;
}
}
return found ? [{ role: normalizeRole(role), content }] : null;
}
const parsed = tryParseJson(text);
if (!parsed || !Array.isArray(parsed.content)) return null;
return [
{
role: normalizeRole(parsed.role ?? "assistant"),
content: flattenContentParts(parsed.content)
}
];
}
function geminiCandidateParts(node: any): string {
const parts = node?.candidates?.[0]?.content?.parts;
return Array.isArray(parts) ? flattenContentParts(parts) : "";
}
function normalizeGeminiResponse(
text: string,
_isStream: boolean
): NormalizedAiMessage[] | null {
const frames = sseDataFrames(text);
let content = "";
let role: unknown = "model";
let found = false;
if (frames.length > 0) {
for (const frame of frames) {
const parsed = tryParseJson(frame);
const piece = geminiCandidateParts(parsed);
if (piece) {
content += piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else {
const parsed = tryParseJson(text);
if (Array.isArray(parsed)) {
for (const chunk of parsed) {
const piece = geminiCandidateParts(chunk);
if (piece) {
content += piece;
found = true;
}
const r = chunk?.candidates?.[0]?.content?.role;
if (r) role = r;
}
} else if (parsed) {
const piece = geminiCandidateParts(parsed);
if (piece) {
content = piece;
found = true;
}
const r = parsed?.candidates?.[0]?.content?.role;
if (r) role = r;
}
}
if (!found) {
const scanned = scanTextFragments(text);
return scanned
? [{ role: normalizeRole(role), content: scanned }]
: null;
}
return [{ role: normalizeRole(role), content }];
}
function normalizeBedrockConverseResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
if (!isStream) {
const message = tryParseJson(text)?.output?.message;
if (!message) return null;
return [
{
role: normalizeRole(message.role ?? "assistant"),
content: Array.isArray(message.content)
? flattenContentParts(message.content)
: ""
}
];
}
// converse-stream uses AWS's binary event-stream framing, but the JSON
// payload of each event survives intact inside it (same assumption
// aiUsageExtraction.ts makes for usage) - scan for the text pieces.
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeBedrockModelInvokeResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
function normalizeGoogleRawPredictResponse(
text: string,
isStream: boolean
): NormalizedAiMessage[] | null {
const anthropicStyle = normalizeAnthropicResponse(text, isStream);
if (anthropicStyle) return anthropicStyle;
const scanned = scanTextFragments(text);
return scanned ? [{ role: "assistant", content: scanned }] : null;
}
const REQUEST_NORMALIZERS: Record<
AiCapability,
(body: any) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatRequest,
openai_responses: normalizeOpenAiResponsesRequest,
anthropic_messages: normalizeAnthropicRequest,
gemini_generate_content: normalizeGeminiRequest,
google_generate_content: normalizeGeminiRequest,
google_raw_predict: normalizeBestEffortRequest,
bedrock_model_invoke: normalizeBestEffortRequest,
bedrock_converse: normalizeBedrockConverseRequest
};
const RESPONSE_NORMALIZERS: Record<
AiCapability,
(text: string, isStream: boolean) => NormalizedAiMessage[] | null
> = {
openai_chat: normalizeOpenAiChatResponse,
openai_responses: normalizeOpenAiResponsesResponse,
anthropic_messages: normalizeAnthropicResponse,
gemini_generate_content: normalizeGeminiResponse,
google_generate_content: normalizeGeminiResponse,
google_raw_predict: normalizeGoogleRawPredictResponse,
bedrock_model_invoke: normalizeBedrockModelInvokeResponse,
bedrock_converse: normalizeBedrockConverseResponse
};
/**
* Normalizes an outbound AI gateway request body into a uniform message
* transcript, regardless of capability/provider. Returns null if the body
* doesn't contain any recognizable messages (or parsing failed) - callers
* should fall back to showing the raw request body.
*/
export function normalizeAiRequest(
capability: AiCapability,
body: unknown
): NormalizedAiMessage[] | null {
try {
const result = REQUEST_NORMALIZERS[capability](body);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI request messages", {
capability,
error
});
return null;
}
}
/**
* Normalizes a completed (non-streaming or fully-accumulated streaming) AI
* gateway response into a uniform message transcript. Returns null if
* nothing recognizable could be extracted - callers should fall back to
* showing the raw response body.
*/
export function normalizeAiResponse(
capability: AiCapability,
responseText: string,
isStream: boolean
): NormalizedAiMessage[] | null {
try {
const result = RESPONSE_NORMALIZERS[capability](responseText, isStream);
return result && result.length > 0 ? result : null;
} catch (error) {
logger.debug("Failed to normalize AI response messages", {
capability,
error
});
return null;
}
}
+323
View File
@@ -0,0 +1,323 @@
import fs from "node:fs";
import axios from "axios";
import { z } from "zod";
import config from "@server/lib/config";
import logger from "@server/logger";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
export const CATALOG_PROVIDERS = [
"openai",
"anthropic",
"gemini",
"vertex",
"azure",
"bedrock"
] as const;
export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number];
const CATALOG_PROVIDER_SET = new Set<string>(CATALOG_PROVIDERS);
// Each of our provider types maps to at most one catalog provider. Provider
// types that proxy arbitrary underlying models (openRouter, vercelAiGateway,
// custom) have no mapping.
const PROVIDER_CATALOG_MAP: Record<
Exclude<AiProviderType, "custom">,
CatalogProvider | null
> = {
openai: "openai",
anthropic: "anthropic",
googleGemini: "gemini",
vertexAi: "vertex",
bedrock: "bedrock",
microsoftFoundry: "azure",
openRouter: null,
vercelAiGateway: null
};
export function getCatalogProviderForType(
type: AiProviderType
): CatalogProvider | null {
if (type === "custom") {
return null;
}
return PROVIDER_CATALOG_MAP[type];
}
export type AiModelCatalogEntry = {
provider: CatalogProvider;
model: string;
pricing: {
in: number | null;
out: number | null;
cache: number | null;
reasoning: number | null;
};
};
const catalogEntrySchema = z.object({
model: z.string(),
provider: z.string(),
pricing: z
.object({
in: z.number().nullable().optional(),
out: z.number().nullable().optional(),
cache: z.number().nullable().optional(),
reasoning: z.number().nullable().optional()
})
.optional()
});
const catalogFileSchema = z.object({
data: z.array(catalogEntrySchema).optional().default([])
});
type RawCatalogEntry = z.infer<typeof catalogEntrySchema>;
function normalizeCatalogProvider(raw: string): CatalogProvider | null {
if (CATALOG_PROVIDER_SET.has(raw)) {
return raw as CatalogProvider;
}
if (raw.startsWith("bedrock")) {
return "bedrock";
}
if (raw.startsWith("vertex")) {
return "vertex";
}
if (raw.startsWith("azure")) {
return "azure";
}
return null;
}
function normalizeEntry(raw: RawCatalogEntry): AiModelCatalogEntry | null {
const provider = normalizeCatalogProvider(raw.provider);
if (!provider) {
return null;
}
if (!raw.model) {
return null;
}
return {
provider,
model: raw.model,
pricing: {
in: raw.pricing?.in ?? null,
out: raw.pricing?.out ?? null,
cache: raw.pricing?.cache ?? null,
reasoning: raw.pricing?.reasoning ?? null
}
};
}
function providerKey(provider: CatalogProvider, key: string): string {
return `${provider}\0${key}`;
}
export class AiModelCatalog {
private entries: AiModelCatalogEntry[] = [];
private byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
private byProviderAndKey = new Map<string, AiModelCatalogEntry>();
private byKey = new Map<string, AiModelCatalogEntry[]>();
private refreshTimer: NodeJS.Timeout | null = null;
/**
* Loads the catalog into memory and schedules periodic background refreshes.
* Call once at server startup.
*/
async init(): Promise<void> {
await this.refresh();
this.scheduleNextRefresh();
}
/** Exact lookup by catalog provider and model key. */
get(
provider: CatalogProvider,
key: string
): AiModelCatalogEntry | undefined {
return this.byProviderAndKey.get(providerKey(provider, key));
}
/** All models for a catalog provider. */
list(provider: CatalogProvider): AiModelCatalogEntry[] {
return this.byProvider.get(provider) ?? [];
}
/** All catalog entries that share a model key, across providers. */
listByKey(key: string): AiModelCatalogEntry[] {
return this.byKey.get(key) ?? [];
}
/** Full in-memory catalog. */
getAll(): AiModelCatalogEntry[] {
return this.entries;
}
private setEntries(entries: AiModelCatalogEntry[]): void {
const byProvider = new Map<CatalogProvider, AiModelCatalogEntry[]>();
const byProviderAndKey = new Map<string, AiModelCatalogEntry>();
const byKey = new Map<string, AiModelCatalogEntry[]>();
for (const entry of entries) {
const list = byProvider.get(entry.provider) ?? [];
list.push(entry);
byProvider.set(entry.provider, list);
const mapKey = providerKey(entry.provider, entry.model);
if (!byProviderAndKey.has(mapKey)) {
byProviderAndKey.set(mapKey, entry);
}
const keyList = byKey.get(entry.model) ?? [];
keyList.push(entry);
byKey.set(entry.model, keyList);
}
this.entries = entries;
this.byProvider = byProvider;
this.byProviderAndKey = byProviderAndKey;
this.byKey = byKey;
}
private async fetchFromFile(
filePath: string
): Promise<AiModelCatalogEntry[] | null> {
try {
if (!fs.existsSync(filePath)) {
logger.warn(
`AI model catalog file not found at ${filePath}; cost calculation will fall back to unknown pricing`
);
return null;
}
const raw = fs.readFileSync(filePath, "utf-8");
const result = catalogFileSchema.safeParse(JSON.parse(raw));
if (!result.success) {
logger.warn(
`AI model catalog file at ${filePath} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error) {
logger.warn("Failed to read AI model catalog file", { error });
return null;
}
}
private async fetchFromUpstream(
upstreamUrl: string
): Promise<AiModelCatalogEntry[] | null> {
try {
const res = await axios.get(upstreamUrl, { timeout: 15_000 });
const result = catalogFileSchema.safeParse(res.data);
if (!result.success) {
logger.warn(
`AI model catalog response from ${upstreamUrl} failed validation: ${result.error.message}`
);
return null;
}
return result.data.data
.map(normalizeEntry)
.filter((e): e is AiModelCatalogEntry => e != null);
} catch (error: any) {
logger.warn(
`Failed to fetch AI model catalog from ${upstreamUrl}: ${error.message || error}`
);
return null;
}
}
private async refresh(): Promise<void> {
const { file, merge_file, upstream_url } =
config.getRawConfig().ai.model_catalog;
const fetched = file
? await this.fetchFromFile(file)
: await this.fetchFromUpstream(upstream_url);
if (!fetched) {
logger.debug(
"AI model catalog refresh failed; keeping previously loaded catalog in memory"
);
return;
}
let merged = fetched;
if (merge_file) {
const mergeEntries = await this.fetchFromFile(merge_file);
if (mergeEntries) {
// Entries from the base catalog take precedence; the merge
// file only adds models not already present.
merged = [...fetched, ...mergeEntries];
}
}
this.setEntries(merged);
logger.debug(
`AI model catalog refreshed: ${this.entries.length} models loaded`
);
}
private scheduleNextRefresh(): void {
const { refresh_interval_min_hours, refresh_interval_max_hours } =
config.getRawConfig().ai.model_catalog;
// Jittered rather than fixed so that many self-hosted instances don't
// all hit the upstream catalog endpoint at the same moment.
const minMs = refresh_interval_min_hours * 60 * 60 * 1000;
const maxMs = refresh_interval_max_hours * 60 * 60 * 1000;
const delayMs = minMs + Math.random() * Math.max(0, maxMs - minMs);
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(async () => {
await this.refresh();
this.scheduleNextRefresh();
}, delayMs);
}
}
export const aiModelCatalog = new AiModelCatalog();
export function listCatalogModelsForType(
type: AiProviderType,
query?: string
): { model: string }[] {
const catalogProvider = getCatalogProviderForType(type);
let models = catalogProvider
? aiModelCatalog.list(catalogProvider).map((entry) => ({
model: entry.model
}))
: [];
if (query) {
const q = query.toLowerCase();
models = models.filter((m) => m.model.toLowerCase().includes(q));
}
const seen = new Set<string>();
models = models.filter((m) => {
if (seen.has(m.model)) {
return false;
}
seen.add(m.model);
return true;
});
models.sort((a, b) => a.model.localeCompare(b.model));
return models;
}
/**
* Loads the AI model pricing catalog into memory and schedules periodic
* background refreshes. Call once at server startup.
*/
export async function initAiModelCatalog(): Promise<void> {
await aiModelCatalog.init();
}
+101
View File
@@ -0,0 +1,101 @@
const modelKeyRegexCache = new Map<string, RegExp>();
export function isModelKeyPattern(key: string): boolean {
return key.includes("*") || key.includes("?");
}
function getModelKeyRegex(pattern: string): RegExp {
let regex = modelKeyRegexCache.get(pattern);
if (!regex) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
regex = new RegExp(
`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`
);
modelKeyRegexCache.set(pattern, regex);
}
return regex;
}
export function modelKeyMatches(
pattern: string,
requestedModel: string
): boolean {
return getModelKeyRegex(pattern).test(requestedModel);
}
function wildcardCharCount(key: string): number {
let count = 0;
for (const char of key) {
if (char === "*" || char === "?") {
count += 1;
}
}
return count;
}
function literalLength(key: string): number {
return key.replace(/[*?]/g, "").length;
}
/**
* Sort comparator: more specific patterns sort before less specific ones
* (negative when `a` is more specific than `b`).
*
* 1. Exact keys beat patterns
* 2. Fewer wildcard characters win
* 3. Longer literal length wins
*/
export function compareModelKeySpecificity(a: string, b: string): number {
const aIsPattern = isModelKeyPattern(a);
const bIsPattern = isModelKeyPattern(b);
if (aIsPattern !== bIsPattern) {
return aIsPattern ? 1 : -1;
}
const wildcardDiff = wildcardCharCount(a) - wildcardCharCount(b);
if (wildcardDiff !== 0) {
return wildcardDiff;
}
return literalLength(b) - literalLength(a);
}
/**
* Provider-layer policy: empty allowlist denies all. Blocklist only applies
* after an allow match.
*/
export function isAllowedByLists(
requested: string,
allows: string[],
blocks: string[]
): boolean {
if (allows.length === 0) {
return false;
}
if (!allows.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
if (blocks.some((pattern) => modelKeyMatches(pattern, requested))) {
return false;
}
return true;
}
/**
* Among allow patterns that match `requested`, return the most specific one,
* or null if none match.
*/
export function mostSpecificMatchingAllow(
requested: string,
allows: string[]
): string | null {
const matching = allows.filter((pattern) =>
modelKeyMatches(pattern, requested)
);
if (matching.length === 0) {
return null;
}
matching.sort(compareModelKeySpecificity);
return matching[0];
}
+148
View File
@@ -0,0 +1,148 @@
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { AiUsage } from "@server/lib/aiUsageExtraction";
import {
aiModelCatalog,
getCatalogProviderForType,
type AiModelCatalogEntry,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
export type AiModelPricing = {
inputCostPerToken: number | null;
outputCostPerToken: number | null;
cacheReadInputTokenCost: number | null;
outputCostPerReasoningToken: number | null;
// True when the match came from a different catalog provider than the
// one mapped to this provider's type (e.g. an openRouter/custom model
// id that only matched a global search across every provider). Costs
// found this way are a best-effort approximation, not a guarantee the
// upstream provider bills at the same rate.
approximate: boolean;
};
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function toPricing(
entry: AiModelCatalogEntry,
approximate: boolean
): AiModelPricing {
return {
inputCostPerToken: entry.pricing.in,
outputCostPerToken: entry.pricing.out,
cacheReadInputTokenCost: entry.pricing.cache,
outputCostPerReasoningToken: entry.pricing.reasoning,
approximate
};
}
function findEntry(
modelId: string,
provider: CatalogProvider | null
): AiModelCatalogEntry | null {
const candidates = [modelId, stripVendorPrefix(modelId)].filter(
(v): v is string => v != null
);
for (const key of candidates) {
if (provider) {
const match = aiModelCatalog.get(provider, key);
if (match) {
return match;
}
continue;
}
const match = aiModelCatalog.listByKey(key)[0];
if (match) {
return match;
}
}
return null;
}
/**
* Looks up per-token pricing for a model, scoped first to the catalog
* provider that corresponds to our provider type, then falling back to a
* global search across every provider (marked `approximate`) for provider
* types that proxy arbitrary underlying models.
*/
export function getModelPricing(
providerType: AiProviderType,
modelId: string | undefined
): AiModelPricing | null {
if (!modelId) {
return null;
}
const catalogProvider = getCatalogProviderForType(providerType);
if (catalogProvider) {
const scoped = findEntry(modelId, catalogProvider);
if (scoped) {
return toPricing(scoped, false);
}
}
const fallback = findEntry(modelId, null);
if (fallback) {
return toPricing(fallback, true);
}
return null;
}
export type AiCostBreakdown = {
promptCost: number;
cacheReadCost: number;
cacheWriteCost: number;
completionCost: number;
reasoningCost: number;
totalCost: number;
};
/**
* Computes a $ cost breakdown for a usage record given a model's pricing.
* Cache writes and reasoning tokens fall back to the normal input/output
* rate respectively when the catalog has no dedicated rate for them (the
* catalog has no cache-write field at all, and only some models report a
* distinct reasoning rate).
*/
export function calculateAiCost(
pricing: AiModelPricing | null,
usage: AiUsage
): AiCostBreakdown | null {
if (!pricing) {
return null;
}
const inputRate = pricing.inputCostPerToken ?? 0;
const outputRate = pricing.outputCostPerToken ?? 0;
const cacheReadRate = pricing.cacheReadInputTokenCost ?? inputRate;
const reasoningRate = pricing.outputCostPerReasoningToken ?? outputRate;
const promptCost = usage.promptTokens * inputRate;
const cacheReadCost = usage.cacheReadTokens * cacheReadRate;
const cacheWriteCost = usage.cacheWriteTokens * inputRate;
const completionCost = usage.completionTokens * outputRate;
const reasoningCost = usage.reasoningTokens * reasoningRate;
return {
promptCost,
cacheReadCost,
cacheWriteCost,
completionCost,
reasoningCost,
totalCost:
promptCost +
cacheReadCost +
cacheWriteCost +
completionCost +
reasoningCost
};
}
+186
View File
@@ -0,0 +1,186 @@
import { decrypt, encrypt } from "@server/lib/crypto";
import {
parseCapabilities,
type AiCapability
} from "@server/lib/aiCapabilities";
import { stripVirtualApiKeyAuthHeaders } from "@app/lib/virtualApiKeyFormat";
import {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
} from "@app/lib/aiProviderDefaults";
export {
AI_PROVIDER_AUTH_TYPES,
AI_PROVIDER_DEFAULTS,
authTypeRequiresApiKey,
defaultsForProviderType,
providerRequiresUpstreamUrl,
type AiBudgetUnit,
type AiProviderAuthType,
type AiProviderRoutingMode,
type AiProviderType
};
const CONFLICTING_AUTH_HEADERS = [
"authorization",
"x-api-key",
"x-goog-api-key",
"cf-aig-authorization"
] as const;
export function resolveAiProviderCreateFields(input: {
type: AiProviderType;
upstreamUrl?: string | null;
authType?: AiProviderAuthType | null;
routingMode?: AiProviderRoutingMode | null;
}): {
upstreamUrl: string | null;
authType: AiProviderAuthType;
routingMode: AiProviderRoutingMode;
} {
const routingMode =
input.type === "custom" ? (input.routingMode ?? "url") : "url";
if (routingMode === "target") {
return {
upstreamUrl: null,
authType: input.authType ?? "bearer",
routingMode
};
}
if (input.type === "custom") {
return {
upstreamUrl: input.upstreamUrl ?? null,
authType: input.authType ?? "bearer",
routingMode
};
}
const defaults = AI_PROVIDER_DEFAULTS[input.type];
return {
upstreamUrl: input.upstreamUrl ?? defaults.upstreamUrl,
authType: input.authType ?? defaults.authType,
routingMode
};
}
export type AiProviderHeader = { name: string; value: string };
export function serializeAiProviderHeaders(
headers: AiProviderHeader[] | null | undefined,
secret: string
): string | null {
if (!headers || headers.length === 0) {
return null;
}
return encrypt(JSON.stringify(headers), secret);
}
export function parseAiProviderHeaders(
raw: string | null | undefined,
secret: string
): AiProviderHeader[] {
if (!raw) {
return [];
}
try {
const decrypted = decrypt(raw, secret);
const parsed = JSON.parse(decrypted);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(h): h is AiProviderHeader =>
h != null &&
typeof h === "object" &&
typeof h.name === "string" &&
typeof h.value === "string"
);
} catch {
return [];
}
}
export function applyAiProviderCustomHeaders(
headers: Record<string, string>,
raw: string | null | undefined,
secret: string
): void {
for (const { name, value } of parseAiProviderHeaders(raw, secret)) {
headers[name] = value;
}
}
/**
* Apply provider auth to upstream headers.
* - Always strips Pangolin virtual API key credentials from client auth headers.
* - Injected modes: strip conflicting client auth headers, then set the provider key.
* - none: strip conflicting client auth headers, send no auth.
* - passthrough: leave remaining client auth headers as-is (after VAK strip).
*/
export function applyAiProviderAuthHeaders(
headers: Record<string, string>,
authType: AiProviderAuthType,
apiKey: string | null
): void {
stripVirtualApiKeyAuthHeaders(headers);
if (authType === "passthrough") {
return;
}
for (const name of CONFLICTING_AUTH_HEADERS) {
for (const key of Object.keys(headers)) {
if (key.toLowerCase() === name) {
delete headers[key];
}
}
}
if (authType === "none") {
return;
}
if (!apiKey) {
throw new Error(`API key required for authType ${authType}`);
}
switch (authType) {
case "bearer":
headers["Authorization"] = `Bearer ${apiKey}`;
break;
case "x-api-key":
headers["x-api-key"] = apiKey;
break;
case "x-goog-api-key":
headers["x-goog-api-key"] = apiKey;
break;
case "hec":
headers["Authorization"] = `Splunk ${apiKey}`;
break;
case "cf-aig-authorization":
headers["cf-aig-authorization"] = `Bearer ${apiKey}`;
break;
}
}
export function resolveCapabilitiesForCreate(input: {
type: AiProviderType;
capabilities?: AiCapability[] | null;
}): AiCapability[] {
if (input.capabilities != null) {
return parseCapabilities(input.capabilities);
}
if (input.type === "custom") {
return [];
}
return [...AI_PROVIDER_DEFAULTS[input.type].capabilities];
}
+97
View File
@@ -0,0 +1,97 @@
import {
aiModelCatalog,
getCatalogProviderForType,
type CatalogProvider
} from "@server/lib/aiModelCatalog";
import type { AiProviderType } from "@server/lib/aiProviderDefaults";
function stripVendorPrefix(modelId: string): string | null {
const idx = modelId.indexOf("/");
if (idx === -1 || idx === modelId.length - 1) {
return null;
}
return modelId.slice(idx + 1);
}
function modelKeysToTry(modelId: string): string[] {
const keys = [modelId];
const stripped = stripVendorPrefix(modelId);
if (stripped) {
keys.push(stripped);
}
return keys;
}
function catalogOwnsModel(
catalogProvider: CatalogProvider,
modelId: string
): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.get(catalogProvider, key)) {
return true;
}
}
return false;
}
function modelKnownInAnyCatalog(modelId: string): boolean {
for (const key of modelKeysToTry(modelId)) {
if (aiModelCatalog.listByKey(key).length > 0) {
return true;
}
}
return false;
}
/**
* How strongly a provider "owns" a requested model id via the known catalog.
*
* 2 - Typed provider whose catalog contains the model
* 1 - Aggregator/custom that can proxy a catalog-known model
* 0 - No ownership signal (typed miss, or unknown model on aggregator/custom)
*/
export function catalogOwnershipScore(
type: AiProviderType,
modelId: string
): number {
const catalogProvider = getCatalogProviderForType(type);
if (catalogProvider != null) {
return catalogOwnsModel(catalogProvider, modelId) ? 2 : 0;
}
return modelKnownInAnyCatalog(modelId) ? 1 : 0;
}
/**
* Prefer native vendor providers over aggregators over custom when catalog
* ownership is tied.
*
* 2 - Native typed provider (openai, anthropic, gemini, ...)
* 1 - Aggregator gateway (openRouter, vercelAiGateway)
* 0 - Custom
*/
export function providerClassRank(type: AiProviderType): number {
if (type === "custom") {
return 0;
}
if (type === "openRouter" || type === "vercelAiGateway") {
return 1;
}
return 2;
}
export function keepBestScored<T>(
items: T[],
scoreFn: (item: T) => number
): T[] {
if (items.length <= 1) {
return items;
}
let best = Number.NEGATIVE_INFINITY;
for (const item of items) {
const score = scoreFn(item);
if (score > best) {
best = score;
}
}
return items.filter((item) => scoreFn(item) === best);
}
+482
View File
@@ -0,0 +1,482 @@
import { encode } from "gpt-tokenizer";
import type { AiCapability } from "@server/lib/aiCapabilities";
import logger from "@server/logger";
export type AiUsage = {
// Input tokens billed at the normal input rate (i.e. NOT already
// covered by cacheReadTokens/cacheWriteTokens below).
promptTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
// Output tokens billed at the normal output rate (i.e. NOT already
// covered by reasoningTokens below).
completionTokens: number;
reasoningTokens: number;
// True when these numbers are our own best-guess estimate (the upstream
// response didn't report usage), rather than provider-reported figures.
estimated: boolean;
};
function emptyUsage(): AiUsage {
return {
promptTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: 0,
reasoningTokens: 0,
estimated: false
};
}
/**
* Scans raw (possibly binary-framed, e.g. Bedrock's vnd.amazon.eventstream)
* text for `"fieldName":123` occurrences and returns the last value seen for
* each field. Used as a best-effort fallback for response shapes we can't
* fully parse as JSON/SSE (streaming Bedrock, raw predict passthroughs).
*/
function scanNumericFields(
text: string,
fields: string[]
): Record<string, number> {
const out: Record<string, number> = {};
for (const field of fields) {
const re = new RegExp(`"${field}"\\s*:\\s*(\\d+)`, "g");
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) {
out[field] = Number(match[1]);
}
}
return out;
}
// Exported for reuse by server/lib/aiMessageNormalization.ts, which needs
// the same SSE-frame/JSON-parsing groundwork to extract message content
// instead of usage numbers.
export function sseDataFrames(text: string): string[] {
const frames: string[] = [];
for (const rawFrame of text.split(/\r?\n\r?\n/)) {
for (const line of rawFrame.split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice("data:".length).trim();
if (data && data !== "[DONE]") {
frames.push(data);
}
}
}
return frames;
}
export function tryParseJson(text: string): any | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function extractOpenAiChat(text: string, isStream: boolean): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
usage = tryParseJson(text)?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0;
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.prompt_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.completion_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractOpenAiResponses(
text: string,
isStream: boolean
): AiUsage | null {
let usage: any = null;
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (
parsed?.type === "response.completed" &&
parsed?.response?.usage
) {
usage = parsed.response.usage;
} else if (parsed?.usage) {
usage = parsed.usage;
}
}
} else {
const parsed = tryParseJson(text);
usage = parsed?.usage ?? parsed?.response?.usage ?? null;
}
if (!usage) {
return null;
}
const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? 0;
const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? 0;
return {
promptTokens: Math.max(0, (usage.input_tokens ?? 0) - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: Math.max(
0,
(usage.output_tokens ?? 0) - reasoningTokens
),
reasoningTokens,
estimated: false
};
}
function extractAnthropicMessages(
text: string,
isStream: boolean
): AiUsage | null {
let inputTokens = 0;
let cacheReadTokens = 0;
let cacheWriteTokens = 0;
let outputTokens = 0;
let found = false;
const applyUsage = (usage: any) => {
if (!usage) return;
found = true;
if (typeof usage.input_tokens === "number") {
inputTokens = usage.input_tokens;
}
if (typeof usage.cache_read_input_tokens === "number") {
cacheReadTokens = usage.cache_read_input_tokens;
}
if (typeof usage.cache_creation_input_tokens === "number") {
cacheWriteTokens = usage.cache_creation_input_tokens;
}
if (typeof usage.output_tokens === "number") {
outputTokens = usage.output_tokens;
}
};
if (isStream) {
for (const frame of sseDataFrames(text)) {
const parsed = tryParseJson(frame);
if (!parsed) continue;
applyUsage(parsed.message?.usage);
applyUsage(parsed.usage);
}
} else {
applyUsage(tryParseJson(text)?.usage);
}
if (!found) {
return null;
}
return {
promptTokens: inputTokens,
cacheReadTokens,
cacheWriteTokens,
completionTokens: outputTokens,
// Anthropic bills extended-thinking output at the normal output
// rate, so there's no separate reasoning bucket to report.
reasoningTokens: 0,
estimated: false
};
}
function extractGoogleGenerateContent(
text: string,
_isStream: boolean
): AiUsage | null {
// Both the plain-JSON-array stream format and the SSE (?alt=sse) format
// repeat a cumulative `usageMetadata` object per chunk; the regex scan
// below naturally picks up the last (most complete) one either way.
const fields = scanNumericFields(text, [
"promptTokenCount",
"candidatesTokenCount",
"cachedContentTokenCount",
"thoughtsTokenCount"
]);
if (fields.promptTokenCount === undefined) {
return null;
}
const cacheReadTokens = fields.cachedContentTokenCount ?? 0;
const reasoningTokens = fields.thoughtsTokenCount ?? 0;
return {
promptTokens: Math.max(0, fields.promptTokenCount - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: 0,
completionTokens: fields.candidatesTokenCount ?? 0,
reasoningTokens,
estimated: false
};
}
function extractBedrockConverse(
text: string,
_isStream: boolean
): AiUsage | null {
// Non-streaming responses are plain JSON; converse-stream frames the
// final `metadata` event's usage object inside binary event-stream
// framing, but the JSON text survives intact inside that binary
// envelope, so the same field scan works for both.
const parsed = tryParseJson(text);
const usage = parsed?.usage;
if (usage) {
const cacheReadTokens = usage.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(
0,
(usage.inputTokens ?? 0) - cacheReadTokens
),
cacheReadTokens,
cacheWriteTokens: usage.cacheWriteInputTokens ?? 0,
completionTokens: usage.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const fields = scanNumericFields(text, [
"inputTokens",
"outputTokens",
"cacheReadInputTokens",
"cacheWriteInputTokens"
]);
if (fields.inputTokens === undefined) {
return null;
}
const cacheReadTokens = fields.cacheReadInputTokens ?? 0;
return {
promptTokens: Math.max(0, fields.inputTokens - cacheReadTokens),
cacheReadTokens,
cacheWriteTokens: fields.cacheWriteInputTokens ?? 0,
completionTokens: fields.outputTokens ?? 0,
reasoningTokens: 0,
estimated: false
};
}
function extractBedrockModelInvoke(
text: string,
_isStream: boolean,
headers: Headers
): AiUsage | null {
// Non-streaming invoke reports counts via response headers regardless
// of the underlying model's payload format.
const headerInput = headers.get("x-amzn-bedrock-input-token-count");
const headerOutput = headers.get("x-amzn-bedrock-output-token-count");
if (headerInput !== null || headerOutput !== null) {
return {
promptTokens: Number(headerInput ?? 0),
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: Number(headerOutput ?? 0),
reasoningTokens: 0,
estimated: false
};
}
// invoke-with-response-stream has no equivalent headers; the model's
// own usage shape (frequently Anthropic-style on Bedrock) is embedded
// inside binary event-stream framing, so fall back to a couple of
// known field-name shapes via regex.
const anthropicStyle = extractAnthropicMessages(text, true);
if (anthropicStyle) {
return anthropicStyle;
}
const fields = scanNumericFields(text, [
"inputTokenCount",
"outputTokenCount"
]);
if (fields.inputTokenCount === undefined) {
return null;
}
return {
promptTokens: fields.inputTokenCount,
cacheReadTokens: 0,
cacheWriteTokens: 0,
completionTokens: fields.outputTokenCount ?? 0,
reasoningTokens: 0,
estimated: false
};
}
const EXTRACTORS: Record<
AiCapability,
(text: string, isStream: boolean, headers: Headers) => AiUsage | null
> = {
openai_chat: extractOpenAiChat,
openai_responses: extractOpenAiResponses,
anthropic_messages: extractAnthropicMessages,
gemini_generate_content: extractGoogleGenerateContent,
google_generate_content: extractGoogleGenerateContent,
// rawPredict is a passthrough to whatever the underlying publisher
// model speaks (often Anthropic-shaped on Vertex); try that, then give
// up to the token-count estimate.
google_raw_predict: (text, isStream) =>
extractAnthropicMessages(text, isStream),
bedrock_model_invoke: extractBedrockModelInvoke,
bedrock_converse: extractBedrockConverse
};
/**
* Attempts to pull provider-reported token usage out of an upstream AI
* gateway response. Returns null if the response didn't contain (or we
* couldn't find) usage data, in which case callers should fall back to
* `estimateUsage`.
*/
export function extractUsage(
capability: AiCapability,
responseText: string,
isStream: boolean,
headers: Headers
): AiUsage | null {
try {
return EXTRACTORS[capability](responseText, isStream, headers);
} catch (error) {
logger.debug("Failed to extract AI usage from response", {
capability,
error
});
return null;
}
}
/**
* Best-guess token estimate for when the provider doesn't report usage.
* Uses OpenAI's BPE tokenizer as a stand-in for whatever tokenizer the
* actual model uses - close enough for an approximate cost figure, not
* exact for non-OpenAI models.
*/
export function estimateUsage(
promptText: string,
completionText: string
): AiUsage {
const usage = emptyUsage();
usage.estimated = true;
try {
usage.promptTokens = promptText ? encode(promptText).length : 0;
} catch (error) {
logger.debug("Failed to estimate prompt tokens", { error });
}
try {
usage.completionTokens = completionText
? encode(completionText).length
: 0;
} catch (error) {
logger.debug("Failed to estimate completion tokens", { error });
}
return usage;
}
/**
* OpenAI's Chat Completions API only includes a `usage` field in a
* streaming response when the request opts in via `stream_options:
* {include_usage: true}` - unlike the Responses API, Anthropic, Gemini and
* Bedrock, which report usage in a streaming response by default. Returns
* whether we need to inject that option ourselves to be able to track cost.
*/
export function needsStreamUsageInjection(
capability: AiCapability,
body: any
): boolean {
return (
capability === "openai_chat" &&
body?.stream === true &&
body?.stream_options?.include_usage !== true
);
}
/**
* Returns a shallow-cloned body with `stream_options.include_usage`
* injected, for capabilities/requests where `needsStreamUsageInjection`
* is true. Leaves the original body untouched.
*/
export function withStreamUsageOption(body: any): any {
return {
...body,
stream_options: { ...body.stream_options, include_usage: true }
};
}
/**
* When we injected stream_options.include_usage ourselves (the caller
* didn't ask for it), OpenAI appends an extra terminal SSE frame with an
* empty `choices: []` array carrying only the usage data. Callers that
* don't expect that shape (most minimal SSE parsers assume a non-empty
* choices array) shouldn't see it, so it's stripped back out of the bytes
* forwarded to the client.
*/
export function stripInjectedUsageFrame(sseText: string): string {
const parts = sseText.split(/(\r?\n\r?\n)/);
let out = "";
for (let i = 0; i < parts.length; i += 2) {
const frame = parts[i];
const separator = parts[i + 1] ?? "";
const dataLine = frame
.split(/\r?\n/)
.find((line) => line.startsWith("data:"));
if (dataLine) {
const data = dataLine.slice("data:".length).trim();
const parsed = data !== "[DONE]" ? tryParseJson(data) : null;
if (
parsed &&
Array.isArray(parsed.choices) &&
parsed.choices.length === 0 &&
parsed.usage
) {
continue;
}
}
out += frame + separator;
}
return out;
}
/**
* Best-effort extraction of the model the upstream provider actually
* served, which some gateways/routers echo back and which may differ from
* the model the caller requested (e.g. an alias resolving to a dated
* snapshot). Falls back to the caller's requested model when absent.
*/
export function extractResponseModel(responseText: string): string | null {
const match = responseText.match(/"model"\s*:\s*"([^"]+)"/);
return match ? match[1] : null;
}
export function isUsageEmpty(usage: AiUsage): boolean {
return (
usage.promptTokens === 0 &&
usage.cacheReadTokens === 0 &&
usage.cacheWriteTokens === 0 &&
usage.completionTokens === 0 &&
usage.reasoningTokens === 0
);
}
@@ -202,6 +202,10 @@ async function handleResource(
return;
}
if (!target.resourceId) {
return;
}
const [resource] = await trx
.select()
.from(resources)
@@ -227,9 +231,7 @@ async function handleResource(
let health = "healthy";
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allHealthy = monitoredTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
+4 -6
View File
@@ -10,7 +10,7 @@ export enum TierFeature {
ActionLogs = "actionLogs", // set the retention period to none on downgrade
ConnectionLogs = "connectionLogs",
RotateCredentials = "rotateCredentials",
MaintencePage = "maintencePage", // handle downgrade
MaintenancePage = "maintenancePage", // handle downgrade
DevicePosture = "devicePosture",
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
@@ -25,8 +25,7 @@ export enum TierFeature {
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
RoleBasedSSHControls = "roleBasedSSHControls"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -39,7 +38,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"],
[TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.MaintenancePage]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"],
[TierFeature.TwoFactorEnforcement]: [
"tier1",
@@ -69,6 +68,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
};
+89
View File
@@ -0,0 +1,89 @@
import { eq } from "drizzle-orm";
import { aiBudgets, Transaction } from "@server/db";
export type BlueprintAiBudgetInput = {
amount: number;
unit: "usd" | "tokens";
period:
| "monthly"
| "yearly"
| "lifetime"
| "daily"
| "hourly"
| "weekly";
enforcement: "hard" | "soft";
enabled: boolean;
};
type SyncAiBudgetsInput = {
orgId: string;
trx: Transaction;
budgets: BlueprintAiBudgetInput[];
} & (
| { scope: "public"; resourceId: number }
| { scope: "site"; siteResourceId: number }
);
/**
* Fully declarative: makes the resource's/site resource's AI budgets match
* exactly what the blueprint declares (omitted unit/period budgets are removed).
*/
export async function syncAiBudgets(input: SyncAiBudgetsInput): Promise<void> {
const { orgId, trx, budgets } = input;
const existing = await trx
.select()
.from(aiBudgets)
.where(
input.scope === "public"
? eq(aiBudgets.resourceId, input.resourceId)
: eq(aiBudgets.siteResourceId, input.siteResourceId)
);
const existingByKey = new Map(
existing.map((b) => [`${b.unit}::${b.period}`, b])
);
const seenKeys = new Set<string>();
const now = Date.now();
for (const budget of budgets) {
const key = `${budget.unit}::${budget.period}`;
seenKeys.add(key);
const existingBudget = existingByKey.get(key);
if (existingBudget) {
await trx
.update(aiBudgets)
.set({
amount: budget.amount,
enforcement: budget.enforcement,
enabled: budget.enabled,
updatedAt: now
})
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
} else {
await trx.insert(aiBudgets).values({
orgId,
resourceId: input.scope === "public" ? input.resourceId : null,
siteResourceId:
input.scope === "site" ? input.siteResourceId : null,
amount: budget.amount,
unit: budget.unit,
period: budget.period,
enforcement: budget.enforcement,
enabled: budget.enabled,
createdAt: now,
updatedAt: now
});
}
}
for (const [key, existingBudget] of existingByKey) {
if (!seenKeys.has(key)) {
await trx
.delete(aiBudgets)
.where(eq(aiBudgets.budgetId, existingBudget.budgetId));
}
}
}
+280
View File
@@ -0,0 +1,280 @@
import { and, eq, inArray } from "drizzle-orm";
import {
aiModels,
aiProviders,
resourceAiModels,
siteResourceAiModels,
Transaction
} from "@server/db";
import {
AccessMode,
ModelListType,
clearPublicResourceAiConfig,
clearSiteResourceAiConfig,
isInferenceFieldsError,
resolveProviderAttachments,
setPublicResourceAiProviders,
setSiteResourceAiProviders
} from "@server/lib/aiInferenceResource";
export type BlueprintAiModelInput = {
model: string;
listType: ModelListType;
};
export type BlueprintAiProviderInput = {
provider: string;
accessMode: AccessMode;
enabled: boolean;
models: string[];
};
async function resolveProviderNiceIds(
orgId: string,
niceIds: string[],
trx: Transaction
): Promise<Map<string, number>> {
const unique = [...new Set(niceIds)];
if (unique.length === 0) {
return new Map();
}
const rows = await trx
.select({
providerId: aiProviders.providerId,
niceId: aiProviders.niceId
})
.from(aiProviders)
.where(
and(
eq(aiProviders.orgId, orgId),
inArray(aiProviders.niceId, unique)
)
);
const byNiceId = new Map(rows.map((r) => [r.niceId, r.providerId]));
const missing = unique.filter((id) => !byNiceId.has(id));
if (missing.length > 0) {
throw new Error(
`AI provider(s) not found in this org: ${missing.join(", ")}`
);
}
return byNiceId;
}
async function resolveModelKeys(
providers: BlueprintAiProviderInput[],
providerIdByNiceId: Map<string, number>,
trx: Transaction
): Promise<Map<string, number>> {
const providerIds = [
...new Set(
providers
.filter((p) => p.models.length > 0)
.map((p) => providerIdByNiceId.get(p.provider)!)
)
];
if (providerIds.length === 0) {
return new Map();
}
const rows = await trx
.select({
modelId: aiModels.modelId,
modelKey: aiModels.modelKey,
providerId: aiModels.providerId
})
.from(aiModels)
.where(inArray(aiModels.providerId, providerIds));
const byProviderAndKey = new Map<string, number>();
for (const row of rows) {
byProviderAndKey.set(`${row.providerId}::${row.modelKey}`, row.modelId);
}
const modelIdByEntryKey = new Map<string, number>();
const missing: string[] = [];
for (const provider of providers) {
const providerId = providerIdByNiceId.get(provider.provider)!;
for (const m of provider.models) {
const modelId = byProviderAndKey.get(`${providerId}::${m}`);
if (modelId === undefined) {
missing.push(`${provider.provider}/${m}`);
continue;
}
modelIdByEntryKey.set(`${provider.provider}::${m}`, modelId);
}
}
if (missing.length > 0) {
throw new Error(`AI model(s) not found: ${missing.join(", ")}`);
}
return modelIdByEntryKey;
}
async function validateModelEntries(input: {
orgId: string;
entries: { modelId: number }[];
selectProviderIds: number[];
trx: Transaction;
}): Promise<void> {
if (input.entries.length === 0) {
return;
}
if (input.selectProviderIds.length === 0) {
throw new Error(
"Set at least one attached AI provider to access-mode 'select' before declaring models"
);
}
const modelIds = input.entries.map((e) => e.modelId);
const catalogRows = await input.trx
.select({
modelId: aiModels.modelId,
listType: aiModels.listType,
providerId: aiModels.providerId,
enabled: aiModels.enabled
})
.from(aiModels)
.innerJoin(aiProviders, eq(aiModels.providerId, aiProviders.providerId))
.where(
and(
inArray(aiModels.modelId, modelIds),
inArray(aiModels.providerId, input.selectProviderIds),
eq(aiProviders.orgId, input.orgId)
)
);
const catalogById = new Map(catalogRows.map((row) => [row.modelId, row]));
for (const entry of input.entries) {
const catalog = catalogById.get(entry.modelId);
if (!catalog) {
throw new Error(
`Model ${entry.modelId} does not exist or does not belong to a select-mode attached provider`
);
}
if (!catalog.enabled) {
throw new Error(
`Model ${entry.modelId} is disabled on its provider`
);
}
}
}
type SyncInferenceAiConfigInput = {
orgId: string;
trx: Transaction;
mode: string;
providers: BlueprintAiProviderInput[];
} & (
| { scope: "public"; resourceId: number }
| { scope: "site"; siteResourceId: number }
);
/**
* Fully declarative: makes the resource's attached AI providers/models match
* exactly what the blueprint declares (omitted providers/models are removed).
* Non-inference resources have any leftover AI config cleared.
*/
export async function syncInferenceAiConfig(
input: SyncInferenceAiConfigInput
): Promise<void> {
const { orgId, trx, mode } = input;
if (mode !== "inference") {
if (input.scope === "public") {
await clearPublicResourceAiConfig(input.resourceId, trx);
} else {
await clearSiteResourceAiConfig(input.siteResourceId, trx);
}
return;
}
const providerIdByNiceId = await resolveProviderNiceIds(
orgId,
input.providers.map((p) => p.provider),
trx
);
const resolvedAttachments = await resolveProviderAttachments({
orgId,
attachments: input.providers.map((p) => ({
providerId: providerIdByNiceId.get(p.provider)!,
accessMode: p.accessMode,
enabled: p.enabled
})),
requireAtLeastOne: false
});
if (isInferenceFieldsError(resolvedAttachments)) {
throw new Error(resolvedAttachments.error);
}
if (input.scope === "public") {
await setPublicResourceAiProviders(
input.resourceId,
resolvedAttachments,
trx
);
} else {
await setSiteResourceAiProviders(
input.siteResourceId,
resolvedAttachments,
trx
);
}
const modelIdByEntryKey = await resolveModelKeys(
input.providers,
providerIdByNiceId,
trx
);
const modelEntries = input.providers.flatMap((p) =>
p.models.map((m) => ({
modelId: modelIdByEntryKey.get(`${p.provider}::${m}`)!
}))
);
const selectProviderIds = resolvedAttachments
.filter((a) => a.accessMode === "select")
.map((a) => a.providerId);
await validateModelEntries({
orgId,
entries: modelEntries,
selectProviderIds,
trx
});
if (input.scope === "public") {
await trx
.delete(resourceAiModels)
.where(eq(resourceAiModels.resourceId, input.resourceId));
if (modelEntries.length > 0) {
await trx.insert(resourceAiModels).values(
modelEntries.map((m) => ({
resourceId: input.resourceId,
modelId: m.modelId
}))
);
}
} else {
await trx
.delete(siteResourceAiModels)
.where(
eq(siteResourceAiModels.siteResourceId, input.siteResourceId)
);
if (modelEntries.length > 0) {
await trx.insert(siteResourceAiModels).values(
modelEntries.map((m) => ({
siteResourceId: input.siteResourceId,
modelId: m.modelId
}))
);
}
}
}
+86
View File
@@ -0,0 +1,86 @@
import {
labels,
resourceLabels,
siteResourceLabels,
Transaction
} from "@server/db";
import logger from "@server/logger";
import { and, eq, sql } from "drizzle-orm";
// Matches the "gray" swatch in the label color palette used by the UI
// (src/components/labels-selector.tsx), used as the default for labels
// auto-created from a blueprint where no color is specified.
const DEFAULT_LABEL_COLOR = "#b4b4b4";
/**
* Looks up labels by name (case-insensitive) within an org, auto-creating
* any that don't already exist. Returns the resolved, de-duplicated labelIds.
*/
export async function getOrCreateLabelIds(
orgId: string,
labelNames: string[],
trx: Transaction
): Promise<number[]> {
const labelIds = new Set<number>();
for (const name of labelNames) {
let [label] = await trx
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
)
)
.limit(1);
if (!label) {
[label] = await trx
.insert(labels)
.values({ name, color: DEFAULT_LABEL_COLOR, orgId })
.returning({ labelId: labels.labelId });
logger.info(
`Auto-created label "${name}" in org ${orgId} from blueprint`
);
}
labelIds.add(label.labelId);
}
return Array.from(labelIds);
}
export async function syncResourceLabels(
resourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(resourceLabels)
.where(eq(resourceLabels.resourceId, resourceId));
if (labelIds.length > 0) {
await trx
.insert(resourceLabels)
.values(labelIds.map((labelId) => ({ resourceId, labelId })));
}
}
export async function syncSiteResourceLabels(
siteResourceId: number,
labelIds: number[],
trx: Transaction
) {
await trx
.delete(siteResourceLabels)
.where(eq(siteResourceLabels.siteResourceId, siteResourceId));
if (labelIds.length > 0) {
await trx
.insert(siteResourceLabels)
.values(
labelIds.map((labelId) => ({ siteResourceId, labelId }))
);
}
}
+140 -53
View File
@@ -19,20 +19,24 @@ import {
import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types";
import { getOrCreateLabelIds, syncSiteResourceLabels } from "./labels";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
fullDomain: string,
orgId: string,
isInference: boolean,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string }> {
const [fullDomainExists] = await trx
@@ -42,6 +46,11 @@ async function getDomainForSiteResource(
and(
eq(siteResources.fullDomain, fullDomain),
eq(siteResources.orgId, orgId),
// exclude looking at the ones on exit nodes if this is an inference resource,
// and vice versa, so inference and non-inference resources can share a full-domain
isInference
? ne(siteResources.mode, "inference")
: eq(siteResources.mode, "inference"),
siteResourceId
? ne(siteResources.siteResourceId, siteResourceId)
: isNotNull(siteResources.siteResourceId)
@@ -119,30 +128,6 @@ export async function updatePrivateResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
@@ -213,7 +198,7 @@ export async function updatePrivateResources(
resourceStatusFromSite = siteSingle.status ?? "approved";
}
if (allSites.length === 0) {
if (resourceData.mode !== "inference" && allSites.length === 0) {
throw new Error(
`No valid sites found for private private resource ${resourceNiceId} in org ${orgId}`
);
@@ -226,15 +211,27 @@ export async function updatePrivateResources(
? false
: resourceData.enabled;
const resourceSsl =
resourceData.mode === "inference" || resourceData.mode === "http"
? resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl
: resourceData.ssl;
if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
if (
resourceData["full-domain"] &&
(resourceData.mode === "http" ||
resourceData.mode === "inference")
) {
domainInfo = await getDomainForSiteResource(
existingResource.siteResourceId,
resourceData["full-domain"],
orgId,
resourceData.mode === "inference",
trx
);
}
@@ -264,13 +261,15 @@ export async function updatePrivateResources(
}
}
const isInference = resourceData.mode === "inference";
// Update existing resource
const [updatedResource] = await trx
.update(siteResources)
.set({
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
ssl: resourceData.ssl,
ssl: resourceSsl,
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
@@ -278,13 +277,15 @@ export async function updatePrivateResources(
alias: resourceData.alias || null,
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
(resourceData.mode == "http" || isInference
? true
: false), // default to true for http/inference resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
@@ -294,7 +295,9 @@ export async function updatePrivateResources(
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
status: resourceStatusFromSite,
networkId: isInference ? null : undefined,
requiresExitNodeConnection: isInference
})
.where(
eq(
@@ -306,7 +309,19 @@ export async function updatePrivateResources(
const siteResourceId = existingResource.siteResourceId;
if (updatedResource.networkId) {
if (isInference) {
// inference resources are not attached to any site network
if (existingResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
existingResource.networkId
)
);
}
} else if (updatedResource.networkId) {
await trx
.delete(siteNetworks)
.where(
@@ -321,6 +336,28 @@ export async function updatePrivateResources(
}
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode,
scope: "site",
siteResourceId,
providers: resourceData["ai-providers"].map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -443,6 +480,13 @@ export async function updatePrivateResources(
);
}
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({
newSiteResource: updatedResource,
oldSiteResource: existingResource,
@@ -493,14 +537,20 @@ export async function updatePrivateResources(
releaseAliasLock = release;
}
const isInference = resourceData.mode === "inference";
let domainInfo:
| { subdomain: string | null; domainId: string }
| undefined;
if (resourceData["full-domain"] && resourceData.mode === "http") {
if (
resourceData["full-domain"] &&
(resourceData.mode === "http" || isInference)
) {
domainInfo = await getDomainForSiteResource(
undefined,
resourceData["full-domain"],
orgId,
isInference,
trx
);
}
@@ -526,13 +576,16 @@ export async function updatePrivateResources(
}
}
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
let network: typeof networks.$inferSelect | undefined;
if (!isInference) {
[network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
}
// Create new resource
const [newResource] = await trx
@@ -540,11 +593,11 @@ export async function updatePrivateResources(
.values({
orgId: orgId,
niceId: resourceNiceId,
networkId: network.networkId,
defaultNetworkId: network.networkId,
networkId: network ? network.networkId : null,
defaultNetworkId: network ? network.networkId : null,
name: resourceData.name || resourceNiceId,
mode: resourceData.mode,
ssl: resourceData.ssl,
ssl: resourceSsl,
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
@@ -553,13 +606,15 @@ export async function updatePrivateResources(
aliasAddress: aliasAddress,
disableIcmp:
resourceData["disable-icmp"] ||
(resourceData.mode == "http" ? true : false), // default to true for http resources, otherwise false
(resourceData.mode == "http" || isInference
? true
: false), // default to true for http/inference resources, otherwise false
tcpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? "443,80"
: resourceData["tcp-ports"],
udpPortRangeString:
resourceData.mode == "http"
resourceData.mode == "http" || isInference
? ""
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
@@ -569,7 +624,8 @@ export async function updatePrivateResources(
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
status: resourceStatusFromSite,
requiresExitNodeConnection: isInference
})
.returning();
@@ -577,13 +633,37 @@ export async function updatePrivateResources(
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
if (network) {
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
}
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode,
scope: "site",
siteResourceId,
providers: resourceData["ai-providers"].map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "site",
siteResourceId,
budgets: resourceData["ai-budget"]
});
const [adminRole] = await trx
.select()
.from(roles)
@@ -697,6 +777,13 @@ export async function updatePrivateResources(
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels,
trx
);
await syncSiteResourceLabels(siteResourceId, labelIds, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,
+122 -36
View File
@@ -1,5 +1,5 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { hashPassword } from "@server/auth/password";
import { generateId } from "@server/auth/sessions/app";
import { build } from "@server/build";
@@ -50,11 +50,14 @@ import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
import { syncInferenceAiConfig } from "./aiProviders";
import { syncAiBudgets } from "./aiBudgets";
export type PublicResourcesResults = {
proxyResource: Resource;
@@ -259,18 +262,6 @@ export async function updatePublicResources(
headers = JSON.stringify(resourceData.headers);
}
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPublicResources
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
);
}
}
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
@@ -286,7 +277,9 @@ export async function updatePublicResources(
if (existingResource) {
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
["http", "ssh", "rdp", "vnc", "inference"].includes(
resourceData.mode || ""
)
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
@@ -304,6 +297,7 @@ export async function updatePublicResources(
existingResource.resourceId,
resourceData["full-domain"]!,
orgId,
resourceData.mode === "inference",
trx
);
@@ -325,7 +319,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
resourceData.maintenance = undefined;
@@ -370,14 +364,22 @@ export async function updatePublicResources(
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -566,14 +568,23 @@ export async function updatePublicResources(
.update(resources)
.set({
name: resourceData.name || "Unnamed Resource",
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
mode: resourceData.mode,
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -673,6 +684,30 @@ export async function updatePublicResources(
trx
);
}
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode || "",
scope: "public",
resourceId: existingResource.resourceId,
providers: (resourceData["ai-providers"] || []).map(
(p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
})
)
});
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: existingResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
}
const existingResourceTargets = await trx
@@ -753,7 +788,7 @@ export async function updatePublicResources(
: undefined),
rewritePathType: targetData["rewrite-match"],
priority: targetData.priority,
mode: resourceData.mode
mode: resourceData.mode as Target["mode"]
})
.where(eq(targets.targetId, existingTarget.targetId))
.returning();
@@ -1058,7 +1093,9 @@ export async function updatePublicResources(
let domain;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
["http", "ssh", "rdp", "vnc", "inference"].includes(
resourceData.mode || ""
)
) {
if (resourceData["full-domain"]?.startsWith("*.")) {
const isLicensed = await isLicensedOrSubscribed(
@@ -1076,6 +1113,7 @@ export async function updatePublicResources(
undefined,
resourceData["full-domain"]!,
orgId,
resourceData.mode === "inference",
trx
);
@@ -1088,7 +1126,7 @@ export async function updatePublicResources(
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.maintencePage
tierMatrix.maintenancePage
);
if (!isLicensed) {
resourceData.maintenance = undefined;
@@ -1155,14 +1193,22 @@ export async function updatePublicResources(
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
proxyPort: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? null
: resourceData["proxy-port"],
fullDomain: ["http", "ssh", "rdp", "vnc"].includes(
resourceData.mode || ""
)
fullDomain: [
"http",
"ssh",
"rdp",
"vnc",
"inference"
].includes(resourceData.mode || "")
? resourceData["full-domain"]
: null,
subdomain: domain ? domain.subdomain : null,
@@ -1217,6 +1263,28 @@ export async function updatePublicResources(
resource = newResource;
await syncInferenceAiConfig({
orgId,
trx,
mode: resourceData.mode || "",
scope: "public",
resourceId: newResource.resourceId,
providers: (resourceData["ai-providers"] || []).map((p) => ({
provider: p.provider,
accessMode: p["access-mode"],
enabled: p.enabled,
models: p.models
}))
});
await syncAiBudgets({
orgId,
trx,
scope: "public",
resourceId: newResource.resourceId,
budgets: resourceData["ai-budget"] || []
});
await trx.insert(roleResources).values({
roleId: adminRole.roleId,
resourceId: newResource.resourceId
@@ -1351,6 +1419,15 @@ export async function updatePublicResources(
logger.debug(`Created resource ${newResource.resourceId}`);
}
if (!isTargetsOnlyResource(resourceData)) {
const labelIds = await getOrCreateLabelIds(
orgId,
resourceData.labels || [],
trx
);
await syncResourceLabels(resource.resourceId, labelIds, trx);
}
results.push({
proxyResource: resource,
targetsToUpdate,
@@ -2065,6 +2142,7 @@ export async function getDomain(
resourceId: number | undefined,
fullDomain: string,
orgId: string,
isInference: boolean,
trx: Transaction
) {
const [fullDomainExists] = await trx
@@ -2074,6 +2152,14 @@ export async function getDomain(
and(
eq(resources.fullDomain, fullDomain),
eq(resources.orgId, orgId),
// Inference resources route through the central AI gateway
// rather than normal target-based proxying, so they're
// allowed to share a full-domain with a non-inference
// resource (and vice versa) - only conflicts within the
// same routing category are rejected.
isInference
? ne(resources.mode, "inference")
: eq(resources.mode, "inference"),
resourceId
? ne(resources.resourceId, resourceId)
: isNotNull(resources.resourceId)
+7 -14
View File
@@ -1,7 +1,5 @@
import {
db,
idp,
idpOrg,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
@@ -20,6 +18,7 @@ import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
@@ -71,19 +70,13 @@ export async function updateResourcePolicies(
// Validate auto-login-idp if provided
if (policyData["auto-login-idp"]) {
const [provider] = await trx
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(
eq(idp.idpId, policyData["auto-login-idp"]),
eq(idpOrg.orgId, orgId)
)
)
.limit(1);
const providerExists = await idpExistsForOrg(
policyData["auto-login-idp"],
orgId,
trx
);
if (!provider) {
if (!providerExists) {
throw new Error(
`Identity provider not found for policy '${policyNiceId}' in this organization`
);
+132 -12
View File
@@ -5,6 +5,11 @@ import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
aiBudgetEnforcementSchema,
aiBudgetPeriodSchema,
aiBudgetUnitSchema
} from "@server/routers/aiBudget/validation";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
@@ -28,7 +33,7 @@ export const TargetHealthCheckSchema = z.object({
hostname: z.string(),
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
path: z.string().optional(),
path: z.string().optional().default("/"),
scheme: z.string().optional(),
mode: z.string().default("http"),
interval: z.int().default(30),
@@ -183,6 +188,56 @@ export const HeaderSchema = z.object({
value: z.string().min(1)
});
export const AiProviderAttachmentSchema = z
.object({
provider: z.string().min(1),
"access-mode": z
.enum(["inherit", "select"])
.optional()
.default("inherit"),
enabled: z.boolean().optional().default(true),
models: z.array(z.string()).optional().default([])
})
.refine(
(provider) => {
if (provider.models.length === 0) {
return true;
}
return provider["access-mode"] === "select";
},
{
path: ["models"],
error: "'models' can only be set on a provider with access-mode 'select'"
}
);
export const AiBudgetSchema = z.object({
amount: z.number().positive(),
unit: aiBudgetUnitSchema,
period: aiBudgetPeriodSchema.optional().default("monthly"),
enforcement: aiBudgetEnforcementSchema.optional().default("hard"),
enabled: z.boolean().optional().default(true)
});
const aiBudgetArraySchema = z.array(AiBudgetSchema).refine(
(budgets) => {
const keys = budgets.map((b) => `${b.unit}::${b.period}`);
return keys.length === new Set(keys).size;
},
{
message:
"'ai-budget' entries must not overlap: only one budget per unit/period combination is allowed"
}
);
// No default here: an object with only 'targets' set must remain
// recognized as a targets-only resource by isTargetsOnlyResource().
export const AiBudgetListSchema = aiBudgetArraySchema.optional();
export const AiBudgetListSchemaWithDefault = aiBudgetArraySchema
.optional()
.default([]);
export const AuthDaemonSchema = z
.object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
@@ -209,7 +264,9 @@ export const PublicResourceSchema = z
protocol: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
mode: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc", "inference"])
.optional(),
policy: z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
@@ -225,7 +282,10 @@ export const PublicResourceSchema = z
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional()
"proxy-protocol-version": z.int().min(1).optional(),
labels: z.array(z.string().min(1)).optional(),
"ai-providers": z.array(AiProviderAttachmentSchema).optional(),
"ai-budget": AiBudgetListSchema
})
.refine(
(resource) => {
@@ -314,11 +374,13 @@ export const PublicResourceSchema = z
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
// If protocol/mode is http, ssh, rdp, vnc, or inference, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
["http", "ssh", "rdp", "vnc", "inference"].includes(
effectiveProtocol
)
) {
return (
resource["full-domain"] !== undefined &&
@@ -329,7 +391,43 @@ export const PublicResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode !== "inference") {
return true;
}
return resource.targets.every((target) => target == null);
},
{
path: ["targets"],
error: "When mode is 'inference', 'targets' must not be provided"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode === "inference") {
return true;
}
return (resource["ai-providers"]?.length ?? 0) === 0;
},
{
path: ["ai-providers"],
error: "'ai-providers' can only be set when mode is 'inference'"
}
)
.refine(
@@ -463,7 +561,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
export const PrivateResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http", "ssh"]),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
@@ -493,16 +591,26 @@ export const PrivateResourceSchema = z
}),
users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional()
labels: z.array(z.string().min(1)).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional(),
"ai-providers": z
.array(AiProviderAttachmentSchema)
.optional()
.default([]),
"ai-budget": AiBudgetListSchemaWithDefault
})
.refine(
(data) => {
// destination is optional only for ssh+native; required for everything else
// destination is optional only for ssh+native or inference; required for everything else
const isNativeSSH =
data.mode === "ssh" &&
(data["auth-daemon"] === undefined ||
data["auth-daemon"].mode === "native");
if (!isNativeSSH && !data.destination) {
if (
data.mode !== "inference" &&
!isNativeSSH &&
!data.destination
) {
return false;
}
return true;
@@ -510,7 +618,19 @@ export const PrivateResourceSchema = z
{
path: ["destination"],
message:
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
}
)
.refine(
(data) => {
if (data.mode === "inference") {
return true;
}
return (data["ai-providers"]?.length ?? 0) === 0;
},
{
path: ["ai-providers"],
error: "'ai-providers' can only be set when mode is 'inference'"
}
)
.refine(
@@ -632,7 +752,6 @@ export const ResourcePolicySchema = z.object({
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
@@ -838,3 +957,4 @@ export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
export type BlueprintAiBudget = z.infer<typeof AiBudgetSchema>;
+15 -15
View File
@@ -10,12 +10,12 @@ export const localCache = new NodeCache({
});
// Log cache statistics periodically for monitoring
setInterval(() => {
const stats = localCache.getStats();
logger.debug(
`Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
);
}, 300000); // Every 5 minutes
// setInterval(() => {
// const stats = localCache.getStats();
// logger.debug(
// `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%`
// );
// }, 300000); // Every 5 minutes
/**
* Adaptive cache that uses Redis when available in multi-node environments,
@@ -34,9 +34,9 @@ class AdaptiveCache {
// Use local cache as fallback or primary
const success = localCache.set(key, value, effectiveTtl || 0);
if (success) {
logger.debug(`Set key in local cache: ${key}`);
}
// if (success) {
// logger.debug(`Set key in local cache: ${key}`);
// }
return success;
}
@@ -48,11 +48,11 @@ class AdaptiveCache {
async get<T = any>(key: string): Promise<T | undefined> {
// Use local cache as fallback or primary
const value = localCache.get<T>(key);
if (value !== undefined) {
logger.debug(`Cache hit in local cache: ${key}`);
} else {
logger.debug(`Cache miss in local cache: ${key}`);
}
// if (value !== undefined) {
// logger.debug(`Cache hit in local cache: ${key}`);
// } else {
// logger.debug(`Cache miss in local cache: ${key}`);
// }
return value;
}
@@ -168,5 +168,5 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export const regionalCache = cache; // Alias for compatibility with the private version
export default cache;
-14
View File
@@ -339,19 +339,6 @@ export async function calculateUserClientsForOrgs(
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
@@ -370,7 +357,6 @@ export async function calculateUserClientsForOrgs(
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
+222 -12
View File
@@ -1,16 +1,226 @@
import config from "@server/lib/config";
import { certificates, db } from "@server/db";
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { regionalCache as cache } from "#dynamic/lib/cache";
import { build } from "@server/build";
// Define the return type for clarity and type safety
export type CertificateResult = {
id: number;
domain: string;
queriedDomain: string; // The domain that was originally requested (may differ for wildcards)
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
};
export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<
Array<{
id: number;
domain: string;
wildcard: boolean | null;
certFile: string | null;
keyFile: string | null;
expiresAt: number | null;
updatedAt?: number | null;
}>
> {
return []; // stub
): Promise<Array<CertificateResult>> {
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
// 1. Check cache first if enabled
if (useCache) {
for (const domain of domains) {
const cacheKey = `cert:${domain}`;
const cachedCert = await cache.get<CertificateResult>(cacheKey);
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
// Also check for a wildcard cache entry covering this domain's parent
const parts = domain.split(".");
let wildcardHit = false;
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
const wildcardCacheKey = `cert:*.${parentDomain}`;
const cachedWildcard =
await cache.get<CertificateResult>(wildcardCacheKey);
if (cachedWildcard) {
// Re-stamp queriedDomain so callers see the originally requested domain
finalResults.push({
...cachedWildcard,
queriedDomain: domain
});
wildcardHit = true;
}
}
if (!wildcardHit) {
domainsToQuery.add(domain); // Cache miss or expired
}
}
}
} else {
// If caching is disabled, add all domains to the query set
domains.forEach((d) => domainsToQuery.add(d));
}
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}
// 3. Prepare domains for the database query
const domainsToQueryArray = Array.from(domainsToQuery);
const parentDomainsToQuery = new Set<string>();
domainsToQueryArray.forEach((domain) => {
const parts = domain.split(".");
// A wildcard can only match a domain with at least two parts (e.g., example.com)
if (parts.length > 1) {
parentDomainsToQuery.add(parts.slice(1).join("."));
}
});
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray =
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
.select()
.from(certificates)
.where(
and(
eq(certificates.status, "valid"),
isNotNull(certificates.certFile),
isNotNull(certificates.keyFile),
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, [
...parentDomainsArray,
...wildcardPrefixedArray
]),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
sql`false`
)
)
);
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
const normalizeWildcardDomain = (domain: string): string =>
domain.startsWith("*.") ? domain.slice(2) : domain;
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
// Normalize to bare parent domain so lookups are consistent regardless of storage format
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
} else {
exactMatches.set(cert.domain, cert);
}
}
for (const domain of domainsToQuery) {
let foundCert: (typeof potentialCerts)[0] | undefined = undefined;
// Priority 1: Check for an exact match (non-wildcard)
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
else {
const normalizedDomain = normalizeWildcardDomain(domain);
if (wildcardMatches.has(normalizedDomain)) {
foundCert = wildcardMatches.get(normalizedDomain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = normalizedDomain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {
foundCert = wildcardMatches.get(parentDomain);
}
}
}
}
// If a certificate was found, format it, add to results, and cache it
if (foundCert) {
logger.debug(
`Creating result cert for ${domain} using cert from ${foundCert.domain}`
);
const resultCert: CertificateResult = {
id: foundCert.certId,
domain: foundCert.domain, // The actual domain of the cert record
queriedDomain: domain, // The domain that was originally requested
wildcard: foundCert.wildcard,
certFile: foundCert.certFile,
keyFile: foundCert.keyFile,
expiresAt: foundCert.expiresAt,
updatedAt: foundCert.updatedAt
};
finalResults.push(resultCert);
// Add to cache for future requests, using the *requested domain* as the key
if (useCache) {
const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180);
// Also cache wildcard certs under a pattern key so other subdomains
// can find them without a DB round-trip
if (resultCert.wildcard) {
const normalizedCertDomain = normalizeWildcardDomain(
resultCert.domain
);
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
await cache.set(wildcardCacheKey, resultCert, 180);
}
}
}
}
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}
function decryptFinalResults(
finalResults: CertificateResult[],
secret: string
): CertificateResult[] {
const validCertsDecrypted = finalResults.map((cert) => {
// Decrypt and save certificate file
const decryptedCert = decrypt(
cert.certFile!, // is not null from query
secret
);
// Decrypt and save key file
const decryptedKey = decrypt(cert.keyFile!, secret);
// Return only the certificate data without org information
return {
...cert,
certFile: decryptedCert,
keyFile: decryptedKey
};
});
return validCertsDecrypted;
}
+17 -4
View File
@@ -3,12 +3,14 @@ import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAu
import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit";
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt";
import { cleanUpOldLogs as cleanUpOldAiSessionLogs } from "@server/routers/aiGateway/logAiSession";
import { gt, or } from "drizzle-orm";
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
import { build } from "@server/build";
export function initLogCleanupInterval() {
if (build == "saas") { // skip log cleanup for saas builds
if (build == "saas") {
// skip log cleanup for saas builds
return null;
}
return setInterval(
@@ -23,7 +25,9 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysRequest:
orgs.settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection:
orgs.settingsLogRetentionDaysConnection
orgs.settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions:
orgs.settingsLogRetentionDaysAISessions
})
.from(orgs)
.where(
@@ -31,7 +35,8 @@ export function initLogCleanupInterval() {
gt(orgs.settingsLogRetentionDaysAction, 0),
gt(orgs.settingsLogRetentionDaysAccess, 0),
gt(orgs.settingsLogRetentionDaysRequest, 0),
gt(orgs.settingsLogRetentionDaysConnection, 0)
gt(orgs.settingsLogRetentionDaysConnection, 0),
gt(orgs.settingsLogRetentionDaysAISessions, 0)
)
);
@@ -42,7 +47,8 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysAction,
settingsLogRetentionDaysAccess,
settingsLogRetentionDaysRequest,
settingsLogRetentionDaysConnection
settingsLogRetentionDaysConnection,
settingsLogRetentionDaysAISessions
} = org;
if (settingsLogRetentionDaysAction > 0) {
@@ -72,6 +78,13 @@ export function initLogCleanupInterval() {
settingsLogRetentionDaysConnection
);
}
if (settingsLogRetentionDaysAISessions > 0) {
await cleanUpOldAiSessionLogs(
orgId,
settingsLogRetentionDaysAISessions
);
}
}
await cleanUpOldFingerprintSnapshots(365);
+16
View File
@@ -18,3 +18,19 @@ export function canCompress(
return false;
}
}
// Whether this newt client understands `tlsCertId` references into the
// sync message's `certs` array, instead of requiring each target to carry
// its own inline `tlsCert`/`tlsKey` PEM data. Bump the version floor here to
// match whatever release first ships the newt-side support.
export function supportsCertReferences(
clientVersion: string | null | undefined
): boolean {
try {
if (!clientVersion) return false;
if (!semver.valid(clientVersion)) return false;
return semver.gte(clientVersion, "1.16.0");
} catch {
return false;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.21.0";
export const APP_VERSION = "1.21.1";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
+3
View File
@@ -93,6 +93,9 @@ export async function deleteOrgById(
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
if (client.exitNodeId && client.pubKey) {
await deletePeer(client.exitNodeId, client.pubKey);
}
const [olm] = await trx
.select()
.from(olms)
+8 -1
View File
@@ -64,13 +64,20 @@ export async function performDeleteResources(
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
if (target.resourceId == null) {
continue;
}
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
targetsToBeRemoved.flatMap((target) =>
target.resourceId == null
? []
: [[target.targetId, target.resourceId] as const]
)
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
+5 -3
View File
@@ -1,4 +1,4 @@
import { and, eq, inArray, sql } from "drizzle-orm";
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
import {
db,
resources,
@@ -33,9 +33,11 @@ export async function getResourceIdsForSite(
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
.where(and(eq(targets.siteId, siteId), isNotNull(targets.resourceId)));
return rows.map((row) => row.resourceId);
return rows
.map((row) => row.resourceId)
.filter((resourceId): resourceId is number => resourceId != null);
}
export async function getSiteResourceIdsForSite(
@@ -0,0 +1,40 @@
import { db, sites, clients } from "@server/db";
import { and, eq, count } from "drizzle-orm";
// (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS)
// higher = more desirable
// like saying, this node has x% of its capacity left
export async function calculateExitNodeWeight(
exitNodeId: number,
maxConnections: number | null | undefined
): Promise<number | null> {
if (maxConnections === null || maxConnections === undefined) {
return 1;
}
const [[siteConnections], [clientConnections]] = await Promise.all([
db
.select({ count: count() })
.from(sites)
.where(
and(eq(sites.exitNodeId, exitNodeId), eq(sites.online, true))
),
db
.select({ count: count() })
.from(clients)
.where(
and(
eq(clients.exitNodeId, exitNodeId),
eq(clients.online, true)
)
)
]);
const currentConnections = siteConnections.count + clientConnections.count;
if (currentConnections >= maxConnections) {
return null;
}
return (maxConnections - currentConnections) / maxConnections;
}
+10 -1
View File
@@ -1,6 +1,5 @@
import { db, exitNodes, Transaction } from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm";
export async function verifyExitNodeOrgAccess(
@@ -52,6 +51,16 @@ export async function listExitNodes(
return allExitNodes;
}
export type ExitNodePingResult = {
exitNodeId: number;
latencyMs: number;
weight: number;
error?: string;
exitNodeName: string;
endpoint: string;
wasPreviouslyConnected: boolean;
};
export function selectBestExitNode(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
@@ -0,0 +1,41 @@
import { db, ExitNode, Transaction, sites, clients } from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getUniqueSubnetForExitNode(
exitNode: ExitNode,
trx: Transaction | typeof db = db
): Promise<string | null> {
const lockKey = `subnet-allocation:${exitNode.exitNodeId}`;
return await lockManager.withLock(
lockKey,
async () => {
const [sitesQuery, clientsQuery] = await Promise.all([
trx
.select({ subnet: sites.exitNodeSubnet })
.from(sites)
.where(eq(sites.exitNodeId, exitNode.exitNodeId)),
trx
.select({ subnet: clients.exitNodeSubnet })
.from(clients)
.where(eq(clients.exitNodeId, exitNode.exitNodeId))
]);
const blockSize = config.getRawConfig().gerbil.site_block_size;
const subnets = [...sitesQuery, ...clientsQuery]
.map((row) => row.subnet)
.filter(
(subnet): subnet is string =>
!!subnet &&
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet)
);
subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`));
return findNextAvailableCidr(subnets, blockSize, exitNode.address);
},
5000 // 5 second lock TTL - subnet allocation should be quick
);
}
+2
View File
@@ -2,3 +2,5 @@ export * from "./exitNodes";
export * from "./exitNodeComms";
export * from "./subnet";
export * from "./getCurrentExitNodeId";
export * from "./calculateExitNodeWeight";
export * from "./getUniqueSubnetForExitNode";
+37
View File
@@ -0,0 +1,37 @@
import { db, idp, idpOrg, Transaction } from "@server/db";
import { and, eq } from "drizzle-orm";
import { build } from "@server/build";
export function isOrgIdentityProviderMode(): boolean {
return build === "saas" || process.env.IDENTITY_PROVIDER_MODE === "org";
}
/**
* Checks whether an identity provider can be used for the given org.
* In org IdP mode, the provider must be linked via idpOrg.
* In global IdP mode, the provider only needs to exist.
*/
export async function idpExistsForOrg(
idpId: number,
orgId: string,
dbOrTrx: typeof db | Transaction = db
): Promise<boolean> {
if (isOrgIdentityProviderMode()) {
const [provider] = await dbOrTrx
.select({ idpId: idp.idpId })
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)))
.limit(1);
return !!provider;
}
const [provider] = await dbOrTrx
.select({ idpId: idp.idpId })
.from(idp)
.where(eq(idp.idpId, idpId))
.limit(1);
return !!provider;
}
+127 -16
View File
@@ -5,7 +5,8 @@ import config from "@server/lib/config";
import z from "zod";
import logger from "@server/logger";
import semver from "semver";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { createHash } from "crypto";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { lockManager } from "#dynamic/lib/lock";
interface IPRange {
@@ -527,7 +528,10 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
export function generateAliasConfig(
allSiteResources: SiteResource[],
overrideIp?: string
): Alias[] {
return allSiteResources
.filter(
(sr) =>
@@ -538,7 +542,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
)
.map((sr) => ({
alias: sr.alias || sr.fullDomain,
aliasAddress: sr.aliasAddress
aliasAddress: overrideIp || sr.aliasAddress
}));
}
@@ -648,21 +652,107 @@ export type SubnetProxyTargetV2 = {
httpTargets?: HTTPTarget[];
tlsCert?: string;
tlsKey?: string;
tlsCertId?: string; // references an entry in the sync message's top-level `certs` array instead of inlining tlsCert/tlsKey
};
export type CertRef = { id: string; cert: string; key: string };
/**
* Replaces each target's inline tlsCert/tlsKey with a tlsCertId reference
* into a deduplicated certs array, so that many targets sharing the same
* certificate (e.g. a wildcard cert used by thousands of site resources)
* only need that certificate sent once per sync message.
*/
export function dedupeCertsForTargets(targetsV2: SubnetProxyTargetV2[]): {
targets: SubnetProxyTargetV2[];
certs: CertRef[];
} {
const idByContent = new Map<string, string>();
const certs: CertRef[] = [];
const targets = targetsV2.map((target) => {
if (!target.tlsCert || !target.tlsKey) {
return target;
}
const contentKey = `${target.tlsCert}|${target.tlsKey}`;
let id = idByContent.get(contentKey);
if (!id) {
id = createHash("sha1")
.update(contentKey)
.digest("hex")
.slice(0, 16);
idByContent.set(contentKey, id);
certs.push({ id, cert: target.tlsCert, key: target.tlsKey });
}
const { tlsCert, tlsKey, ...rest } = target;
return { ...rest, tlsCertId: id };
});
return { targets, certs };
}
export type HTTPTarget = {
destAddr: string; // must be an IP or hostname
destPort: number;
scheme: "http" | "https";
};
export type CertByDomain = Map<string, { certFile: string; keyFile: string }>;
/**
* Fetches the TLS certificates for every enabled, SSL-enabled HTTP site
* resource's fullDomain in a single batched call, instead of one call per
* resource. Many resources commonly resolve to the very same certificate
* (e.g. a wildcard covering the org's domain), so batching turns what would
* be N concurrent DB/cache round-trips into one, and a lookup failure fails
* loudly for the whole batch rather than silently dropping the cert on a
* random subset of otherwise-identical resources under load.
*/
export async function batchFetchCertsForSiteResources(
allSiteResources: SiteResource[]
): Promise<CertByDomain> {
const domains = new Set(
allSiteResources
.filter(
(r) => r.enabled && r.mode === "http" && r.ssl && r.fullDomain
)
.map((r) => r.fullDomain as string)
);
const certByDomain: CertByDomain = new Map();
if (domains.size === 0) {
return certByDomain;
}
try {
const certResults = await getValidCertificatesForDomains(domains, true);
for (const cert of certResults) {
if (cert.certFile && cert.keyFile) {
certByDomain.set(cert.queriedDomain, {
certFile: cert.certFile,
keyFile: cert.keyFile
});
}
}
} catch (err) {
logger.error(
`Failed to batch-retrieve certificates for ${domains.size} domain(s): ${err}`
);
}
return certByDomain;
}
export async function generateSubnetProxyTargetV2(
siteResource: SiteResource,
clients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[]
}[],
certByDomain?: CertByDomain
): Promise<SubnetProxyTargetV2[] | undefined> {
if (!siteResource.enabled) {
logger.debug(
@@ -750,23 +840,44 @@ export async function generateSubnetProxyTargetV2(
let tlsKey: string | undefined;
if (siteResource.ssl && siteResource.fullDomain) {
try {
const certs = await getValidCertificatesForDomains(
new Set([siteResource.fullDomain]),
true
);
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
if (certByDomain) {
// Caller batch-fetched certs for all resources up front (the
// common, high-scale path) — just look up this resource's
// domain rather than issuing its own DB/cache round-trip.
const cert = certByDomain.get(siteResource.fullDomain);
if (cert) {
tlsCert = cert.certFile;
tlsKey = cert.keyFile;
} else {
logger.warn(
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
);
}
} catch (err) {
logger.error(
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
);
} else {
// No batched map supplied by the caller — fall back to a
// single-domain lookup for this resource alone.
try {
const certs = await getValidCertificatesForDomains(
new Set([siteResource.fullDomain]),
true
);
if (
certs.length > 0 &&
certs[0].certFile &&
certs[0].keyFile
) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
} else {
logger.warn(
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
);
}
} catch (err) {
logger.error(
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
);
}
}
}
+34 -3
View File
@@ -15,10 +15,41 @@ function getSegmentRegex(patternPart: string): RegExp {
return regex;
}
// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a
// real path separator, matching what most backends will do) and then
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment
// or a wildcard-swallowed sequence under `/public/*`.
function decodeAndResolvePath(p: string): string[] {
const rawParts = p.split("/").filter(Boolean);
const resolved: string[] = [];
for (const rawPart of rawParts) {
let part: string;
try {
part = decodeURIComponent(rawPart);
} catch {
part = rawPart;
}
// an encoded slash can turn one raw segment into several real ones
for (const segment of part.split("/").filter(Boolean)) {
if (segment === ".") {
continue;
} else if (segment === "..") {
resolved.pop();
} else {
resolved.push(segment);
}
}
}
return resolved;
}
export function isPathAllowed(pattern: string, path: string): boolean {
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
const patternParts = pattern.split("/").filter(Boolean);
const pathParts = decodeAndResolvePath(path);
function matchSegments(
patternIndex: number,
+116 -3
View File
@@ -79,7 +79,13 @@ export const configSchema = z
.default(3001)
.transform(stoi)
.pipe(portSchema),
ai_gateway_port: portSchema
.optional()
.default(3005)
.transform(stoi)
.pipe(portSchema),
badger_override: z.string().optional(),
ai_gateway_override: z.string().optional(),
next_port: portSchema
.optional()
.default(3002)
@@ -105,6 +111,23 @@ export const configSchema = z
})
.optional()
.prefault({}),
remote_headers: z
.object({
user_id: z
.string()
.optional()
.default("Remote-User-Id"),
virtual_api_key_id: z
.string()
.optional()
.default("Remote-Virtual-Api-Key-Id"),
user: z.string().optional().default("Remote-User"),
email: z.string().optional().default("Remote-Email"),
name: z.string().optional().default("Remote-Name"),
role: z.string().optional().default("Remote-Role")
})
.optional()
.prefault({}),
resource_session_request_param: z
.string()
.optional()
@@ -130,6 +153,24 @@ export const configSchema = z
})
.optional(),
trust_proxy: z.int().gte(0).optional().default(1),
// Opt-in: have Traefik/Badger stamp the resolved client IP
// into a dedicated header (X-Pangolin-Client-Ip) on the
// site-resource AI gateway route, so it survives an
// intermediary proxy between Traefik and the gateway that
// overwrites X-Forwarded-For/X-Real-Ip instead of appending
// to them. Off by default since it requires a Badger
// version that supports realIpHeader.
enable_ai_gateway_client_ip_header: z
.boolean()
.optional()
.default(false)
.transform((val) =>
process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER !==
undefined
? process.env.ENABLE_AI_GATEWAY_CLIENT_IP_HEADER ===
"true"
: val
),
secret: z.string().pipe(z.string().min(8)).optional(),
maxmind_db_path: z.string().optional(),
maxmind_asn_path: z.string().optional()
@@ -139,6 +180,7 @@ export const configSchema = z
integration_port: 3003,
external_port: 3000,
internal_port: 3001,
ai_gateway_port: 3005,
next_port: 3002,
internal_hostname: "pangolin",
session_cookie_name: "p_session_token",
@@ -147,11 +189,20 @@ export const configSchema = z
id: "P-Access-Token-Id",
token: "P-Access-Token"
},
remote_headers: {
user_id: "Remote-User-Id",
virtual_api_key_id: "Remote-Virtual-Api-Key-Id",
user: "Remote-User",
email: "Remote-Email",
name: "Remote-Name",
role: "Remote-Role"
},
resource_session_request_param:
"resource_session_request_param",
dashboard_session_length_hours: 720,
resource_session_length_hours: 720,
trust_proxy: 1
trust_proxy: 1,
enable_ai_gateway_client_ip_header: false
}),
postgres: z
.object({
@@ -258,7 +309,24 @@ export const configSchema = z
pp_transport_prefix: z
.string()
.optional()
.default("pp-transport-v")
.default("pp-transport-v"),
rate_limit: z
.object({
average: z
.number()
.positive()
.gt(0)
.optional()
.default(30),
burst: z
.number()
.positive()
.gt(0)
.optional()
.default(50)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
@@ -374,9 +442,54 @@ export const configSchema = z
disable_basic_wireguard_sites: z.boolean().optional(),
disable_config_managed_domains: z.boolean().optional(),
disable_product_help_banners: z.boolean().optional(),
disable_enterprise_features: z.boolean().optional()
disable_enterprise_features: z.boolean().optional(),
enable_acme_cert_sync: z.boolean().optional().default(true)
})
.optional(),
acme: z
.object({
acme_json_path: z
.string()
.optional()
.default("config/letsencrypt/acme.json"),
acme_http_endpoint: z.string().optional(),
sync_interval_ms: z.number().optional().default(5000)
})
.optional(),
ai: z
.object({
model_catalog: z
.object({
upstream_url: z
.url()
.optional()
.default("https://api.fossorial.io/api/v1/models"),
// No default - only used when an operator wants to
// pin the catalog to a local file instead of
// fetching it from upstream_url.
file: z.string().optional(),
// No default - only used when an operator wants to
// merge the content of the json file with the upstream catalog. This is useful for adding
// custom models to the catalog without having to maintain a separate fork of the upstream catalog.
merge_file: z.string().optional(),
refresh_interval_min_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(6),
refresh_interval_max_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(12)
})
.optional()
.prefault({})
})
.optional()
.prefault({}),
dns: z
.object({
nameservers: z
+325 -6
View File
@@ -19,7 +19,7 @@ import {
userOrgRoles,
userSiteResources
} from "@server/db";
import { and, count, eq, inArray, ne } from "drizzle-orm";
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
import {
@@ -27,6 +27,9 @@ import {
deletePeersBatch as olmDeletePeersBatch
} from "@server/routers/olm/peers";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { sendToClientsBatch } from "#dynamic/routers/ws";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
generateAliasConfig,
@@ -187,7 +190,12 @@ export async function getClientSiteResourceAccess(
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
);
if (sitesList.length === 0) {
if (sitesList.length === 0 && siteResource.networkId !== null) {
// A site resource with a networkId is expected to have at least one
// site attached via siteNetworks. Resources with no networkId (e.g.
// inference-mode resources, which connect clients directly to the
// exit node instead of any site) are expected to have no sites, so
// don't warn for those.
logger.warn(
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
);
@@ -687,6 +695,22 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove,
trx
);
// If this resource requires clients to be connected to the exit node
// (e.g. an inference resource), re-sync the connect/disconnect state for
// every client whose access to it may have changed - both those who
// currently have access and those who just lost it.
if (siteResource.requiresExitNodeConnection) {
await syncClientExitNodeConnections(
Array.from(
new Set([
...mergedAllClientIds,
...existingClientSiteResourceIds
])
),
trx
);
}
}
async function handleMessagesForSiteClients(
@@ -966,7 +990,7 @@ export async function updateClientSiteDestinations(
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
for (const site of sitesData) {
if (!site.sites.subnet) {
if (!site.sites.exitNodeSubnet) {
logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue;
}
@@ -1002,7 +1026,7 @@ export async function updateClientSiteDestinations(
sourcePort: parsedEndpoint.port,
destinations: [
{
destinationIP: site.sites.subnet.split("/")[0],
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
}
]
@@ -1010,7 +1034,7 @@ export async function updateClientSiteDestinations(
} else {
// add to the existing destinations
destinations.destinations.push({
destinationIP: site.sites.subnet.split("/")[0],
destinationIP: site.sites.exitNodeSubnet.split("/")[0],
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
});
}
@@ -1052,6 +1076,265 @@ export async function updateClientSiteDestinations(
}
}
// Determines, for each of the given clients, whether they currently have
// access to any enabled site resource with requiresExitNodeConnection set
// (e.g. an inference-mode resource) and tells the client's olm to connect to
// or disconnect from its assigned exit node accordingly. Site resources with
// requiresExitNodeConnection don't belong to any site/network, so this can't
// be derived from the per-site peer logic above - it has to be recomputed
// from the client's full current resource access every time that access
// changes.
async function syncClientExitNodeConnections(
clientIds: number[],
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
// Only clients with an exit node assigned can be told to connect/disconnect.
const clientsData = await trx
.select({
clientId: clients.clientId,
exitNodeId: clients.exitNodeId,
exitNodeSubnet: clients.exitNodeSubnet
})
.from(clients)
.where(
and(
inArray(clients.clientId, uniqueClientIds),
isNotNull(clients.exitNodeId)
)
);
if (clientsData.length === 0) {
return;
}
const clientIdsWithExitNode = clientsData.map((c) => c.clientId);
const requiresExitNodeRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
alias: siteResources.alias,
fullDomain: siteResources.fullDomain
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
siteResources,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
clientIdsWithExitNode
),
eq(siteResources.enabled, true),
eq(siteResources.requiresExitNodeConnection, true)
)
);
const needsConnectSet = new Set(
requiresExitNodeRows.map((r) => r.clientId)
);
// Aliases for every exit-node-backed resource this client can reach, so
// the live connect push carries the same alias list the register/reconnect
// path (buildSiteConfigurationForOlmClient) would compute.
const exitNodeAliasesByClientId = new Map<number, (string | null)[]>();
for (const row of requiresExitNodeRows) {
if (row.alias == null && row.fullDomain == null) continue;
const existing = exitNodeAliasesByClientId.get(row.clientId);
if (existing) {
existing.push(row.fullDomain || row.alias); // accept both for now in case we have other resource types that dont use the full domain
} else {
exitNodeAliasesByClientId.set(row.clientId, [
row.fullDomain || row.alias
]);
}
}
const exitNodeIds = Array.from(
new Set(
clientsData
.map((c) => c.exitNodeId)
.filter((id): id is number => id !== null)
)
);
const exitNodeRows =
exitNodeIds.length > 0
? await trx
.select()
.from(exitNodes)
.where(inArray(exitNodes.exitNodeId, exitNodeIds))
: [];
const exitNodeById = new Map(exitNodeRows.map((n) => [n.exitNodeId, n]));
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, clientIdsWithExitNode));
const olmByClientId = new Map(
olmRows
.filter((r) => r.clientId !== null)
.map((r) => [r.clientId as number, r])
);
const relayPort = config.getRawConfig().gerbil.clients_start_port;
const connectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean; incrementConfigVersion: boolean };
}[] = [];
const disconnectPayloads: {
clientId: string;
message: { type: string; data: any };
options: { compress: boolean; incrementConfigVersion: boolean };
}[] = [];
for (const client of clientsData) {
const olm = olmByClientId.get(client.clientId);
if (!olm) {
// No olm registered for this client yet/anymore, nothing to send.
continue;
}
const needsConnect = needsConnectSet.has(client.clientId);
if (needsConnect) {
const exitNode = client.exitNodeId
? exitNodeById.get(client.exitNodeId)
: undefined;
if (!exitNode || !client.exitNodeSubnet) {
logger.warn(
`rebuildClientAssociations: [syncClientExitNodeConnections] client ${client.clientId} needs an exit node connection but has no exit node or subnet assigned`
);
continue;
}
connectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/connect",
data: {
connect: true,
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort,
publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0],
tunnelIP: client.exitNodeSubnet.split("/")[0],
aliases:
exitNodeAliasesByClientId.get(client.clientId) ?? []
}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true
}
});
} else {
disconnectPayloads.push({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/disconnect",
data: {}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true
}
});
}
}
if (connectPayloads.length > 0) {
await sendToClientsBatch(connectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node connect messages:`,
error
);
});
}
if (disconnectPayloads.length > 0) {
await sendToClientsBatch(disconnectPayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node disconnect messages:`,
error
);
});
}
}
// Notifies the olms of every given client that the alias of the site resource
// they're using an exit node connection for has changed, via the dedicated
// exit node data-update message. Unlike syncClientExitNodeConnections, this
// doesn't touch connect/disconnect state - it's purely a rename for clients
// that are (and remain) connected to the exit node for this resource.
async function syncClientExitNodeAliasUpdate(
clientIds: number[],
oldAlias: string | null,
newAlias: string | null,
trx: Transaction | typeof db = db
): Promise<void> {
const uniqueClientIds = Array.from(new Set(clientIds));
if (uniqueClientIds.length === 0) {
return;
}
const oldAliases = oldAlias ? [oldAlias] : [];
const newAliases = newAlias ? [newAlias] : [];
if (oldAliases.length === 0 && newAliases.length === 0) {
return;
}
const olmRows = await trx
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, uniqueClientIds));
const updatePayloads = olmRows
.filter((r) => r.clientId !== null)
.map((olm) => ({
clientId: olm.olmId,
message: {
type: "olm/wg/exitnode/data/update",
data: {
oldAliases,
newAliases
}
},
options: {
compress: canCompress(olm.version, "olm"),
incrementConfigVersion: true // this is important information we would need to sync
}
}));
if (updatePayloads.length > 0) {
await sendToClientsBatch(updatePayloads).catch((error) => {
logger.error(
`rebuildClientAssociations: Error sending exit node alias update messages:`,
error
);
});
}
}
async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource,
sitesList: Site[],
@@ -1282,7 +1565,7 @@ export async function handleMessagingForUpdatedSiteResource(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
);
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
// !!!!!!!!!!!!!!!!!! WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED !!!!!!!!!!!!!!!!!!
const mergedAllClients = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -1709,6 +1992,38 @@ export async function handleMessagingForUpdatedSiteResource(
);
}
// For a resource that stays on an exit node connection across the update,
// the alias is the only field that affects already-connected clients (the
// exit node itself, its endpoint, etc. are not per-resource). Tell those
// clients' olms about the rename directly via the exit node data-update
// message rather than a full connect/disconnect cycle.
if (
existingSiteResource?.requiresExitNodeConnection &&
updatedSiteResource.requiresExitNodeConnection &&
aliasChanged
) {
await syncClientExitNodeAliasUpdate(
mergedAllClients.map((c) => c.clientId),
existingSiteResource.alias,
updatedSiteResource.alias,
trx
);
}
// If this resource requires (or required) clients to be connected to the
// exit node (e.g. an inference resource), re-sync connect/disconnect
// state for every client currently associated with it - covers toggling
// requiresExitNodeConnection on update as well as enabling/disabling it.
if (
updatedSiteResource.requiresExitNodeConnection ||
existingSiteResource?.requiresExitNodeConnection
) {
await syncClientExitNodeConnections(
mergedAllClients.map((c) => c.clientId),
trx
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
);
@@ -1990,6 +2305,10 @@ async function rebuildClientAssociationsFromClientImpl(
resourcesToRemove,
trx
);
// Re-sync exit node connect/disconnect state based on this client's
// current full set of resource access (e.g. inference resources).
await syncClientExitNodeConnections([client.clientId], trx);
}
async function handleMessagesForClientSites(
+120 -5
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, lt, asc, desc } from "drizzle-orm";
import { and, eq, gte, lt, asc, desc, inArray, max, sql } from "drizzle-orm";
import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -264,9 +264,7 @@ export function computeBuckets(
// Shift by the client's offset before formatting so the label reflects
// their local calendar date rather than the UTC date of dayStartSec
const dateStr = new Date(
(dayStartSec + tzOffsetMinutes * 60) * 1000
)
const dateStr = new Date((dayStartSec + tzOffsetMinutes * 60) * 1000)
.toISOString()
.slice(0, 10);
@@ -301,6 +299,123 @@ export function computeBuckets(
status
});
}
return { buckets, totalDowntime };
}
export type BatchedStatusHistoryResponse = Record<
string,
StatusHistoryResponse
>;
export async function getBatchedStatusHistory(
entityType: string,
entityIds: number[],
days: number,
tzOffsetMinutes: number = 0
): Promise<BatchedStatusHistoryResponse> {
// Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
// window aligns with stable calendar days for the requesting client
const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const startSec = todayMidnightSec - days * 86400;
const events = await logsDb
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
inArray(statusHistory.entityId, entityIds),
gte(statusHistory.timestamp, startSec)
)
)
.orderBy(asc(statusHistory.timestamp));
// Fetch the last known state before the window so that entities that
// haven't changed status recently still show the correct status rather
// than appearing as "no_data".
/**
* If we used only postgres, we would have used `SELECT DISTINCT ON` to get the
* latest event for each `entityId`,
* but it doesn't work on SQLite, so instead we use a subquery,
* the `ROW_NUMBER() OVER PARTITION` allows to assign a number
* to each row ordered by the timestamp, the number 1 is the first one appearing in
* the specified order, then the next and more, we only want the highest timestamp,
* so we get for `row_number=1`
*/
const lastKnowEventsSub = logsDb
.select({
entityId: statusHistory.entityId,
status: statusHistory.status,
timestamp: statusHistory.timestamp,
row_number:
sql<number>`ROW_NUMBER() OVER (PARTITION BY ${statusHistory.entityId} ORDER BY ${statusHistory.timestamp} DESC)`.as(
"row_number"
)
})
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
inArray(statusHistory.entityId, entityIds),
lt(statusHistory.timestamp, startSec)
)
)
.as("sub");
const lastKnownEvents = await logsDb
.select({
entityId: lastKnowEventsSub.entityId,
status: lastKnowEventsSub.status,
timestamp: lastKnowEventsSub.timestamp
})
.from(lastKnowEventsSub)
.where(eq(lastKnowEventsSub.row_number, 1));
const eventStatusMap: Record<
number,
{
events: typeof events;
lastKnownEvent: (typeof lastKnownEvents)[number] | null;
}
> = {};
for (const entityId of entityIds) {
eventStatusMap[entityId] = {
events: events.filter((ev) => ev.entityId === entityId),
lastKnownEvent:
lastKnownEvents.find((ev) => ev.entityId === entityId) ?? null
};
}
const result: BatchedStatusHistoryResponse = {};
for (const entityId in eventStatusMap) {
const event = eventStatusMap[Number(entityId)];
const priorStatus = event.lastKnownEvent?.status ?? null;
const { buckets, totalDowntime } = computeBuckets(
event.events,
days,
priorStatus,
tzOffsetMinutes
);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
? Math.max(
0,
((totalWindow - totalDowntime) / totalWindow) * 100
)
: 100;
result[entityId] = {
entityType,
entityId: Number(entityId),
days: buckets,
overallUptimePercent: Math.round(overallUptime * 100) / 100,
totalDowntimeSeconds: totalDowntime
};
}
return result;
}
+32 -3
View File
@@ -8,7 +8,7 @@ import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import { getTraefikConfig } from "#dynamic/lib/traefik";
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
import { getValidCertificatesForDomains } from "@server/lib/certificates";
import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
@@ -516,6 +516,11 @@ export class TraefikConfigManager {
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
const aiGatewayUrl =
config.getRawConfig().server.ai_gateway_override ||
`http://${maintenanceHost}:${
config.getRawConfig().server.ai_gateway_port
}`;
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
traefikConfig = await getTraefikConfig(
@@ -528,7 +533,8 @@ export class TraefikConfigManager {
? false
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
pangolinUIUrl, // generate browser gateway targets on cloud and hybrid
aiGatewayUrl
);
const domains = new Set<string>();
@@ -599,7 +605,30 @@ export class TraefikConfigManager {
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
.resource_session_request_param,
remoteUserIdHeader:
config.getRawConfig().server.remote_headers
.user_id,
remoteVirtualApiKeyIdHeader:
config.getRawConfig().server.remote_headers
.virtual_api_key_id,
remoteUserHeader:
config.getRawConfig().server.remote_headers
.user,
remoteEmailHeader:
config.getRawConfig().server.remote_headers
.email,
remoteNameHeader:
config.getRawConfig().server.remote_headers
.name,
remoteRoleHeader:
config.getRawConfig().server.remote_headers.role
}
}
};
+165
View File
@@ -0,0 +1,165 @@
import config from "@server/lib/config";
import {
AI_GATEWAY_TRUST_HEADER,
AI_GATEWAY_RESOURCE_TYPE_HEADER,
AI_GATEWAY_CLIENT_IP_HEADER,
getAiGatewayTrustToken
} from "@server/lib/aiGatewayTrust";
// The trust token is the same for every inference route on an exit node, so
// these middlewares are built once and attached to each inference router.
// Two variants exist (public resource vs. siteResource) so the resource
// type header lets the gateway know which kind of router the request came
// through without re-deriving it from resourceId.
export const AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE =
"ai-gateway-trust-headers-resource";
export const AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE =
"ai-gateway-trust-headers-site-resource";
// Opt-in: a Badger instance with forward auth disabled, used only to stamp
// the resolved client IP into a dedicated header before the request reaches
// whatever sits between Traefik and the AI gateway. Only the site-resource
// router needs this - it's the only path that resolves request identity
// from the client IP (see resolveRequestUser in aiGateway/pipeline.ts) -
// and it's the only inference router that doesn't already run Badger.
export const AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME = "ai-gateway-client-ip";
/**
* The AI gateway may live on a different host than the inference resource
* itself (e.g. a remote exit node forwarding to the central dashboard over
* a tunnel), so callers use this to decide whether to pin the Host header
* to the gateway's own host.
*/
export function getAiGatewayHost(aiGatewayUrl: string): string | undefined {
try {
return new URL(aiGatewayUrl).host;
} catch {
return undefined;
}
}
/**
* Header middleware that pins the Host header to the AI gateway's own host
* (when it differs from the resource's) and smuggles the original resource
* host through in "p-host" instead, so passHostHeader can't leak the wrong
* Host to a gateway that lives on a different host than the resource.
*/
export function buildAiGatewayHostHeaderMiddleware(
aiGatewayHost: string | undefined,
fullDomain: string
): { headers: { customRequestHeaders: Record<string, string> } } {
return {
headers: {
customRequestHeaders: {
...(aiGatewayHost ? { Host: aiGatewayHost } : {}),
"p-host": fullDomain
}
}
};
}
export function buildAiGatewayTrustMiddlewares(): Record<string, any> {
const token = getAiGatewayTrustToken();
return {
[AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE]: {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: token,
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "resource"
}
}
},
[AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE]: {
headers: {
customRequestHeaders: {
[AI_GATEWAY_TRUST_HEADER]: token,
[AI_GATEWAY_RESOURCE_TYPE_HEADER]: "site-resource"
}
}
}
};
}
export function buildAiGatewayClientIpMiddleware(): Record<string, any> | null {
const enabled =
config.getRawConfig().server.enable_ai_gateway_client_ip_header;
if (!enabled) {
return null;
}
return {
[AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]: {
plugin: {
badger: {
disableForwardAuth: true,
realIpHeader: AI_GATEWAY_CLIENT_IP_HEADER
}
}
}
};
}
/**
* Build the redirect (if ssl), main router, and single-server service for
* an AI-gateway-backed inference router. Identical between the public
* inference-resource and siteResource-inference cases, and between the OSS
* and private config generators - only the rule/tls/middleware chain
* differs, which callers resolve themselves beforehand.
*/
export function buildAiGatewayRouterAndService(params: {
routerName: string;
serviceName: string;
rule: string;
ssl: boolean | null;
tls: any;
priority: number;
routerMiddlewares: string[];
aiGatewayUrl: string;
redirectHttpsMiddlewareName: string;
}): { routers: Record<string, any>; services: Record<string, any> } {
const {
routerName,
serviceName,
rule,
ssl,
tls,
priority,
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
} = params;
const routers: Record<string, any> = {};
if (ssl) {
routers[`${routerName}-redirect`] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule,
priority
};
}
routers[routerName] = {
entryPoints: [
ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: serviceName,
rule,
priority,
...(ssl ? { tls } : {})
};
const services = {
[serviceName]: {
loadBalancer: {
servers: [{ url: aiGatewayUrl }]
}
}
};
return { routers, services };
}
+399
View File
@@ -0,0 +1,399 @@
import config from "@server/lib/config";
import { sanitize } from "./utils";
export type BrowserGatewayResourceRow = {
resourceId: number;
resourceName: string | null;
mode: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
maintenanceModeEnabled: boolean | null;
maintenanceModeType: string | null;
maintenanceTitle: string | null;
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targetId: number;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
// Cloud-only namespace field - absent on OSS rows, so the namespace
// filter below naturally no-ops there.
domainNamespaceId?: unknown;
};
export type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
maintenanceModeEnabled: boolean | null;
maintenanceModeType: string | null;
maintenanceTitle: string | null;
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targets: {
targetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
}[];
};
/**
* Group the raw resource/target/site rows into per-resource browser-gateway
* entries (SSH/VNC/RDP-mode resources served through the browser gateway
* web UI instead of a real backend target).
*/
export function buildBrowserGatewayResourcesMap(
rows: BrowserGatewayResourceRow[],
filterOutNamespaceDomains: boolean
): Map<number, BrowserGatewayResourceEntry> {
const map = new Map<number, BrowserGatewayResourceEntry>();
for (const row of rows) {
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
continue;
}
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!map.has(row.resourceId)) {
map.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName ?? undefined) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
targets: []
});
}
map.get(row.resourceId)!.targets.push({
targetId: row.targetId,
bgType: row.mode,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet
});
}
return map;
}
/**
* Build the Traefik routers/services for browser-gateway resources
* (SSH/VNC/RDP served via a browser-based client instead of a raw target),
* mutating config_output. TLS/cert-resolver handling differs between the
* OSS (always resolve directly) and private (pangolin-dns aware) config
* generators, so callers resolve that themselves via resolveTls - returning
* null skips the resource (no valid cert available yet).
*/
export function buildBrowserGatewayConfig(params: {
config_output: any;
browserGatewayResourcesMap: Map<number, BrowserGatewayResourceEntry>;
browserGatewayUiUrl: string;
maintenancePageUiUrl: string | null;
badgerMiddlewareName: string;
redirectHttpsMiddlewareName: string;
resolveTls: (args: {
fullDomain: string;
hasSubdomain: boolean;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
}) => any | null;
}): void {
const {
config_output,
browserGatewayResourcesMap,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls
} = params;
const bgRateLimitMiddlewareName = "bg-ratelimit";
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
if (!config_output.http.middlewares[bgRateLimitMiddlewareName]) {
const traefikRateLimit = config.getRawConfig().traefik.rate_limit;
config_output.http.middlewares[bgRateLimitMiddlewareName] = {
rateLimit: {
average: traefikRateLimit.average,
burst: traefikRateLimit.burst
}
};
}
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
bgRateLimitMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
const tls = resolveTls({
fullDomain,
hasSubdomain: !!bgResource.subdomain,
domainCertResolver: bgResource.domainCertResolver,
preferWildcardCert: bgResource.preferWildcardCert
});
if (tls === null) {
continue;
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Maintenance page logic for browser gateway resources
let showBgMaintenancePage = false;
if (bgResource.maintenanceModeEnabled) {
if (bgResource.maintenanceModeType === "forced") {
showBgMaintenancePage = true;
} else if (bgResource.maintenanceModeType === "automatic") {
showBgMaintenancePage = !anySiteOnline;
}
}
if (showBgMaintenancePage && maintenancePageUiUrl) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
const bgMaintenanceHeadersMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-headers`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
if (!config_output.http.services) config_output.http.services = {};
if (!config_output.http.middlewares)
config_output.http.middlewares = {};
if (!config_output.http.routers) config_output.http.routers = {};
config_output.http.services![bgMaintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: maintenancePageUiUrl
}
],
passHostHeader: true
}
};
config_output.http.middlewares![bgRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
}
};
config_output.http.middlewares![
bgMaintenanceHeadersMiddlewareName
] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [
bgRewriteMiddlewareName,
bgMaintenanceHeadersMiddlewareName
],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
};
// Router to allow Next.js assets to load without rewrite
config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgMaintenanceHeadersMiddlewareName],
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
};
continue;
}
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter((v, i, a) => a.findIndex((u) => u.url === v.url) === i);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const uiHeadersMiddlewareName = `bg-r${bgResource.resourceId}-ui-headers`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.middlewares![uiHeadersMiddlewareName] = {
headers: {
customRequestHeaders: {
Host: "app.pangolin.net", // if we are sending to the cloud the host needs to be this but we will pull the p-host to find the resource
"p-host": fullDomain
}
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: browserGatewayUiUrl
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite.
// Do NOT apply the path-rewrite middleware here — static assets must
// keep their original path; only the host headers are needed.
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiHeadersMiddlewareName],
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![`bg-r${bgResource.resourceId}-ui-router`] =
{
entryPoints: [entrypoint],
middlewares: [
...routerMiddlewares,
uiRewriteMiddlewareName,
uiHeadersMiddlewareName
],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
}
+44
View File
@@ -0,0 +1,44 @@
import config from "@server/lib/config";
/**
* Build the Traefik `tls` block for a domain using the cert-resolver /
* wildcard-cert logic shared by both the OSS and private Traefik config
* generators (used whenever certs are obtained directly via ACME rather
* than through pangolin-dns).
*/
export function buildWildcardTls(params: {
fullDomain: string;
hasSubdomain: boolean;
domainCertResolver?: string | null;
preferWildcardCert?: boolean | null;
}): { certResolver: string | undefined; domains?: { main: string }[] } {
const { fullDomain, hasSubdomain, domainCertResolver, preferWildcardCert } =
params;
const domainParts = fullDomain.split(".");
let wildCard =
domainParts.length <= 2
? `*.${domainParts.join(".")}`
: `*.${domainParts.slice(1).join(".")}`;
if (!hasSubdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver = config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = domainCertResolver
? domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
preferWildcardCert !== undefined && preferWildcardCert !== null
? preferWildcardCert
: globalDefaultPreferWildcard;
return {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
}
+397 -344
View File
@@ -1,4 +1,13 @@
import { db, targetHealthCheck, domains } from "@server/db";
import {
db,
targetHealthCheck,
domains,
aiProviders,
resourceAiProviders,
siteResources,
siteNetworks,
exitNodes
} from "@server/db";
import {
and,
eq,
@@ -12,41 +21,64 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, Target, targets } from "@server/db";
import createPathRewriteMiddleware from "./middleware";
import { resources, sites, targets } from "@server/db";
import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import {
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
} from "./loadBalancer";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayResourcesMap,
buildBrowserGatewayConfig
} from "./browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
// Define extended target type with site information
type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[],
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
allowRawResources = true,
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
maintenancePageUiUrl: string | null = null,
browserGatewayUiUrl: string | null = null,
aiGatewayUrl: string | null = null
): Promise<any> {
// Get the exit node but cache it for 5 minutes to avoid hitting the DB too often
const exitNodeCacheKey = `exitNode:${exitNodeId}`;
let exitNode =
await regionalCache.get<typeof exitNodes.$inferSelect>(
exitNodeCacheKey
);
if (!exitNode) {
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId))
.limit(1);
await regionalCache.set(exitNodeCacheKey, exitNode, 300);
}
// Get resources with their targets and sites in a single optimized query
// Start from sites on this exit node, then join to targets and resources
const resourcesWithTargetsAndSites = await db
@@ -67,8 +99,15 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Target fields
targetId: targets.targetId,
targetEnabled: targets.enabled,
@@ -87,7 +126,7 @@ export async function getTraefikConfig(
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
@@ -115,8 +154,15 @@ export async function getTraefikConfig(
),
inArray(sites.type, siteTypes),
allowRawResources
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.mode, "http")
? inArray(resources.mode, [
"http",
"udp",
"tcp",
"vnc",
"ssh",
"rdp"
]) // allow all three, plus browser-gateway modes
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
)
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -125,6 +171,9 @@ export async function getTraefikConfig(
const resourcesMap = new Map();
resourcesWithTargetsAndSites.forEach((row) => {
if (!["http", "tcp", "udp"].includes(row.mode)) {
return;
}
const resourceId = row.resourceId;
const resourceName = sanitize(row.resourceName) || "";
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
@@ -209,8 +258,81 @@ export async function getTraefikConfig(
});
});
// Group browser gateway targets by resource (SSH/VNC/RDP-mode resources
// served through the browser gateway web UI instead of a real target).
const browserGatewayResourcesMap = browserGatewayUiUrl
? buildBrowserGatewayResourcesMap(
resourcesWithTargetsAndSites,
filterOutNamespaceDomains
)
: new Map();
// Query siteResources in HTTP mode with SSL enabled and aliases, so
// Traefik generates TLS certificates for those domains even before a
// matching resource exists.
const siteResourcesWithFullDomain = await db
.select({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain),
eq(siteResources.mode, "http"), // important so we dont double get the inference siteResources below
eq(siteResources.ssl, true),
eq(sites.exitNodeId, exitNodeId),
inArray(sites.type, siteTypes)
)
);
// Inference-mode resources have no targets/sites (their "backend" is the
// central AI gateway), so they can't be reached via the targets->sites
// join above - query them separately and include them on every exit node.
const inferenceResources = await db
.selectDistinct({
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(resources)
// .innerJoin(
// resourceAiProviders,
// eq(resources.resourceId, resourceAiProviders.resourceId)
// )
// .innerJoin(
// aiProviders,
// eq(resourceAiProviders.providerId, aiProviders.providerId)
// )
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.where(
and(
eq(resources.mode, "inference"),
eq(resources.enabled, true)
// eq(aiProviders.enabled, true)
)
);
// make sure we have at least one resource
if (resourcesMap.size === 0) {
if (
resourcesMap.size === 0 &&
inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0
) {
return {};
}
@@ -254,56 +376,12 @@ export async function getTraefikConfig(
config_output.http.services = {};
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!resource.subdomain) {
wildCard = resource.fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
// Handle both letsencrypt & custom cases
if (domainCertResolver) {
resolverName = domainCertResolver.trim();
} else {
resolverName = globalDefaultResolver;
}
if (
preferWildcardCert !== undefined &&
preferWildcardCert !== null
) {
preferWildcard = preferWildcardCert;
} else {
preferWildcard = globalDefaultPreferWildcard;
}
const tls = {
certResolver: resolverName,
...(preferWildcard
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!resource.subdomain,
domainCertResolver: resource.domainCertResolver,
preferWildcardCert: resource.preferWildcardCert
});
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
@@ -314,134 +392,40 @@ export async function getTraefikConfig(
];
// Handle path rewriting middleware
if (
resource.rewritePath !== null &&
resource.path !== null &&
resource.pathMatchType &&
resource.rewritePathType
) {
// Create a unique middleware name
const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType
);
// Initialize middlewares object if it doesn't exist
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// the middleware to the config
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
// middlewares to the router middleware chain
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
// logger.debug(
// `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})`
// );
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}`
);
}
}
applyPathRewriteMiddleware(
config_output,
resource.resourceId,
key,
resource.path,
resource.pathMatchType,
resource.rewritePath,
resource.rewritePathType,
routerMiddlewares
);
// Handle custom headers middleware
if (resource.headers || resource.setHostHeader) {
const headersObj: { [key: string]: string } = {};
if (resource.headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(resource.headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resource.resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (resource.setHostHeader) {
headersObj["Host"] = resource.setHostHeader;
}
if (Object.keys(headersObj).length > 0) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] = {
headers: {
customRequestHeaders: headersObj
}
};
routerMiddlewares.push(headersMiddlewareName);
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.headers,
resource.setHostHeader,
resource.resourceId
);
if (customHeadersMiddleware) {
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[headersMiddlewareName] =
customHeadersMiddleware;
routerMiddlewares.push(headersMiddlewareName);
}
// Build routing rules
let rule = `Host(\`${fullDomain}\`)`;
// priority logic
let priority: number;
if (resource.priority && resource.priority != 100) {
priority = resource.priority;
} else {
priority = 100;
if (resource.path && resource.pathMatchType) {
priority += 10;
if (resource.pathMatchType === "exact") {
priority += 5;
} else if (resource.pathMatchType === "prefix") {
priority += 3;
} else if (resource.pathMatchType === "regex") {
priority += 2;
}
if (resource.path === "/") {
priority = 1; // lowest for catch-all
}
}
}
if (resource.path && resource.pathMatchType) {
// priority += 1;
// add path to rule based on match type
let path = resource.path;
// if the path doesn't start with a /, add it
if (!path.startsWith("/")) {
path = `/${path}`;
}
if (resource.pathMatchType === "exact") {
rule += ` && Path(\`${path}\`)`;
} else if (resource.pathMatchType === "prefix") {
rule += ` && PathPrefix(\`${path}\`)`;
} else if (resource.pathMatchType === "regex") {
rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex
}
}
let rule = buildHostRule(fullDomain);
const priority = computeRoutePriority(
resource.priority,
resource.path,
resource.pathMatchType
);
rule = appendPathMatch(rule, resource.path, resource.pathMatchType);
config_output.http.routers![routerName] = {
entryPoints: [
@@ -470,90 +454,9 @@ export async function getTraefikConfig(
config_output.http.services![serviceName] = {
loadBalancer: {
servers: (() => {
// Check if any sites are online
// THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK
// EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE
// RECEIVE BANDWIDTH ENDPOINT.
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) => target.site.online
);
return (
targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
// filter out duplicates
.filter(
(v, i, a) =>
a.findIndex(
(t) => t && v && t.url === v.url
) === i
)
);
})(),
servers: buildHttpLoadBalancerServers(targets),
...(resource.stickySession
? {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}
}
}
? buildStickySessionCookie(resource.ssl)
: {})
}
};
@@ -603,75 +506,225 @@ export async function getTraefikConfig(
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) => target.site.online
);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip =
target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
})(),
servers: buildTcpUdpLoadBalancerServers(targets),
...(resource.proxyProtocol && protocol == "tcp"
? {
serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues?
}
: {}),
...(resource.stickySession
? {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
}
: {})
...(resource.stickySession ? buildStickySessionIp() : {})
}
};
}
}
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
browserGatewayResourcesMap,
browserGatewayUiUrl,
maintenancePageUiUrl,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: ({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
}) =>
buildWildcardTls({
fullDomain,
hasSubdomain,
domainCertResolver,
preferWildcardCert
})
});
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
if (siteResourcesWithFullDomain.length > 0) {
// Build a set of domains already covered by normal resources
const existingFullDomains = new Set<string>();
for (const resource of resourcesMap.values()) {
if (resource.fullDomain) {
existingFullDomains.add(resource.fullDomain);
}
}
buildSiteResourceAliasCertPlaceholders({
config_output,
siteResourcesWithFullDomain,
existingFullDomains,
maintenancePageUiUrl,
redirectHttpsMiddlewareName,
resolveTls: (fullDomain) =>
buildWildcardTls({
fullDomain,
hasSubdomain: true
})
});
}
if (aiGatewayUrl) {
// The AI gateway may live on a different host than the inference
// resource itself (e.g. a remote exit node forwarding to the
// central dashboard over a tunnel). passHostHeader would forward
// the resource's own Host, which that external host won't
// recognize, so we pin the Host header to the gateway's own host
// and smuggle the original resource host through in "p-host"
// instead.
const aiGatewayHost = getAiGatewayHost(aiGatewayUrl);
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
Object.assign(
config_output.http.middlewares,
buildAiGatewayTrustMiddlewares()
);
const aiGatewayClientIpMiddleware = buildAiGatewayClientIpMiddleware();
const enableAiGatewayClientIpHeader = !!aiGatewayClientIpMiddleware;
if (aiGatewayClientIpMiddleware) {
Object.assign(
config_output.http.middlewares,
aiGatewayClientIpMiddleware
);
}
// Public inference resources: same TLS/cert-resolver handling as
// plain http-mode resources, but the service points at the AI
// gateway instead of any real backend targets.
for (const ir of inferenceResources) {
if (!ir.enabled) continue;
if (!ir.domainId || !ir.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = ir.fullDomain;
const irKey = `inference-r${ir.resourceId}`;
const routerName = `${irKey}-router`;
const serviceName = `${irKey}-service`;
const rule = buildHostRule(fullDomain, ir.wildcard);
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: !!ir.subdomain,
domainCertResolver: ir.domainCertResolver,
preferWildcardCert: ir.preferWildcardCert
});
const irHeadersMiddlewareName = `${irKey}-headers-middleware`;
config_output.http.middlewares[irHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(aiGatewayHost, fullDomain);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
irHeadersMiddlewareName,
...additionalMiddlewares
];
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
rule,
ssl: ir.ssl,
tls,
priority: 100,
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
}
// Private (siteResource) inference resources: routed by their alias
// instead of a public fullDomain, and deliberately WITHOUT the
// badger middleware - no per-user auth/policy stack exists for
// siteResources today, so gating here is reachability-only for now.
const siteResourcesInference = await db
.selectDistinct({
siteResourceId: siteResources.siteResourceId,
fullDomain: siteResources.fullDomain,
ssl: siteResources.ssl,
enabled: siteResources.enabled
})
.from(siteResources)
.where(
and(
eq(siteResources.mode, "inference"),
eq(siteResources.enabled, true),
isNotNull(siteResources.fullDomain)
)
);
if (exitNode) {
for (const sr of siteResourcesInference) {
if (!sr.enabled || !sr.fullDomain) continue;
if (!config_output.http.routers)
config_output.http.routers = {};
if (!config_output.http.services)
config_output.http.services = {};
const fullDomain = sr.fullDomain;
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
// siteResource aliases don't have a per-domain cert resolver
// stored, so always fall back to the global defaults.
const tls = buildWildcardTls({
fullDomain,
hasSubdomain: true
});
const srHeadersMiddlewareName = `${srKey}-headers-middleware`;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares[srHeadersMiddlewareName] =
buildAiGatewayHostHeaderMiddleware(
aiGatewayHost,
fullDomain
);
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
...(enableAiGatewayClientIpHeader
? [AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME]
: []),
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
srHeadersMiddlewareName,
...additionalMiddlewares
];
const { routers, services } = buildAiGatewayRouterAndService({
routerName,
serviceName,
rule,
ssl: sr.ssl,
tls,
priority: 200, // we want to match on the site resource first because the clientIP rule is more specific than the public inference resource rule, which is just the exit node IP range. so we give it a higher priority to ensure it matches first.
routerMiddlewares,
aiGatewayUrl,
redirectHttpsMiddlewareName
});
Object.assign(config_output.http.routers, routers);
Object.assign(config_output.http.services, services);
}
}
}
return config_output;
}
+46
View File
@@ -0,0 +1,46 @@
import logger from "@server/logger";
/**
* Build the customRequestHeaders middleware definition for a resource's
* custom headers + setHostHeader config. Returns null when there are no
* headers to set, so the caller can skip attaching the middleware.
*/
export function buildCustomHeadersMiddleware(
headers: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
const headersObj: { [key: string]: string } = {};
if (headers) {
let headersArr: { name: string; value: string }[] = [];
try {
headersArr = JSON.parse(headers) as {
name: string;
value: string;
}[];
} catch (e) {
logger.warn(
`Failed to parse headers for resource ${resourceId}: ${e}`
);
}
headersArr.forEach((header) => {
headersObj[header.name] = header.value;
});
}
if (setHostHeader) {
headersObj["Host"] = setHostHeader;
}
if (Object.keys(headersObj).length === 0) {
return null;
}
return {
headers: {
customRequestHeaders: headersObj
}
};
}
+134
View File
@@ -0,0 +1,134 @@
import { TargetWithSite } from "./types";
/**
* Build the loadBalancer.servers list for an HTTP-mode resource, preferring
* currently-online sites but falling back to all enabled/healthy targets if
* none are online yet (so there's still some feedback before sites report
* back over the receive-bandwidth endpoint).
*/
export function buildHttpLoadBalancerServers(targets: TargetWithSite[]) {
const anySitesOnline = targets.some((target) => target.site.online);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port || !target.method) {
return false;
}
} else if (target.site.type === "newt") {
if (
!target.internalPort ||
!target.method ||
!target.site.subnet
) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip = target.site.subnet!.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
.filter(
(v, i, a) => a.findIndex((t) => t && v && t.url === v.url) === i
);
}
export function buildStickySessionCookie(ssl: boolean | null) {
return {
sticky: {
cookie: {
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: ssl,
httpOnly: true
}
}
};
}
/**
* Build the loadBalancer.servers list for a TCP/UDP-mode resource.
*/
export function buildTcpUdpLoadBalancerServers(targets: TargetWithSite[]) {
const anySitesOnline = targets.some((target) => target.site.online);
return targets
.filter((target) => {
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
}
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (target.site.type === "newt") {
if (!target.internalPort || !target.site.subnet) {
return false;
}
}
return true;
})
.map((target) => {
if (
target.site.type === "local" ||
target.site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (target.site.type === "newt") {
const ip = target.site.subnet!.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
});
}
export function buildStickySessionIp() {
return {
sticky: {
ipStrategy: {
depth: 0,
sourcePort: true
}
}
};
}
+59
View File
@@ -1,5 +1,64 @@
import logger from "@server/logger";
/**
* Create (if configured) and attach a path-rewrite middleware for a
* resource, mutating both config_output.http.middlewares and the
* router's middleware chain. Shared by the OSS and private Traefik config
* generators, which apply it identically.
*/
export function applyPathRewriteMiddleware(
config_output: any,
resourceId: number,
key: string,
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null,
routerMiddlewares: string[]
) {
if (
rewritePath === null ||
path === null ||
!pathMatchType ||
!rewritePathType
) {
return;
}
const rewriteMiddlewareName = `rewrite-r${resourceId}-${key}`;
try {
const rewriteResult = createPathRewriteMiddleware(
rewriteMiddlewareName,
path,
pathMatchType,
rewritePath,
rewritePathType
);
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
Object.assign(
config_output.http.middlewares,
rewriteResult.middlewares
);
if (rewriteResult.chain) {
// For chained middlewares (like stripPrefix + addPrefix)
routerMiddlewares.push(...rewriteResult.chain);
} else {
// Single middleware
routerMiddlewares.push(rewriteMiddlewareName);
}
} catch (error) {
logger.error(
`Failed to create path rewrite middleware for resource ${resourceId}: ${error}`
);
}
}
export default function createPathRewriteMiddleware(
middlewareName: string,
path: string,
+71
View File
@@ -0,0 +1,71 @@
/**
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
* Wildcard resources match any single subdomain via HostRegexp.
*/
export function buildHostRule(
fullDomain: string,
wildcard?: boolean | null
): string {
if (wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
}
return `Host(\`${fullDomain}\`)`;
}
/**
* Append a path-matching clause to a Traefik rule based on the resource's
* configured path and pathMatchType.
*/
export function appendPathMatch(
rule: string,
path: string | null | undefined,
pathMatchType: string | null | undefined
): string {
if (!path || !pathMatchType) return rule;
let p = path;
if (!p.startsWith("/")) {
p = `/${p}`;
}
if (pathMatchType === "exact") {
return `${rule} && Path(\`${p}\`)`;
} else if (pathMatchType === "prefix") {
return `${rule} && PathPrefix(\`${p}\`)`;
} else if (pathMatchType === "regex") {
return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex
}
return rule;
}
/**
* Compute the router priority for a resource, favoring an explicit override
* and otherwise deriving it from the path match specificity.
*/
export function computeRoutePriority(
priority: number | null | undefined,
path: string | null | undefined,
pathMatchType: string | null | undefined
): number {
if (priority && priority != 100) {
return priority;
}
let p = 100;
if (path && pathMatchType) {
p += 10;
if (pathMatchType === "exact") {
p += 5;
} else if (pathMatchType === "prefix") {
p += 3;
} else if (pathMatchType === "regex") {
p += 2;
}
if (path === "/") {
p = 1; // lowest for catch-all
}
}
return p;
}
+114
View File
@@ -0,0 +1,114 @@
import config from "@server/lib/config";
export type SiteResourceAliasRow = {
siteResourceId: number;
fullDomain: string | null;
};
/**
* Add placeholder Traefik routes for siteResource HTTP aliases so Traefik
* generates TLS certificates for those domains even before a matching
* resource exists. Requests that land on these routes before a real
* resource is created are served the placeholder page. TLS/cert-resolver
* handling differs between the OSS and private (pangolin-dns aware) config
* generators, so callers resolve that themselves via resolveTls - returning
* null skips the alias (no valid cert available yet).
*/
export function buildSiteResourceAliasCertPlaceholders(params: {
config_output: any;
siteResourcesWithFullDomain: SiteResourceAliasRow[];
existingFullDomains: Set<string>;
maintenancePageUiUrl: string | null;
redirectHttpsMiddlewareName: string;
resolveTls: (fullDomain: string) => any | null;
}): void {
const {
config_output,
siteResourcesWithFullDomain,
existingFullDomains,
maintenancePageUiUrl,
redirectHttpsMiddlewareName,
resolveTls
} = params;
if (siteResourcesWithFullDomain.length === 0 || !maintenancePageUiUrl) {
return;
}
for (const sr of siteResourcesWithFullDomain) {
if (!sr.fullDomain) continue;
// Skip if this alias is already handled by a resource router
if (existingFullDomains.has(sr.fullDomain)) continue;
const fullDomain = sr.fullDomain;
const srKey = `site-resource-cert-${sr.siteResourceId}`;
const siteResourceServiceName = `${srKey}-service`;
const siteResourceRouterName = `${srKey}-router`;
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (!config_output.http.services) {
config_output.http.services = {};
}
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
// Service pointing at the internal maintenance/Next.js page
config_output.http.services[siteResourceServiceName] = {
loadBalancer: {
servers: [
{
url: maintenancePageUiUrl
}
],
passHostHeader: true
}
};
// Middleware that rewrites any path to /private-maintenance-screen
config_output.http.middlewares[siteResourceRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
// HTTP -> HTTPS redirect so the ACME challenge can be served
config_output.http.routers[`${siteResourceRouterName}-redirect`] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
priority: 100
};
// Determine TLS / cert-resolver configuration
const tls = resolveTls(fullDomain);
if (tls === null) {
continue;
}
// HTTPS router - presence of this entry triggers cert generation
config_output.http.routers[siteResourceRouterName] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
priority: 100,
tls
};
// Assets bypass router - lets Next.js static files load without rewrite
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Target } from "@server/db";
// Extended target type with site information, shared between the OSS and
// private getTraefikConfig implementations.
export type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
method: string | null;
port: number | null;
internalPort: number | null;
enabled: boolean;
health: string | null;
site: {
siteId: number;
type: string;
subnet: string | null;
exitNodeId: number | null;
online: boolean;
};
};
+19
View File
@@ -1,10 +1,29 @@
import {
getResourceRuleValueValidationError,
isValidDomain,
isValidUrlGlobPattern
} from "./validators";
import { assertEquals } from "@test/assert";
function runTests() {
console.log("Running domain validation tests...");
assertEquals(
isValidDomain("example.com"),
true,
"Standard ASCII domain should be valid"
);
assertEquals(
isValidDomain("xn--e1afmkfd.xn--p1ai"),
true,
"Punycode IDN domain should be valid"
);
assertEquals(
isValidDomain("example.invalid-tld"),
false,
"Domain with unknown TLD should be invalid"
);
console.log("Running URL pattern validation tests...");
// Test valid patterns
+3 -2
View File
@@ -171,9 +171,10 @@ export function isValidDomain(domain: string): boolean {
if (!/^[a-zA-Z0-9-]+$/.test(label)) return false;
}
// TLD should be at least 2 characters and contain only letters
// TLD should be at least 2 characters. Punycode TLDs can contain digits
// and hyphens, so validity is ultimately enforced by the TLD allowlist.
const tld = labels[labels.length - 1];
if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false;
if (tld.length < 2) return false;
// Check if TLD is in the list of valid TLDs
if (!validTlds.includes(tld.toUpperCase())) return false;
+228
View File
@@ -0,0 +1,228 @@
import {
generateId,
generateIdFromEntropySize
} from "@server/auth/sessions/app";
import {
db,
resources,
User,
virtualApiKeyResources,
virtualApiKeys,
type Transaction,
type VirtualApiKey
} from "@server/db";
import config from "@server/lib/config";
import { decrypt, encrypt } from "@server/lib/crypto";
import { and, eq, inArray } from "drizzle-orm";
export {
VIRTUAL_API_KEY_PREFIX,
formatVirtualApiKeyCredential,
formatVirtualApiKeyPreview,
looksLikeVirtualApiKeyCredential,
stripVirtualApiKeyAuthHeaders
} from "@app/lib/virtualApiKeyFormat";
export type MintedVirtualApiKeySecret = {
virtualApiKeyId: string;
secret: string;
lastChars: string;
};
export type PublicVirtualApiKey = Omit<VirtualApiKey, "token"> & {
secret?: string;
};
export function mintVirtualApiKeySecret(): MintedVirtualApiKeySecret {
const secret = generateIdFromEntropySize(16);
return {
virtualApiKeyId: generateId(8),
secret,
lastChars: secret.slice(-4)
};
}
export function encryptVirtualApiKeyToken(secret: string): string {
return encrypt(secret, config.getRawConfig().server.secret!);
}
export function decryptVirtualApiKeyToken(ciphertext: string): string {
return decrypt(ciphertext, config.getRawConfig().server.secret!);
}
export function toPublicVirtualApiKey(
row: VirtualApiKey,
options?: { includeSecret?: boolean }
): PublicVirtualApiKey {
const { token, ...rest } = row;
if (!options?.includeSecret) {
return rest;
}
return {
...rest,
secret: decryptVirtualApiKeyToken(token)
};
}
export async function assertManualKeyResourcesInOrg(params: {
allResources: boolean;
resourceIds: number[];
orgId: string;
}): Promise<{ ok: true } | { ok: false; message: string }> {
const { allResources, resourceIds, orgId } = params;
if (allResources) {
return { ok: true };
}
if (resourceIds.length === 0) {
return {
ok: false,
message:
"Select at least one public inference resource, or enable all public inference resources"
};
}
const uniqueIds = [...new Set(resourceIds)];
const rows = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
eq(resources.orgId, orgId),
eq(resources.mode, "inference"),
inArray(resources.resourceId, uniqueIds)
)
);
if (rows.length !== uniqueIds.length) {
return {
ok: false,
message:
"One or more resources are invalid public inference resources for this organization"
};
}
return { ok: true };
}
export async function replaceVirtualApiKeyResources(
trx: Transaction | typeof db,
virtualApiKeyId: string,
resourceIds: number[]
): Promise<void> {
await trx
.delete(virtualApiKeyResources)
.where(eq(virtualApiKeyResources.virtualApiKeyId, virtualApiKeyId));
const uniqueIds = [...new Set(resourceIds)];
if (uniqueIds.length === 0) {
return;
}
await trx.insert(virtualApiKeyResources).values(
uniqueIds.map((resourceId) => ({
virtualApiKeyId,
resourceId
}))
);
}
async function selectUserVirtualApiKey(
orgId: string,
userId: string
): Promise<VirtualApiKey | null> {
const [existing] = await db
.select()
.from(virtualApiKeys)
.where(
and(
eq(virtualApiKeys.orgId, orgId),
eq(virtualApiKeys.userId, userId),
eq(virtualApiKeys.kind, "user")
)
)
.limit(1);
return existing ?? null;
}
export async function getOrCreateUserVirtualApiKey(params: {
orgId: string;
user: User;
createdByUserId?: string | null;
}): Promise<{ key: VirtualApiKey; secret: string }> {
const { orgId, user, createdByUserId } = params;
const existing = await selectUserVirtualApiKey(orgId, user.userId);
if (existing) {
return {
key: existing,
secret: decryptVirtualApiKeyToken(existing.token)
};
}
const minted = mintVirtualApiKeySecret();
const now = Date.now();
try {
const [created] = await db
.insert(virtualApiKeys)
.values({
virtualApiKeyId: minted.virtualApiKeyId,
orgId,
kind: "user",
userId: user.userId,
name: `${user.name ?? user.username}'s API Key`,
description: null,
token: encryptVirtualApiKeyToken(minted.secret),
lastChars: minted.lastChars,
allResources: false,
expiresAt: null,
lastUsedAt: null,
createdAt: now,
createdByUserId: createdByUserId ?? null
})
.returning();
return { key: created, secret: minted.secret };
} catch {
const raced = await selectUserVirtualApiKey(orgId, user.userId);
if (raced) {
return {
key: raced,
secret: decryptVirtualApiKeyToken(raced.token)
};
}
throw new Error("Failed to create user virtual API key");
}
}
export async function rotateUserVirtualApiKey(params: {
orgId: string;
user: User;
createdByUserId?: string | null;
}): Promise<{ key: VirtualApiKey; secret: string }> {
const { orgId, user, createdByUserId } = params;
const existing = await selectUserVirtualApiKey(orgId, user.userId);
if (!existing) {
return getOrCreateUserVirtualApiKey(params);
}
const minted = mintVirtualApiKeySecret();
const [updated] = await db
.update(virtualApiKeys)
.set({
token: encryptVirtualApiKeyToken(minted.secret),
lastChars: minted.lastChars,
createdByUserId:
createdByUserId !== undefined
? createdByUserId
: existing.createdByUserId
})
.where(eq(virtualApiKeys.virtualApiKeyId, existing.virtualApiKeyId))
.returning();
return { key: updated, secret: minted.secret };
}

Some files were not shown because too many files have changed in this diff Show More