Compare commits

...

112 Commits

Author SHA1 Message Date
Owen Schwartz
d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen
390c822bb4 Fix issue with overlapping site resources not sending 2026-07-03 16:13:50 -04:00
Owen
2bfc1901a6 Dont check the subnet because we dont use it 2026-07-03 14:38:01 -04:00
Owen
5da186b528 Quiet warning messages 2026-07-03 11:58:00 -04:00
Owen
600a96c13b Update rate limit to 10 2026-07-03 11:52:59 -04:00
Owen
e4e0da3723 Add not exists check 2026-07-03 11:24:53 -04:00
Owen Schwartz
87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen
440ebfe08e Clarify error messages 2026-07-03 10:26:13 -04:00
Owen
b399d2a291 Add some retry and database confict mitigation 2026-07-03 10:23:32 -04:00
Owen Schwartz
2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
Owen
1b1fba60f1 Make sure to retry rebuilds 2026-07-03 09:02:08 -04:00
v1rusnl
ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl
1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
Owen
b93d26f09f Fix reading from replicas 2026-07-02 21:46:53 -04:00
Owen
fc54ad49b5 Fix trial showing 2026-07-02 21:46:44 -04:00
Owen
f87e136f6b Unique subnets for exit nodes 2026-07-02 20:54:59 -04:00
Owen
1bf3d2cdd6 Add back the sync with semver 2026-07-02 12:10:20 -04:00
Owen
5fc5a3ebca Adjust spacing 2026-07-02 11:49:49 -04:00
Owen Schwartz
49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen
e40f325703 Add new limits to the billing page 2026-07-02 10:55:46 -04:00
Owen
8f377a4fb2 Dont run on cloud 2026-07-01 22:07:33 -04:00
Owen Schwartz
45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
Owen Schwartz
3699f8f9cb Merge pull request #3380 from fosrl/resource-launcher
improve pagination
2026-07-01 21:47:14 -04:00
Owen
5a2388a1e6 Fix imports 2026-07-01 21:43:51 -04:00
Owen
86e6ebc8af Comment out sync again for now 2026-07-01 21:40:08 -04:00
miloschwartz
005f050a81 improve pagination 2026-07-01 21:36:23 -04:00
Owen Schwartz
c82678852e Merge pull request #3377 from fosrl/crowdin_dev
New Crowdin updates
2026-07-01 21:30:26 -04:00
Owen Schwartz
2e3ab10f5e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-01 21:28:16 -04:00
Owen Schwartz
4985ed02d3 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-01 21:28:14 -04:00
Owen Schwartz
a2fea7f714 New translations en-us.json (Turkish)
[ci skip]
2026-07-01 21:28:12 -04:00
Owen Schwartz
ddba2aff21 New translations en-us.json (Russian)
[ci skip]
2026-07-01 21:28:11 -04:00
Owen Schwartz
da9e668fb8 New translations en-us.json (Portuguese)
[ci skip]
2026-07-01 21:28:09 -04:00
Owen Schwartz
9c2d14d8c6 New translations en-us.json (Polish)
[ci skip]
2026-07-01 21:28:07 -04:00
Owen Schwartz
c383e74df4 New translations en-us.json (Dutch)
[ci skip]
2026-07-01 21:28:05 -04:00
Owen Schwartz
40101f8bb0 New translations en-us.json (Korean)
[ci skip]
2026-07-01 21:28:03 -04:00
Owen Schwartz
b20ed9efa9 New translations en-us.json (Italian)
[ci skip]
2026-07-01 21:28:02 -04:00
Owen Schwartz
56266e3b62 New translations en-us.json (German)
[ci skip]
2026-07-01 21:28:00 -04:00
Owen Schwartz
47eff8c948 New translations en-us.json (Danish)
[ci skip]
2026-07-01 21:27:58 -04:00
Owen Schwartz
15f36096ef New translations en-us.json (Czech)
[ci skip]
2026-07-01 21:27:57 -04:00
Owen Schwartz
064252586d New translations en-us.json (Bulgarian)
[ci skip]
2026-07-01 21:27:55 -04:00
Owen Schwartz
6181d46a1e New translations en-us.json (Spanish)
[ci skip]
2026-07-01 21:27:53 -04:00
Owen Schwartz
9747731668 New translations en-us.json (French)
[ci skip]
2026-07-01 21:27:51 -04:00
Owen Schwartz
2a478eef6f Merge pull request #3375 from fosrl/resource-launcher
Resource launcher
2026-07-01 21:19:19 -04:00
Owen
4ab101f8a9 Fix lock 2026-07-01 21:13:40 -04:00
miloschwartz
e35878ee55 add caching 2026-07-01 19:59:38 -04:00
Owen
807613f28c Move rate limit up 2026-07-01 17:30:07 -04:00
miloschwartz
663244fa3a support labels in list alises 2026-07-01 16:17:27 -04:00
Owen
bcc128aeb6 Fix ping function 2026-07-01 15:47:24 -04:00
Owen
ba33cb9895 Increment config version for this 2026-07-01 15:44:08 -04:00
Owen
69e7fedcfc Add the remote exit node info 2026-07-01 15:28:26 -04:00
miloschwartz
023110b341 update en-us 2026-07-01 15:18:49 -04:00
miloschwartz
7fb95e1726 restore script 2026-07-01 15:15:43 -04:00
miloschwartz
db0a7cc1ce fix toolbar responsiveness and disable side panel 2026-07-01 15:14:29 -04:00
Owen
61fc2e5ea7 Fix restartSite import 2026-07-01 14:48:36 -04:00
miloschwartz
0871a211ec open side panel on click 2026-07-01 14:30:43 -04:00
miloschwartz
5a1d5cb66e redirect to settings if org admin 2026-07-01 14:06:33 -04:00
miloschwartz
5a7ca5b542 add refresh button 2026-07-01 12:38:45 -04:00
miloschwartz
87e1a509ce show sites and labels for non admins 2026-07-01 12:17:36 -04:00
miloschwartz
75f481bc3d improve search bar debounce behavior 2026-07-01 11:53:11 -04:00
miloschwartz
97cdb2eb5a use proper label filter selector 2026-07-01 11:46:20 -04:00
miloschwartz
297fd2caf3 standardize permissions in api 2026-07-01 11:26:14 -04:00
miloschwartz
22dd4220fe fix ordering of buttons 2026-07-01 11:09:26 -04:00
Owen
108cb6216c Add migration to fix policy delete issues
Ref #3257
2026-07-01 10:39:41 -04:00
Owen
e3ef592778 Adjust language 2026-07-01 10:39:41 -04:00
miloschwartz
3c37e10638 add mobile overflow menu 2026-07-01 10:11:17 -04:00
miloschwartz
561f75b6b1 improve responsiveness 2026-07-01 10:03:06 -04:00
Owen Schwartz
e98bcb83ac Merge pull request #3371 from jaisinha77777/fix/oss-listexitnodes-siteid-signature
Fix OSS build break: add siteId param to OSS listExitNodes
2026-07-01 08:44:12 -04:00
Owen Schwartz
80284863bb Merge pull request #3369 from jaisinha77777/fix/private-resources-delete-error-toast
Fix broken toast on private resource delete failure
2026-07-01 08:43:50 -04:00
miloschwartz
bc759c5c9e improve mobile responsiveness 2026-06-30 22:11:25 -04:00
miloschwartz
f4854a3a74 include exit node endpoint in tcp/udp resources 2026-06-30 21:54:27 -04:00
miloschwartz
376dd465b3 show no site category 2026-06-30 21:49:08 -04:00
jaisinha77777
296439fd67 Fix OSS build break: add siteId param to OSS listExitNodes
listExitNodes is resolved via the #dynamic path alias, which maps to
server/lib in the OSS build and server/private/lib in enterprise/saas.
Commit 9c18936b added a 4th siteId argument to the shared caller
(handleNewtPingRequestMessage) and to the enterprise implementation, but
not to the OSS one, so under the OSS tsconfig the call fails:

  handleNewtPingRequestMessage.ts: error TS2554: Expected 1-3 arguments,
  but got 4.

This breaks 'npx tsc --noEmit' for the OSS build (the CI 'Test with tsc'
step runs it after set:oss). Add siteId?: number to the OSS signature
for parity. It is unused in OSS since that build has no remote exit
nodes to label-filter; accepting it keeps the two #dynamic
implementations interface-compatible.
2026-07-01 07:17:03 +05:30
jaisinha77777
31f675f38c Fix broken toast on private resource delete failure
The delete-error handler in PrivateResourcesTable referenced two i18n
keys that do not exist in the message catalog:

- console.error used "resourceErrorDelete" (the catalog key is
  "resourceErrorDelte"), logging a raw key instead of the message.
- the toast description passed "v" to formatAxiosError, so a failed
  delete showed the user a bogus fallback title.

Point both at the existing "resourceErrorDelte" key, matching the
delete handlers in PublicResourcesTable and ResourcePoliciesTable. No
catalog/translation changes, so nothing changes for Crowdin.
2026-07-01 07:04:54 +05:30
miloschwartz
9f68be2a9b add empty state 2026-06-30 21:18:46 -04:00
miloschwartz
fed4ec42c4 add server side fetching 2026-06-30 21:15:03 -04:00
miloschwartz
f0efa4203b basic functionality 2026-06-30 21:03:19 -04:00
Owen
cfbbdedaf5 rework ui 2026-06-30 17:44:35 -04:00
Owen
686789ee4c Properly translate 2026-06-30 15:37:42 -04:00
Owen
3fda190ff6 Merge branch 'backhaul' into dev 2026-06-30 15:21:11 -04:00
Owen
0033f40f4d Fix typo preventing subscription cancelation 2026-06-30 15:06:35 -04:00
Owen
af95052706 Update schema for tracking valid domains 2026-06-30 13:53:44 -04:00
Owen
9bb2d6cdc8 Prompt for the username on vnc 2026-06-30 12:16:58 -04:00
Owen
29563a13a4 Add indexes on the niceId and orgId 2026-06-30 09:49:53 -04:00
Owen
b41c1f5b27 Add restart button 2026-06-29 21:10:49 -04:00
Owen
e5652cdb8a Dont enable admin routes 2026-06-29 20:45:38 -04:00
Owen
7c2ea153c5 Use regional cache for rate limiting 2026-06-29 18:33:03 -04:00
Owen
ccabddc225 Add logging for access for new public resources 2026-06-29 18:05:29 -04:00
Owen
42d98fa83b Comment back in the sync command 2026-06-29 16:34:10 -04:00
Owen
2f2b7f43c1 Add usage tracking to blueprints 2026-06-29 16:13:12 -04:00
Owen
528bbeca26 Implement usage tracking on resources, clients 2026-06-29 15:39:30 -04:00
Owen
d60c15b0ae Fix typo 2026-06-29 15:24:16 -04:00
Owen
ff89a64453 Rename to limit id 2026-06-29 15:22:35 -04:00
Owen
4718c489d3 Add concurrency guard calculateUserClientsForOrgs 2026-06-29 15:02:41 -04:00
Owen
d5d99a4804 Add org rebuild rate limit 2026-06-29 14:59:05 -04:00
Owen
9c18936be7 Filter the nodes based on the preference labels 2026-06-29 11:40:25 -04:00
Owen
cf07cceb5d Fix bad col in pg 2026-06-29 11:31:34 -04:00
Owen
faee9e6330 Merge branch 'main' into dev 2026-06-29 11:29:12 -04:00
miloschwartz
31725eb3cc display resource type in info card 2026-06-29 10:45:38 -04:00
miloschwartz
04d4e298e8 fix spacing on endpoint field on site 2026-06-29 10:42:25 -04:00
Owen
c9cc9581b1 Add batch update 2026-06-26 23:24:14 -04:00
Owen
eac7c67dcc Send down remote subnets 2026-06-26 18:09:56 -04:00
Owen
633d9031af Add labels input 2026-06-26 18:02:20 -04:00
Owen
05dc558c4a Add basic resources input on the remote node 2026-06-26 18:01:24 -04:00
miloschwartz
7506c0420d properly pass org policy error message in olm register 2026-06-26 17:11:32 -04:00
Owen Schwartz
5572822c4a Merge pull request #3351 from fosrl/copilot/fix-rest-ruleid-changing
fix: preserve rule IDs when saving policy rules via the GUI
2026-06-26 15:05:31 -04:00
Owen
ea3f1c341b Move hashing outside of transaction 2026-06-26 14:40:39 -04:00
Owen
35dffe71cb Make error statement debug 2026-06-26 14:40:31 -04:00
copilot-swe-agent[bot]
5428bf4ed0 fix: preserve rule IDs when saving policy rules through the GUI
The `setResourcePolicyRules` endpoint was deleting all existing rules and
re-inserting them on every save, causing all ruleIDs to change.

Backend: Accept an optional `ruleId` per rule in the request body and
perform an upsert — update existing rules (matched by ruleId), insert
new ones (no ruleId), and delete only rules absent from the incoming list.

Frontend: Include `ruleId` in the rules payload for existing (non-new)
rules so the backend can match and preserve them.
2026-06-26 14:37:34 +00:00
copilot-swe-agent[bot]
9a89579e08 Initial plan 2026-06-26 14:33:35 +00:00
Owen Schwartz
784588cebc Merge pull request #3350 from fosrl/dev
Make sure the rebuild actually executes
2026-06-26 09:28:12 -04:00
Owen
2e628fe0e4 Make sure the rebuild actually executes 2026-06-26 09:26:43 -04:00
Owen Schwartz
7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
199 changed files with 12269 additions and 2718 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.

View File

@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode
traefik: traefik:
image: traefik:v3.6 image: traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service network_mode: service:gerbil # Ports appear on the gerbil service

View File

@@ -50,7 +50,7 @@ services:
- 80:80{{end}} - 80:80{{end}}
traefik: traefik:
image: docker.io/traefik:v3.6 image: docker.io/traefik:v3.7
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Сайтът е обновен", "siteUpdated": "Сайтът е обновен",
"siteUpdatedDescription": "Сайтът е актуализиран.", "siteUpdatedDescription": "Сайтът е актуализиран.",
"siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт",
"siteRestartTitle": "Рестартирайте Сайта",
"siteRestartDescription": "Рестартирайте WireGuard тунела за този сайт. Това ще прекъсне връзката за кратко.",
"siteRestartBody": "Използвайте това, ако тунелът на сайта не функционира правилно и искате да принудите повторно свързване без да рестартирате хоста.",
"siteRestartButton": "Рестартирайте Сайта",
"siteRestartDialogMessage": "Сигурни ли сте, че искате да рестартирате WireGuard тунела за <b>{name}</b>? Сайтът ще изгуби връзка за кратко.",
"siteRestartWarning": "Сайтът ще се изключи за кратко, докато тунелът се рестартира.",
"siteRestarted": "Сайтът е рестартиран",
"siteRestartedDescription": "WireGuard тунелът е рестартиран.",
"siteErrorRestart": "Неуспешно рестартиране на сайта",
"siteErrorRestartDescription": "Възникна грешка при рестартирането на сайта.",
"siteSettingDescription": "Конфигурирайте настройките на сайта", "siteSettingDescription": "Конфигурирайте настройките на сайта",
"siteResourcesTab": "Ресурси", "siteResourcesTab": "Ресурси",
"siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.", "siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Приложи Чернова", "actionApplyBlueprint": "Приложи Чернова",
"actionListBlueprints": "Списък с планове.", "actionListBlueprints": "Списък с планове.",
"actionGetBlueprint": "Вземи план.", "actionGetBlueprint": "Вземи план.",
"actionCreateOrgWideLauncherView": "Създайте Изглед на Стартирача за Цялата Организация",
"setupToken": "Конфигурация на токен", "setupToken": "Конфигурация на токен",
"setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.",
"setupTokenRequired": "Необходим е конфигурационен токен", "setupTokenRequired": "Необходим е конфигурационен токен",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Мрежа", "subnetPlaceholder": "Мрежа",
"addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.", "addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.",
"selectSites": "Избор на сайтове", "selectSites": "Избор на сайтове",
"selectLabels": "Изберете етикети",
"sitesDescription": "Клиентът ще има връзка с избраните сайтове", "sitesDescription": "Клиентът ще има връзка с избраните сайтове",
"clientInstallOlm": "Инсталиране на Olm", "clientInstallOlm": "Инсталиране на Olm",
"clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система", "clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Сайт", "createInternalResourceDialogSite": "Сайт",
"selectSite": "Изберете сайт...", "selectSite": "Изберете сайт...",
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}",
"labelsSelectorLabelsCount": "{count, plural, one {# етикет} other {# етикета}}",
"noSitesFound": "Не са намерени сайтове.", "noSitesFound": "Не са намерени сайтове.",
"createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogProtocol": "Протокол",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Отдалечени възли", "sidebarRemoteExitNodes": "Отдалечени възли",
"remoteExitNodeId": "ID.", "remoteExitNodeId": "ID.",
"remoteExitNodeSecretKey": "Секретен ключ.", "remoteExitNodeSecretKey": "Секретен ключ.",
"remoteExitNodeNetworkingTitle": "Настройки на Мрежата",
"remoteExitNodeNetworkingDescription": "Настройте как този отдалечен край маршрутизира трафика и кои сайтове предпочитат да се свържат чрез него. Усъвършенствани функции за използване при конфигурации на бекаул мрежи.",
"remoteExitNodeNetworkingSave": "Запазване на Настройките",
"remoteExitNodeNetworkingSaveSuccessTitle": "Настройките на мрежата са успешно запазени",
"remoteExitNodeNetworkingSaveSuccessDescription": "Настройките на мрежата бяха успешно обновени.",
"remoteExitNodeNetworkingSaveError": "Неуспешно запазване на мрежовите настройки",
"remoteExitNodeNetworkingSubnetsTitle": "Отдалечени Подмрежи",
"remoteExitNodeNetworkingSubnetsDescription": "Определете CIDR диапазоните, които този отдалечен край ще маршрутизира трафика към. Въведете валиден CIDR (e.g. <code>10.0.0.0/8</code>) и натиснете Enter, за да добавите.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Създаване на отдалечен възел.", "title": "Създаване на отдалечен възел.",
"description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.", "description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC доставчик", "idpGoogleDescription": "Google OAuth2/OIDC доставчик",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик",
"subnet": "Подмрежа", "subnet": "Подмрежа",
"utilitySubnet": "Мрежа на Помощни Подмрежи",
"subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.", "subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.",
"customDomain": "Персонализиран домейн.", "customDomain": "Персонализиран домейн.",
"authPage": "Страници за автентификация.", "authPage": "Страници за автентификация.",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Бял списък на имейли", "memberPortalEmailWhitelist": "Бял списък на имейли",
"memberPortalResourceDisabled": "Ресурсът е деактивиран", "memberPortalResourceDisabled": "Ресурсът е деактивиран",
"memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси", "memberPortalShowingResources": "Показва {start}-{end} от {total} ресурси",
"resourceLauncherTitle": "Стартер за Ресурси",
"resourceLauncherDescription": "Преглеждайте детайлите на ресурса и ги стартирайте от едно място",
"resourceLauncherSearchPlaceholder": "Търсете във всички сайтове...",
"resourceLauncherDefaultView": "По Подразбиране",
"resourceLauncherSaveView": "Запазете Изгледа",
"resourceLauncherSaveToCurrentView": "Запазете в Текущ Изглед",
"resourceLauncherResetView": "Нулирайте Изгледа",
"resourceLauncherSaveAsNewView": "Запазете като Нов Изглед",
"resourceLauncherSaveAsNewViewDescription": "Дайте име на този изглед, за да запазите текущите си филтри и оформление.",
"resourceLauncherSaveForEveryone": "Запазете за Всеки",
"resourceLauncherSaveForEveryoneDescription": "Споделете този изглед с всички членове на организацията. Когато е изключено, изгледът е видим само за вас.",
"resourceLauncherMakePersonal": "Направи Личен",
"resourceLauncherFilter": "Филтър",
"resourceLauncherSort": "Сортиране",
"resourceLauncherSortAscending": "Сортиране възходящо",
"resourceLauncherSortDescending": "Сортиране низходящо",
"resourceLauncherSettings": "Настройки",
"resourceLauncherGroupBy": "Групирай По",
"resourceLauncherGroupBySite": "Сайт",
"resourceLauncherGroupByLabel": "Етикет",
"resourceLauncherLayout": "Оформление",
"resourceLauncherLayoutGrid": "Мрежа",
"resourceLauncherLayoutList": "Списък",
"resourceLauncherShowLabels": "Показване на Етикети",
"resourceLauncherShowSiteTags": "Показване на Тагове на Сайт",
"resourceLauncherShowRecents": "Показване на Последни",
"resourceLauncherDeleteView": "Изтриване на Изглед",
"resourceLauncherViewAsAdmin": "Вижте като Админ",
"resourceLauncherResourceDetailsDescription": "Вижте детайлите за този ресурс.",
"resourceLauncherUnlabeled": "Без Етикет",
"resourceLauncherNoSite": "Няма Сайт",
"resourceLauncherNoResourcesInGroup": "Няма ресурси в тази група",
"resourceLauncherEmptyStateTitle": "Няма Налични Ресурси",
"resourceLauncherEmptyStateDescription": "Все още нямате достъп до никакви ресурси. Свържете се с вашия администратор, за да поискате достъп.",
"resourceLauncherEmptyStateNoResultsTitle": "Няма Намерени Ресурси",
"resourceLauncherEmptyStateNoResultsDescription": "Никакви ресурси не съвпадат с текущото ви търсене или филтри. Опитайте да ги коригирате, за да намерите това, което търсите.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Никакви ресурси не съвпадат с \"{query}\". Опитайте да коригирате търсенето или да изтриете филтри, за да видите всички ресурси.",
"resourceLauncherCopiedToClipboard": "Копирано в клипборда",
"resourceLauncherCopiedAccessDescription": "Достъпът до ресурса е копиран на вашия клипборд.",
"resourceLauncherViewNamePlaceholder": "Име на Изгледа",
"resourceLauncherViewNameLabel": "Име на Изгледа",
"resourceLauncherViewSaved": "Изгледът е запазен",
"resourceLauncherViewSavedDescription": "Вашият изглед на стартер е запазен.",
"resourceLauncherViewSaveFailed": "Неуспешно запазване на изгледа",
"resourceLauncherViewSaveFailedDescription": "Не можеше да се запази изгледът на стартер. Моля, опитайте отново.",
"resourceLauncherViewDeleted": "Изгледът е изтрит",
"resourceLauncherViewDeletedDescription": "Изгледът на стартер е изтрит.",
"resourceLauncherViewDeleteFailed": "Неуспешно изтриване на изгледа",
"resourceLauncherViewDeleteFailedDescription": "Не можахте да изтриете изгледа на стартер. Моля, опитайте отново.",
"memberPortalPrevious": "Предишен", "memberPortalPrevious": "Предишен",
"memberPortalNext": "Следващ", "memberPortalNext": "Следващ",
"httpSettings": "HTTP настройки", "httpSettings": "HTTP настройки",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----", "sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----",
"sshPrivateKeyRequired": "Изисква се частен ключ", "sshPrivateKeyRequired": "Изисква се частен ключ",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Въведете вашата VNC парола за свързване", "vncSignInDescription": "Въведете VNC данните си за връзка",
"vncUsernameOptional": "Потребителско име (по избор)",
"vncPasswordOptional": "Парола (по избор)", "vncPasswordOptional": "Парола (по избор)",
"vncNoResourceTarget": "Не е налична цел за ресурса", "vncNoResourceTarget": "Не е налична цел за ресурса",
"vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC", "vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Lokalita upravena", "siteUpdated": "Lokalita upravena",
"siteUpdatedDescription": "Lokalita byla upravena.", "siteUpdatedDescription": "Lokalita byla upravena.",
"siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu",
"siteRestartTitle": "Restartovat lokalitu",
"siteRestartDescription": "Restartujte tunel WireGuard pro tuto lokalitu. To krátce přeruší konektivitu.",
"siteRestartBody": "Použijte to, pokud tunel lokality nefunguje správně a chcete vynutit opětovné připojení bez restartování hostitele.",
"siteRestartButton": "Restartovat lokalitu",
"siteRestartDialogMessage": "Opravdu chcete restartovat WireGuard tunel pro <b>{name}</b>? Lokalita krátce ztratí konektivitu.",
"siteRestartWarning": "Lokalita bude krátce odpojena, zatímco se tunel restartuje.",
"siteRestarted": "Lokalita restartována",
"siteRestartedDescription": "Tunel WireGuard byl restartován.",
"siteErrorRestart": "Nepodařilo se restartovat lokalitu",
"siteErrorRestartDescription": "Při restartování lokality došlo k chybě.",
"siteSettingDescription": "Konfigurace nastavení na webu", "siteSettingDescription": "Konfigurace nastavení na webu",
"siteResourcesTab": "Zdroje", "siteResourcesTab": "Zdroje",
"siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.", "siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Použít plán", "actionApplyBlueprint": "Použít plán",
"actionListBlueprints": "Seznam šablon", "actionListBlueprints": "Seznam šablon",
"actionGetBlueprint": "Získat šablonu", "actionGetBlueprint": "Získat šablonu",
"actionCreateOrgWideLauncherView": "Vytvořit organizační pohled",
"setupToken": "Nastavit token", "setupToken": "Nastavit token",
"setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.",
"setupTokenRequired": "Je vyžadován token nastavení", "setupTokenRequired": "Je vyžadován token nastavení",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Podsíť", "subnetPlaceholder": "Podsíť",
"addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.", "addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.",
"selectSites": "Vyberte stránky", "selectSites": "Vyberte stránky",
"selectLabels": "Vyberte názvy",
"sitesDescription": "Klient bude mít připojení k vybraným webům", "sitesDescription": "Klient bude mít připojení k vybraným webům",
"clientInstallOlm": "Nainstalovat Olm", "clientInstallOlm": "Nainstalovat Olm",
"clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému", "clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Lokalita", "createInternalResourceDialogSite": "Lokalita",
"selectSite": "Vybrat lokalitu...", "selectSite": "Vybrat lokalitu...",
"multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}", "multiSitesSelectorSitesCount": "{count, plural, one {# web} few {# weby} many {# webů} other {# weby}}",
"labelsSelectorLabelsCount": "{count, plural, one {# název} few {# názvy} many {# názvů} other {# názvů}}",
"noSitesFound": "Nebyly nalezeny žádné lokality.", "noSitesFound": "Nebyly nalezeny žádné lokality.",
"createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Vzdálené uzly", "sidebarRemoteExitNodes": "Vzdálené uzly",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Tajný klíč", "remoteExitNodeSecretKey": "Tajný klíč",
"remoteExitNodeNetworkingTitle": "Nastavení sítě",
"remoteExitNodeNetworkingDescription": "Nastavte, jak tento vzdálený výstupní uzel směruje provoz a které lokality se mají připojit přes něj. Pokročilé funkce pro použití s konfiguracemi zpětné sítě.",
"remoteExitNodeNetworkingSave": "Uložit nastavení",
"remoteExitNodeNetworkingSaveSuccessTitle": "Nastavení sítě bylo úspěšně uloženo",
"remoteExitNodeNetworkingSaveSuccessDescription": "Nastavení sítě bylo úspěšně aktualizováno.",
"remoteExitNodeNetworkingSaveError": "Nepodařilo se uložit nastavení sítě",
"remoteExitNodeNetworkingSubnetsTitle": "Dálkové podsítě",
"remoteExitNodeNetworkingSubnetsDescription": "Definujte rozsahy CIDR, ke kterým tento vzdálený výstupní uzel bude směrovat provoz. Zadejte platné CIDR (např. <code>10.0.0.0/8</code>) a stiskněte Enter pro přidání.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
"remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.",
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Vytvořit vzdálený uzel", "title": "Vytvořit vzdálený uzel",
"description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel", "description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC", "idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsíť", "subnet": "Podsíť",
"utilitySubnet": "Nástrojová podsíť",
"subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.",
"customDomain": "Vlastní doména", "customDomain": "Vlastní doména",
"authPage": "Autentizační stránky", "authPage": "Autentizační stránky",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Seznam povolených emailů", "memberPortalEmailWhitelist": "Seznam povolených emailů",
"memberPortalResourceDisabled": "Zdroj je zakázán", "memberPortalResourceDisabled": "Zdroj je zakázán",
"memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů", "memberPortalShowingResources": "Zobrazeny {start}-{end} z {total} zdrojů",
"resourceLauncherTitle": "Spouštěč zdrojů",
"resourceLauncherDescription": "Podívejte se na podrobnosti o zdrojích a spusťte je z jednoho místa",
"resourceLauncherSearchPlaceholder": "Hledat všechny lokality...",
"resourceLauncherDefaultView": "Výchozí",
"resourceLauncherSaveView": "Uložit pohled",
"resourceLauncherSaveToCurrentView": "Uložit do aktuálního pohledu",
"resourceLauncherResetView": "Obnovit pohled",
"resourceLauncherSaveAsNewView": "Uložit jako nový pohled",
"resourceLauncherSaveAsNewViewDescription": "Uložte tento pohled k uloženému filtrování a rozvržení.",
"resourceLauncherSaveForEveryone": "Uložit pro všechny",
"resourceLauncherSaveForEveryoneDescription": "Sdílejte tento pohled se všemi členy organizace. Pokud není zaškrtnuto, pohled je viditelný pouze vám.",
"resourceLauncherMakePersonal": "Udělat osobní",
"resourceLauncherFilter": "Filtr",
"resourceLauncherSort": "Řadit",
"resourceLauncherSortAscending": "Řadit vzestupně",
"resourceLauncherSortDescending": "Řadit sestupně",
"resourceLauncherSettings": "Nastavení",
"resourceLauncherGroupBy": "Seskupit podle",
"resourceLauncherGroupBySite": "Lokalita",
"resourceLauncherGroupByLabel": "Název",
"resourceLauncherLayout": "Rozvržení",
"resourceLauncherLayoutGrid": "Mřížka",
"resourceLauncherLayoutList": "Seznam",
"resourceLauncherShowLabels": "Zobrazit název",
"resourceLauncherShowSiteTags": "Zobrazit značky lokality",
"resourceLauncherShowRecents": "Zobrazit nedávné",
"resourceLauncherDeleteView": "Smazat pohled",
"resourceLauncherViewAsAdmin": "Zobrazit jako administrátor",
"resourceLauncherResourceDetailsDescription": "Podívejte se na podrobnosti o tomto zdroji.",
"resourceLauncherUnlabeled": "Bez nálepky",
"resourceLauncherNoSite": "Žádná lokalita",
"resourceLauncherNoResourcesInGroup": "V této skupině nejsou žádné zdroje",
"resourceLauncherEmptyStateTitle": "Žádné dostupné zdroje",
"resourceLauncherEmptyStateDescription": "Zatím nemáte přístup k žádným zdrojům. Kontaktujte svého administrátora, abyste požádali o přístup.",
"resourceLauncherEmptyStateNoResultsTitle": "Nebyl nalezen žádný zdroj",
"resourceLauncherEmptyStateNoResultsDescription": "Žádný zdroj neodpovídá vašemu aktuálnímu vyhledávání nebo filtrům. Zkuste je upravit, abyste našli to, co hledáte.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Žádné zdroje neodpovídají \"{query}\". Zkuste upravit vyhledávání nebo vymazat filtry, abyste viděli všechny zdroje.",
"resourceLauncherCopiedToClipboard": "Zkopírováno do schránky",
"resourceLauncherCopiedAccessDescription": "Přístup ke zdroji byl zkopírován do vaší schránky.",
"resourceLauncherViewNamePlaceholder": "Název pohledu",
"resourceLauncherViewNameLabel": "Název pohledu",
"resourceLauncherViewSaved": "Pohled uložen",
"resourceLauncherViewSavedDescription": "Váš spouštěcí pohled byl uložen.",
"resourceLauncherViewSaveFailed": "Nepodařilo se uložit pohled",
"resourceLauncherViewSaveFailedDescription": "Nepodařilo se uložit spouštěcí pohled. Prosím zkuste to znovu.",
"resourceLauncherViewDeleted": "Pohled smazán",
"resourceLauncherViewDeletedDescription": "Spouštěcí pohled byl smazán.",
"resourceLauncherViewDeleteFailed": "Nepodařilo se smazat pohled",
"resourceLauncherViewDeleteFailedDescription": "Nepodařilo se smazat spouštěcí pohled. Prosím zkuste to znovu.",
"memberPortalPrevious": "Předchozí", "memberPortalPrevious": "Předchozí",
"memberPortalNext": "Následující", "memberPortalNext": "Následující",
"httpSettings": "Nastavení HTTP", "httpSettings": "Nastavení HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----ZAČÁTEK SOUKROMÉHO KLÍČE OPENSSH-----", "sshPrivateKeyPlaceholder": "-----ZAČÁTEK SOUKROMÉHO KLÍČE OPENSSH-----",
"sshPrivateKeyRequired": "Je vyžadován soukromý klíč", "sshPrivateKeyRequired": "Je vyžadován soukromý klíč",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Zadejte své heslo VNC pro připojení", "vncSignInDescription": "Zadejte své VNC přihlašovací údaje pro připojení",
"vncUsernameOptional": "Uživatelské jméno (nepovinné)",
"vncPasswordOptional": "Heslo (nepovinné)", "vncPasswordOptional": "Heslo (nepovinné)",
"vncNoResourceTarget": "Není k dispozici žádný cíl zdroje", "vncNoResourceTarget": "Není k dispozici žádný cíl zdroje",
"vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC", "vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site opdateret", "siteUpdated": "Site opdateret",
"siteUpdatedDescription": "Sitet er blevet opdateret.", "siteUpdatedDescription": "Sitet er blevet opdateret.",
"siteGeneralDescription": "Konfigurer de generelle indstillinger for dette site", "siteGeneralDescription": "Konfigurer de generelle indstillinger for dette site",
"siteRestartTitle": "Genstart Site",
"siteRestartDescription": "Genstart WireGuard-tunnelen for dette site. Dette vil kortvarigt afbryde forbindelsen.",
"siteRestartBody": "Brug dette, hvis site-tunnelen ikke fungerer korrekt, og du vil tvinge en genforbindelse uden at genstarte værten.",
"siteRestartButton": "Genstart Site",
"siteRestartDialogMessage": "Er du sikker på, at du vil genstarte WireGuard-tunnelen for <b>{name}</b>? Sitet vil kortvarigt miste forbindelsen.",
"siteRestartWarning": "Sitet vil kortvarigt afbryde forbindelse, mens tunnelen genstarter.",
"siteRestarted": "Site genstartet",
"siteRestartedDescription": "WireGuard-tunnelen er blevet genstartet.",
"siteErrorRestart": "Kunne ikke genstarte site",
"siteErrorRestartDescription": "En fejl opstod, mens sitet blev genstartet.",
"siteSettingDescription": "Konfigurer indstillingerne for sitet", "siteSettingDescription": "Konfigurer indstillingerne for sitet",
"siteResourcesTab": "Ressourcer", "siteResourcesTab": "Ressourcer",
"siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.", "siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Brug blueprint", "actionApplyBlueprint": "Brug blueprint",
"actionListBlueprints": "Vis blueprints", "actionListBlueprints": "Vis blueprints",
"actionGetBlueprint": "Hent blueprint", "actionGetBlueprint": "Hent blueprint",
"actionCreateOrgWideLauncherView": "Opret org-dækkende launcher-visning",
"setupToken": "Opsætningstoken", "setupToken": "Opsætningstoken",
"setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.", "setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.",
"setupTokenRequired": "Opsætningstoken er nødvendig", "setupTokenRequired": "Opsætningstoken er nødvendig",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnet", "subnetPlaceholder": "Subnet",
"addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.", "addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.",
"selectSites": "Vælg sites", "selectSites": "Vælg sites",
"selectLabels": "Vælg etiketter",
"sitesDescription": "Klienten vil have forbindelse til de valgte områdene", "sitesDescription": "Klienten vil have forbindelse til de valgte områdene",
"clientInstallOlm": "Installer Olm", "clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Få Olm til at køre på dit system", "clientInstallOlmDescription": "Få Olm til at køre på dit system",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Websted", "createInternalResourceDialogSite": "Websted",
"selectSite": "Vælg site...", "selectSite": "Vælg site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketter}}",
"noSitesFound": "Ingen sites fundet.", "noSitesFound": "Ingen sites fundet.",
"createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Eksterne noder", "sidebarRemoteExitNodes": "Eksterne noder",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Sikkerhedsnøgle", "remoteExitNodeSecretKey": "Sikkerhedsnøgle",
"remoteExitNodeNetworkingTitle": "Netværksindstillinger",
"remoteExitNodeNetworkingDescription": "Konfigurér, hvordan denne fjerne exit-node skal dirigere trafik, og hvilke sites der foretrækkes at oprette forbindelse gennem den. Avancerede funktioner til brug med backhaul-netværkskonfigurationer.",
"remoteExitNodeNetworkingSave": "Gem indstillinger",
"remoteExitNodeNetworkingSaveSuccessTitle": "Netværksindstillinger gemt",
"remoteExitNodeNetworkingSaveSuccessDescription": "Netværksindstillinger er blevet opdateret.",
"remoteExitNodeNetworkingSaveError": "Kunne ikke gemme netværksindstillinger",
"remoteExitNodeNetworkingSubnetsTitle": "Fjern Subnets",
"remoteExitNodeNetworkingSubnetsDescription": "Definér de CIDR-områder, som denne fjerne exit-node vil dirigere trafik til. Indtast en gyldig CIDR (f.eks. <code>10.0.0.0/8</code>) og tryk Enter for at tilføje.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
"remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Opret ekstern node", "title": "Opret ekstern node",
"description": "Opret en ny selvhostet ekstern relay- og proxyservernode", "description": "Opret en ny selvhostet ekstern relay- og proxyservernode",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC udbyder", "idpGoogleDescription": "Google OAuth2/OIDC udbyder",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør",
"subnet": "Subnet", "subnet": "Subnet",
"utilitySubnet": "Forsynings subnet",
"subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.",
"customDomain": "Brugerdefineret domæne", "customDomain": "Brugerdefineret domæne",
"authPage": "Autentiseringssider", "authPage": "Autentiseringssider",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-mailwhitelist", "memberPortalEmailWhitelist": "E-mailwhitelist",
"memberPortalResourceDisabled": "Ressource deaktiveret", "memberPortalResourceDisabled": "Ressource deaktiveret",
"memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer", "memberPortalShowingResources": "Viser {start}-{end} af {total} ressourcer",
"resourceLauncherTitle": "Ressource Starter",
"resourceLauncherDescription": "Se ressource detaljer og start dem fra ét sted",
"resourceLauncherSearchPlaceholder": "Søg i alle sites...",
"resourceLauncherDefaultView": "Standard",
"resourceLauncherSaveView": "Gem Visning",
"resourceLauncherSaveToCurrentView": "Gem til nuværende visning",
"resourceLauncherResetView": "Nulstil Visning",
"resourceLauncherSaveAsNewView": "Gem som Ny Visning",
"resourceLauncherSaveAsNewViewDescription": "Giv denne visning et navn for at gemme dine nuværende filtre og layout.",
"resourceLauncherSaveForEveryone": "Gem for Alle",
"resourceLauncherSaveForEveryoneDescription": "Del denne visning med alle organisationsmedlemmer. Når den er ikke markeret, er visningen kun synlig for dig.",
"resourceLauncherMakePersonal": "Gør Personlig",
"resourceLauncherFilter": "Filter",
"resourceLauncherSort": "Sortér",
"resourceLauncherSortAscending": "Sortér stigende",
"resourceLauncherSortDescending": "Sortér faldende",
"resourceLauncherSettings": "Indstillinger",
"resourceLauncherGroupBy": "Gruppér Efter",
"resourceLauncherGroupBySite": "Websted",
"resourceLauncherGroupByLabel": "Etikett",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Gitter",
"resourceLauncherLayoutList": "Liste",
"resourceLauncherShowLabels": "Vis Etiketter",
"resourceLauncherShowSiteTags": "Vis Site Tags",
"resourceLauncherShowRecents": "Vis Seneste",
"resourceLauncherDeleteView": "Slet Visning",
"resourceLauncherViewAsAdmin": "Vis som Admin",
"resourceLauncherResourceDetailsDescription": "Se detaljer for denne ressource.",
"resourceLauncherUnlabeled": "Uden Etiket",
"resourceLauncherNoSite": "Ingen Site",
"resourceLauncherNoResourcesInGroup": "Ingen ressourcer i denne gruppe",
"resourceLauncherEmptyStateTitle": "Ingen ressourcer tilgængelige",
"resourceLauncherEmptyStateDescription": "Du har endnu ikke adgang til nogen ressourcer. Kontakt din administrator for at anmode om adgang.",
"resourceLauncherEmptyStateNoResultsTitle": "Ingen ressourcer fundet",
"resourceLauncherEmptyStateNoResultsDescription": "Ingen ressourcer matcher din nuværende søgning eller filtre. Prøv at justere dem for at finde det, du leder efter.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressourcer matcher \"{query}\". Prøv at justere din søgning eller rydde filtre for at se alle ressourcer.",
"resourceLauncherCopiedToClipboard": "Kopieret til udklipsholderen",
"resourceLauncherCopiedAccessDescription": "Ressourcetilgangen er kopieret til din udklipsholder.",
"resourceLauncherViewNamePlaceholder": "Vis navn",
"resourceLauncherViewNameLabel": "Vis Navn",
"resourceLauncherViewSaved": "Vis Gemt",
"resourceLauncherViewSavedDescription": "Din launcher-visning er blevet gemt.",
"resourceLauncherViewSaveFailed": "Kunne ikke gemme visning",
"resourceLauncherViewSaveFailedDescription": "Kunne ikke gemme launcher-visningen. Prøv venligst igen.",
"resourceLauncherViewDeleted": "Visning slået væk",
"resourceLauncherViewDeletedDescription": "Launcher-visningen er blevet slettet.",
"resourceLauncherViewDeleteFailed": "Kunne ikke slette visning",
"resourceLauncherViewDeleteFailedDescription": "Kan ikke slette launcher-visningen. Prøv venligst igen.",
"memberPortalPrevious": "Forrige", "memberPortalPrevious": "Forrige",
"memberPortalNext": "Næste", "memberPortalNext": "Næste",
"httpSettings": "HTTP Indstillinger", "httpSettings": "HTTP Indstillinger",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØGLE-----", "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØGLE-----",
"sshPrivateKeyRequired": "Privat nøgle er påkrævet", "sshPrivateKeyRequired": "Privat nøgle er påkrævet",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Indtast VNC-adgangskoden for at oprette forbindelse til", "vncSignInDescription": "Indtast dine VNC-legitimationsoplysninger for at oprette forbindelse",
"vncUsernameOptional": "Brugernavn (valgfrit)",
"vncPasswordOptional": "Adgangskode (valgfrit)", "vncPasswordOptional": "Adgangskode (valgfrit)",
"vncNoResourceTarget": "Intet ressourcemål tilgængeligt", "vncNoResourceTarget": "Intet ressourcemål tilgængeligt",
"vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC", "vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Standort aktualisiert", "siteUpdated": "Standort aktualisiert",
"siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteUpdatedDescription": "Der Standort wurde aktualisiert.",
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
"siteRestartTitle": "Standort neu starten",
"siteRestartDescription": "Starten Sie den WireGuard-Tunnel für diesen Standort neu. Dies wird die Konnektivität kurzzeitig unterbrechen.",
"siteRestartBody": "Verwenden Sie dies, wenn der Standort-Tunnel nicht ordnungsgemäß funktioniert und Sie eine erneute Verbindung erzwingen möchten, ohne den Host neu zu starten.",
"siteRestartButton": "Standort neu starten",
"siteRestartDialogMessage": "Sind Sie sicher, dass Sie den WireGuard-Tunnel für <b>{name}</b> neu starten möchten? Der Standort wird kurzzeitig die Konnektivität verlieren.",
"siteRestartWarning": "Der Standort wird kurzzeitig getrennt, während der Tunnel neu gestartet wird.",
"siteRestarted": "Standort neu gestartet",
"siteRestartedDescription": "Der WireGuard-Tunnel wurde neu gestartet.",
"siteErrorRestart": "Fehler beim Neustart des Standorts",
"siteErrorRestartDescription": "Ein Fehler ist aufgetreten, während der Standort neu gestartet wurde.",
"siteSettingDescription": "Standorteinstellungen konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteResourcesTab": "Ressourcen", "siteResourcesTab": "Ressourcen",
"siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen", "siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Blueprint anwenden", "actionApplyBlueprint": "Blueprint anwenden",
"actionListBlueprints": "Blaupausen anzeigen", "actionListBlueprints": "Blaupausen anzeigen",
"actionGetBlueprint": "Erhalte Blaupause", "actionGetBlueprint": "Erhalte Blaupause",
"actionCreateOrgWideLauncherView": "Organisationen-Weiter-Startansicht erstellen",
"setupToken": "Setup-Token", "setupToken": "Setup-Token",
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
"setupTokenRequired": "Setup-Token ist erforderlich", "setupTokenRequired": "Setup-Token ist erforderlich",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnetz", "subnetPlaceholder": "Subnetz",
"addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.", "addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.",
"selectSites": "Standorte auswählen", "selectSites": "Standorte auswählen",
"selectLabels": "Etiketten auswählen",
"sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.", "sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.",
"clientInstallOlm": "Olm installieren", "clientInstallOlm": "Olm installieren",
"clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen", "clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Standort", "createInternalResourceDialogSite": "Standort",
"selectSite": "Standort auswählen...", "selectSite": "Standort auswählen...",
"multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}", "multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}",
"labelsSelectorLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}",
"noSitesFound": "Keine Standorte gefunden.", "noSitesFound": "Keine Standorte gefunden.",
"createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogProtocol": "Protokoll",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Entfernte Knoten", "sidebarRemoteExitNodes": "Entfernte Knoten",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Geheimnis", "remoteExitNodeSecretKey": "Geheimnis",
"remoteExitNodeNetworkingTitle": "Netzwerkeinstellungen",
"remoteExitNodeNetworkingDescription": "Konfigurieren Sie, wie dieser Remote Exit Node den Datenverkehr leitet und welche Standorte bevorzugt über ihn verbinden. Erweiterte Funktionen zur Verwendung mit Backhaul-Netzwerkkonfigurationen.",
"remoteExitNodeNetworkingSave": "Einstellungen speichern",
"remoteExitNodeNetworkingSaveSuccessTitle": "Netzwerkeinstellungen gespeichert",
"remoteExitNodeNetworkingSaveSuccessDescription": "Netzwerkeinstellungen wurden erfolgreich aktualisiert.",
"remoteExitNodeNetworkingSaveError": "Fehler beim Speichern der Netzwerkeinstellungen",
"remoteExitNodeNetworkingSubnetsTitle": "Remote-Subnetze",
"remoteExitNodeNetworkingSubnetsDescription": "Definieren Sie die CIDR-Bereiche, an die dieser Remote Exit Node den Datenverkehr weiterleitet. Geben Sie einen gültigen CIDR (z. B. <code>10.0.0.0/8</code>) ein und drücken Sie die Eingabetaste, um hinzuzufügen.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Erstelle Remote Node", "title": "Erstelle Remote Node",
"description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes", "description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpGoogleDescription": "Google OAuth2/OIDC Provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnetz", "subnet": "Subnetz",
"utilitySubnet": "Nutzsubnetz",
"subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.",
"customDomain": "Eigene Domain", "customDomain": "Eigene Domain",
"authPage": "Authentifizierungs-Seiten", "authPage": "Authentifizierungs-Seiten",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-Mail-Whitelist", "memberPortalEmailWhitelist": "E-Mail-Whitelist",
"memberPortalResourceDisabled": "Ressource deaktiviert", "memberPortalResourceDisabled": "Ressource deaktiviert",
"memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen", "memberPortalShowingResources": "Zeige {start}-{end} von {total} Ressourcen",
"resourceLauncherTitle": "Ressourcenstarter",
"resourceLauncherDescription": "Sehen Sie sich Ressourcendetails an und starten Sie sie von einem Ort aus",
"resourceLauncherSearchPlaceholder": "Alle Standorte durchsuchen...",
"resourceLauncherDefaultView": "Standard",
"resourceLauncherSaveView": "Ansicht speichern",
"resourceLauncherSaveToCurrentView": "In aktueller Ansicht speichern",
"resourceLauncherResetView": "Ansicht zurücksetzen",
"resourceLauncherSaveAsNewView": "Als neue Ansicht speichern",
"resourceLauncherSaveAsNewViewDescription": "Geben Sie dieser Ansicht einen Namen, um Ihre aktuellen Filter und das Layout zu speichern.",
"resourceLauncherSaveForEveryone": "Für alle speichern",
"resourceLauncherSaveForEveryoneDescription": "Teilen Sie diese Ansicht mit allen Organisationsmitgliedern. Wenn nicht aktiviert, ist die Ansicht nur für Sie sichtbar.",
"resourceLauncherMakePersonal": "Persönlich machen",
"resourceLauncherFilter": "Filter",
"resourceLauncherSort": "Sortieren",
"resourceLauncherSortAscending": "Aufsteigend sortieren",
"resourceLauncherSortDescending": "Absteigend sortieren",
"resourceLauncherSettings": "Einstellungen",
"resourceLauncherGroupBy": "Gruppieren nach",
"resourceLauncherGroupBySite": "Standort",
"resourceLauncherGroupByLabel": "Etikett",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Raster",
"resourceLauncherLayoutList": "Liste",
"resourceLauncherShowLabels": "Etiketten anzeigen",
"resourceLauncherShowSiteTags": "Standort-Tags anzeigen",
"resourceLauncherShowRecents": "Kürzlich anzeigen",
"resourceLauncherDeleteView": "Ansicht löschen",
"resourceLauncherViewAsAdmin": "Ansicht als Administrator anzeigen",
"resourceLauncherResourceDetailsDescription": "Anzeigen von Details zu dieser Ressource.",
"resourceLauncherUnlabeled": "Nicht etikettiert",
"resourceLauncherNoSite": "Kein Standort",
"resourceLauncherNoResourcesInGroup": "Keine Ressourcen in dieser Gruppe",
"resourceLauncherEmptyStateTitle": "Keine Ressourcen verfügbar",
"resourceLauncherEmptyStateDescription": "Sie haben noch keinen Zugriff auf Ressourcen. Kontaktieren Sie Ihren Administrator, um Zugriff anzufordern.",
"resourceLauncherEmptyStateNoResultsTitle": "Keine Ressourcen gefunden",
"resourceLauncherEmptyStateNoResultsDescription": "Keine Ressourcen entsprechen Ihrer aktuellen Suche oder den Filtern. Versuchen Sie, diese anzupassen, um zu finden, wonach Sie suchen.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Keine Ressourcen entsprechen \"{query}\". Versuchen Sie, Ihre Suche anzupassen oder die Filter zu löschen, um alle Ressourcen anzuzeigen.",
"resourceLauncherCopiedToClipboard": "In die Zwischenablage kopiert",
"resourceLauncherCopiedAccessDescription": "Der Ressourcenzugriff wurde in Ihre Zwischenablage kopiert.",
"resourceLauncherViewNamePlaceholder": "Ansichtsname",
"resourceLauncherViewNameLabel": "Ansichtsname",
"resourceLauncherViewSaved": "Ansicht gespeichert",
"resourceLauncherViewSavedDescription": "Ihre Startansicht wurde gespeichert.",
"resourceLauncherViewSaveFailed": "Fehler beim Speichern der Ansicht",
"resourceLauncherViewSaveFailedDescription": "Die Startansicht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"resourceLauncherViewDeleted": "Ansicht gelöscht",
"resourceLauncherViewDeletedDescription": "Die Startansicht wurde gelöscht.",
"resourceLauncherViewDeleteFailed": "Fehler beim Löschen der Ansicht",
"resourceLauncherViewDeleteFailedDescription": "Die Startansicht konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
"memberPortalPrevious": "Vorherige", "memberPortalPrevious": "Vorherige",
"memberPortalNext": "Nächste", "memberPortalNext": "Nächste",
"httpSettings": "HTTP-Einstellungen", "httpSettings": "HTTP-Einstellungen",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich", "sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Geben Sie Ihr VNC-Passwort ein, um sich zu verbinden", "vncSignInDescription": "Geben Sie Ihre VNC-Zugangsdaten ein, um sich zu verbinden",
"vncUsernameOptional": "Benutzername (optional)",
"vncPasswordOptional": "Passwort (optional)", "vncPasswordOptional": "Passwort (optional)",
"vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar", "vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar",
"vncFailedToLoadNovnc": "Fehler beim Laden von noVNC", "vncFailedToLoadNovnc": "Fehler beim Laden von noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site updated", "siteUpdated": "Site updated",
"siteUpdatedDescription": "The site has been updated.", "siteUpdatedDescription": "The site has been updated.",
"siteGeneralDescription": "Configure the general settings for this site", "siteGeneralDescription": "Configure the general settings for this site",
"siteRestartTitle": "Restart Site",
"siteRestartDescription": "Restart the WireGuard tunnel for this site. This will briefly interrupt connectivity.",
"siteRestartBody": "Use this if the site tunnel is not functioning correctly and you want to force a reconnect without restarting the host.",
"siteRestartButton": "Restart Site",
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
"siteRestarted": "Site restarted",
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
"siteErrorRestart": "Failed to restart site",
"siteErrorRestartDescription": "An error occurred while restarting the site.",
"siteSettingDescription": "Configure the settings on the site", "siteSettingDescription": "Configure the settings on the site",
"siteResourcesTab": "Resources", "siteResourcesTab": "Resources",
"siteResourcesNoneOnSite": "This site has no public or private resources yet.", "siteResourcesNoneOnSite": "This site has no public or private resources yet.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Apply Blueprint", "actionApplyBlueprint": "Apply Blueprint",
"actionListBlueprints": "List Blueprints", "actionListBlueprints": "List Blueprints",
"actionGetBlueprint": "Get Blueprint", "actionGetBlueprint": "Get Blueprint",
"actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View",
"setupToken": "Setup Token", "setupToken": "Setup Token",
"setupTokenDescription": "Enter the setup token from the server console.", "setupTokenDescription": "Enter the setup token from the server console.",
"setupTokenRequired": "Setup token is required", "setupTokenRequired": "Setup token is required",
@@ -1904,6 +1915,9 @@
"billingDomains": "Domains", "billingDomains": "Domains",
"billingOrganizations": "Orgs", "billingOrganizations": "Orgs",
"billingRemoteExitNodes": "Remote Nodes", "billingRemoteExitNodes": "Remote Nodes",
"billingPublicResources": "Public Resources",
"billingPrivateResources": "Private Resources",
"billingMachineClients": "Machine Clients",
"billingNoLimitConfigured": "No limit configured", "billingNoLimitConfigured": "No limit configured",
"billingEstimatedPeriod": "Estimated Billing Period", "billingEstimatedPeriod": "Estimated Billing Period",
"billingIncludedUsage": "Included Usage", "billingIncludedUsage": "Included Usage",
@@ -1932,6 +1946,9 @@
"billingUsersInfo": "How many users you can use", "billingUsersInfo": "How many users you can use",
"billingDomainInfo": "How many domains you can use", "billingDomainInfo": "How many domains you can use",
"billingRemoteExitNodesInfo": "How many remote nodes you can use", "billingRemoteExitNodesInfo": "How many remote nodes you can use",
"billingPublicResourcesInfo": "How many public resources you can use",
"billingPrivateResourcesInfo": "How many private resources you can use",
"billingMachineClientsInfo": "How many machine clients you can use",
"billingLicenseKeys": "License Keys", "billingLicenseKeys": "License Keys",
"billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription", "billingLicenseSubscription": "License Subscription",
@@ -2077,6 +2094,7 @@
"subnetPlaceholder": "Subnet", "subnetPlaceholder": "Subnet",
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
"selectSites": "Select sites", "selectSites": "Select sites",
"selectLabels": "Select labels",
"sitesDescription": "The client will have connectivity to the selected sites", "sitesDescription": "The client will have connectivity to the selected sites",
"clientInstallOlm": "Install Machine Client", "clientInstallOlm": "Install Machine Client",
"clientInstallOlmDescription": "Install the machine client for your system", "clientInstallOlmDescription": "Install the machine client for your system",
@@ -2304,6 +2322,7 @@
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Select site...", "selectSite": "Select site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
"noSitesFound": "No sites found.", "noSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2397,21 @@
"sidebarRemoteExitNodes": "Remote Nodes", "sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Secret", "remoteExitNodeSecretKey": "Secret",
"remoteExitNodeNetworkingTitle": "Network Settings",
"remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it. Advanced features to be used with backhaul networking configurations.",
"remoteExitNodeNetworkingSave": "Save Settings",
"remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved",
"remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.",
"remoteExitNodeNetworkingSaveError": "Failed to save network settings",
"remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets",
"remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. <code>10.0.0.0/8</code>) and press Enter to add.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Create Remote Node", "title": "Create Remote Node",
"description": "Create a new self-hosted remote relay and proxy server node", "description": "Create a new self-hosted remote relay and proxy server node",
@@ -3542,6 +3576,55 @@
"memberPortalEmailWhitelist": "Email Whitelist", "memberPortalEmailWhitelist": "Email Whitelist",
"memberPortalResourceDisabled": "Resource Disabled", "memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View resource details and launch them from one place",
"resourceLauncherSearchPlaceholder": "Search all sites...",
"resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherResetView": "Reset View",
"resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter",
"resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending",
"resourceLauncherSettings": "Settings",
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
"resourceLauncherShowLabels": "Show Labels",
"resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site",
"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.",
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
"resourceLauncherViewNameLabel": "View Name",
"resourceLauncherViewSaved": "View saved",
"resourceLauncherViewSavedDescription": "Your launcher view has been saved.",
"resourceLauncherViewSaveFailed": "Failed to save view",
"resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.",
"resourceLauncherViewDeleted": "View deleted",
"resourceLauncherViewDeletedDescription": "The launcher view has been deleted.",
"resourceLauncherViewDeleteFailed": "Failed to delete view",
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
"memberPortalPrevious": "Previous", "memberPortalPrevious": "Previous",
"memberPortalNext": "Next", "memberPortalNext": "Next",
"httpSettings": "HTTP Settings", "httpSettings": "HTTP Settings",
@@ -3577,7 +3660,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Private key is required", "sshPrivateKeyRequired": "Private key is required",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Enter your VNC password to connect", "vncSignInDescription": "Enter your VNC credentials to connect",
"vncUsernameOptional": "Username (optional)",
"vncPasswordOptional": "Password (optional)", "vncPasswordOptional": "Password (optional)",
"vncNoResourceTarget": "No resource target is available", "vncNoResourceTarget": "No resource target is available",
"vncFailedToLoadNovnc": "Failed to load noVNC", "vncFailedToLoadNovnc": "Failed to load noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Sitio actualizado", "siteUpdated": "Sitio actualizado",
"siteUpdatedDescription": "El sitio ha sido actualizado.", "siteUpdatedDescription": "El sitio ha sido actualizado.",
"siteGeneralDescription": "Configurar la configuración general de este sitio", "siteGeneralDescription": "Configurar la configuración general de este sitio",
"siteRestartTitle": "Reiniciar Sitio",
"siteRestartDescription": "Reinicia el túnel WireGuard para este sitio. Esto interrumpirá brevemente la conectividad.",
"siteRestartBody": "Utiliza esto si el túnel del sitio no está funcionando correctamente y quieres forzar una reconexión sin reiniciar el host.",
"siteRestartButton": "Reiniciar Sitio",
"siteRestartDialogMessage": "¿Estás seguro de que deseas reiniciar el túnel WireGuard para <b>{name}</b>? El sitio perderá conectividad brevemente.",
"siteRestartWarning": "El sitio se desconectará brevemente mientras se reinicia el túnel.",
"siteRestarted": "Sitio reiniciado",
"siteRestartedDescription": "El túnel WireGuard ha sido reiniciado.",
"siteErrorRestart": "Error al reiniciar el sitio",
"siteErrorRestartDescription": "Se ha producido un error al reiniciar el sitio.",
"siteSettingDescription": "Configurar los ajustes en el sitio", "siteSettingDescription": "Configurar los ajustes en el sitio",
"siteResourcesTab": "Recursos", "siteResourcesTab": "Recursos",
"siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.", "siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Aplicar plano", "actionApplyBlueprint": "Aplicar plano",
"actionListBlueprints": "Listar blueprints", "actionListBlueprints": "Listar blueprints",
"actionGetBlueprint": "Obtener blueprint", "actionGetBlueprint": "Obtener blueprint",
"actionCreateOrgWideLauncherView": "Crear Vista de Lanzador para toda la Organización",
"setupToken": "Configuración de token", "setupToken": "Configuración de token",
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
"setupTokenRequired": "Se requiere el token de configuración", "setupTokenRequired": "Se requiere el token de configuración",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subred", "subnetPlaceholder": "Subred",
"addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.", "addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.",
"selectSites": "Seleccionar sitios", "selectSites": "Seleccionar sitios",
"selectLabels": "Seleccionar etiquetas",
"sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados", "sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados",
"clientInstallOlm": "Instalar Olm", "clientInstallOlm": "Instalar Olm",
"clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema", "clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Sitio", "createInternalResourceDialogSite": "Sitio",
"selectSite": "Seleccionar sitio...", "selectSite": "Seleccionar sitio...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}", "multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}",
"noSitesFound": "Sitios no encontrados.", "noSitesFound": "Sitios no encontrados.",
"createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogProtocol": "Protocolo",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nodos remotos", "sidebarRemoteExitNodes": "Nodos remotos",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Secreto", "remoteExitNodeSecretKey": "Secreto",
"remoteExitNodeNetworkingTitle": "Ajustes de Red",
"remoteExitNodeNetworkingDescription": "Configura cómo este nodo de salida remoto dirige el tráfico y qué sitios prefieren conectarse a través de él. Características avanzadas para usar con configuraciones de red de retroceso.",
"remoteExitNodeNetworkingSave": "Guardar Ajustes",
"remoteExitNodeNetworkingSaveSuccessTitle": "Ajustes de red guardados",
"remoteExitNodeNetworkingSaveSuccessDescription": "Los ajustes de red han sido actualizados exitosamente.",
"remoteExitNodeNetworkingSaveError": "Error al guardar los ajustes de red",
"remoteExitNodeNetworkingSubnetsTitle": "Subredes Remotas",
"remoteExitNodeNetworkingSubnetsDescription": "Define los rangos CIDR a los que este nodo de salida remoto dirigirá el tráfico. Escribe un CIDR válido (e.g. <code>10.0.0.0/8</code>) y presiona Enter para añadir.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Crear nodo remoto", "title": "Crear nodo remoto",
"description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado", "description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subred", "subnet": "Subred",
"utilitySubnet": "Subred de Utilidad",
"subnetDescription": "La subred para la configuración de red de esta organización.", "subnetDescription": "La subred para la configuración de red de esta organización.",
"customDomain": "Dominio personalizado", "customDomain": "Dominio personalizado",
"authPage": "Páginas de autenticación", "authPage": "Páginas de autenticación",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista Blanca de Correo", "memberPortalEmailWhitelist": "Lista Blanca de Correo",
"memberPortalResourceDisabled": "Recurso Deshabilitado", "memberPortalResourceDisabled": "Recurso Deshabilitado",
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
"resourceLauncherTitle": "Lanzador de Recursos",
"resourceLauncherDescription": "Ve los detalles de los recursos y lánzalos desde un solo lugar",
"resourceLauncherSearchPlaceholder": "Buscar en todos los sitios...",
"resourceLauncherDefaultView": "Predeterminado",
"resourceLauncherSaveView": "Guardar Vista",
"resourceLauncherSaveToCurrentView": "Guardar en la Vista Actual",
"resourceLauncherResetView": "Restablecer Vista",
"resourceLauncherSaveAsNewView": "Guardar como Nueva Vista",
"resourceLauncherSaveAsNewViewDescription": "Ponle un nombre a esta vista para guardar tus filtros y diseño actuales.",
"resourceLauncherSaveForEveryone": "Guardar para Todos",
"resourceLauncherSaveForEveryoneDescription": "Comparte esta vista con todos los miembros de la organización. Si está desmarcado, la vista solo es visible para ti.",
"resourceLauncherMakePersonal": "Hacer Personal",
"resourceLauncherFilter": "Filtro",
"resourceLauncherSort": "Ordenar",
"resourceLauncherSortAscending": "Ordenar Ascendente",
"resourceLauncherSortDescending": "Ordenar Descendente",
"resourceLauncherSettings": "Ajustes",
"resourceLauncherGroupBy": "Agrupar Por",
"resourceLauncherGroupBySite": "Sitio",
"resourceLauncherGroupByLabel": "Etiqueta",
"resourceLauncherLayout": "Disposición",
"resourceLauncherLayoutGrid": "Cuadrícula",
"resourceLauncherLayoutList": "Lista",
"resourceLauncherShowLabels": "Mostrar Etiquetas",
"resourceLauncherShowSiteTags": "Mostrar Etiquetas del Sitio",
"resourceLauncherShowRecents": "Mostrar Recientes",
"resourceLauncherDeleteView": "Eliminar Vista",
"resourceLauncherViewAsAdmin": "Ver como Administrador",
"resourceLauncherResourceDetailsDescription": "Ver detalles de este recurso.",
"resourceLauncherUnlabeled": "Sin Etiqueta",
"resourceLauncherNoSite": "Sin Sitio",
"resourceLauncherNoResourcesInGroup": "No hay recursos en este grupo",
"resourceLauncherEmptyStateTitle": "No hay Recursos Disponibles",
"resourceLauncherEmptyStateDescription": "Todavía no tienes acceso a ningún recurso. Contacta a tu administrador para solicitar acceso.",
"resourceLauncherEmptyStateNoResultsTitle": "No se Encontraron Recursos",
"resourceLauncherEmptyStateNoResultsDescription": "No hay recursos que coincidan con tu búsqueda o filtros actuales. Intenta ajustarlos para encontrar lo que buscas.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No hay recursos que coincidan con \"{query}\". Intenta ajustar tu búsqueda o borrar filtros para ver todos los recursos.",
"resourceLauncherCopiedToClipboard": "Copiado al portapapeles",
"resourceLauncherCopiedAccessDescription": "El acceso al recurso ha sido copiado a tu portapapeles.",
"resourceLauncherViewNamePlaceholder": "Nombre de la Vista",
"resourceLauncherViewNameLabel": "Nombre de la Vista",
"resourceLauncherViewSaved": "Vista guardada",
"resourceLauncherViewSavedDescription": "Tu vista del lanzador ha sido guardada.",
"resourceLauncherViewSaveFailed": "Error al guardar la vista",
"resourceLauncherViewSaveFailedDescription": "No se pudo guardar la vista del lanzador. Por favor, intenta de nuevo.",
"resourceLauncherViewDeleted": "Vista eliminada",
"resourceLauncherViewDeletedDescription": "La vista del lanzador ha sido eliminada.",
"resourceLauncherViewDeleteFailed": "Error al eliminar la vista",
"resourceLauncherViewDeleteFailedDescription": "No se pudo eliminar la vista del lanzador. Por favor, intenta de nuevo.",
"memberPortalPrevious": "Anterior", "memberPortalPrevious": "Anterior",
"memberPortalNext": "Siguiente", "memberPortalNext": "Siguiente",
"httpSettings": "Configuración HTTP", "httpSettings": "Configuración HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----", "sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----",
"sshPrivateKeyRequired": "Se requiere clave privada", "sshPrivateKeyRequired": "Se requiere clave privada",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Introduce tu contraseña VNC para conectar", "vncSignInDescription": "Introduce tus credenciales VNC para conectarte",
"vncUsernameOptional": "Nombre de usuario (opcional)",
"vncPasswordOptional": "Contraseña (opcional)", "vncPasswordOptional": "Contraseña (opcional)",
"vncNoResourceTarget": "No hay objetivo de recurso disponible", "vncNoResourceTarget": "No hay objetivo de recurso disponible",
"vncFailedToLoadNovnc": "Error al cargar noVNC", "vncFailedToLoadNovnc": "Error al cargar noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Nœud mis à jour", "siteUpdated": "Nœud mis à jour",
"siteUpdatedDescription": "Le nœud a été mis à jour.", "siteUpdatedDescription": "Le nœud a été mis à jour.",
"siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud",
"siteRestartTitle": "Redémarrer Site",
"siteRestartDescription": "Redémarrer le tunnel WireGuard pour ce site. Cela interrompra brièvement la connectivité.",
"siteRestartBody": "Utilisez cela si le tunnel du site ne fonctionne pas correctement et que vous souhaitez forcer une reconnexion sans redémarrer l'hôte.",
"siteRestartButton": "Redémarrer Site",
"siteRestartDialogMessage": "Êtes-vous sûr de vouloir redémarrer le tunnel WireGuard pour <b>{name}</b>? Le site perdra brièvement sa connectivité.",
"siteRestartWarning": "Le site sera brièvement déconnecté pendant le redémarrage du tunnel.",
"siteRestarted": "Site redémarré",
"siteRestartedDescription": "Le tunnel WireGuard a été redémarré.",
"siteErrorRestart": "Échec du redémarrage du site",
"siteErrorRestartDescription": "Une erreur s'est produite lors du redémarrage du site.",
"siteSettingDescription": "Configurer les paramètres du site", "siteSettingDescription": "Configurer les paramètres du site",
"siteResourcesTab": "Ressources", "siteResourcesTab": "Ressources",
"siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.", "siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Appliquer la Config", "actionApplyBlueprint": "Appliquer la Config",
"actionListBlueprints": "Lister les plans", "actionListBlueprints": "Lister les plans",
"actionGetBlueprint": "Obtenez un plan", "actionGetBlueprint": "Obtenez un plan",
"actionCreateOrgWideLauncherView": "Créer une vue de lancement au niveau de l'organisation",
"setupToken": "Jeton de configuration", "setupToken": "Jeton de configuration",
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
"setupTokenRequired": "Le jeton de configuration est requis.", "setupTokenRequired": "Le jeton de configuration est requis.",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sous-réseau", "subnetPlaceholder": "Sous-réseau",
"addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.", "addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.",
"selectSites": "Sélectionner des sites", "selectSites": "Sélectionner des sites",
"selectLabels": "Sélectionner des étiquettes",
"sitesDescription": "Le client aura une connectivité vers les sites sélectionnés", "sitesDescription": "Le client aura une connectivité vers les sites sélectionnés",
"clientInstallOlm": "Installer Olm", "clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Faites fonctionner Olm sur votre système", "clientInstallOlmDescription": "Faites fonctionner Olm sur votre système",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Sélectionner un site...", "selectSite": "Sélectionner un site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}",
"noSitesFound": "Aucun site trouvé.", "noSitesFound": "Aucun site trouvé.",
"createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogProtocol": "Protocole",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nœuds distants", "sidebarRemoteExitNodes": "Nœuds distants",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Clé secrète", "remoteExitNodeSecretKey": "Clé secrète",
"remoteExitNodeNetworkingTitle": "Paramètres du réseau",
"remoteExitNodeNetworkingDescription": "Configurez comment ce nœud de sortie distant acheminera le trafic et quels sites préfèrent se connecter via ce dernier. Fonctions avancées à utiliser avec les configurations réseau de retour.",
"remoteExitNodeNetworkingSave": "Enregistrer les paramètres",
"remoteExitNodeNetworkingSaveSuccessTitle": "Paramètres du réseau enregistrés",
"remoteExitNodeNetworkingSaveSuccessDescription": "Les paramètres du réseau ont été mis à jour avec succès.",
"remoteExitNodeNetworkingSaveError": "Échec de l'enregistrement des paramètres du réseau",
"remoteExitNodeNetworkingSubnetsTitle": "Sous-réseaux distants",
"remoteExitNodeNetworkingSubnetsDescription": "Définissez les plages CIDR que ce nœud de sortie distant acheminera. Saisissez un CIDR valide (par exemple <code>10.0.0.0/8</code>) et appuyez sur Entrée pour ajouter.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
"remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Créer un nœud distant", "title": "Créer un nœud distant",
"description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé", "description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sous-réseau", "subnet": "Sous-réseau",
"utilitySubnet": "Routeur utilitaire",
"subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.",
"customDomain": "Domaine personnalisé", "customDomain": "Domaine personnalisé",
"authPage": "Pages d'authentification", "authPage": "Pages d'authentification",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Liste blanche des e-mails", "memberPortalEmailWhitelist": "Liste blanche des e-mails",
"memberPortalResourceDisabled": "Ressource désactivée", "memberPortalResourceDisabled": "Ressource désactivée",
"memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources", "memberPortalShowingResources": "Affichage de {start}-{end} sur {total} ressources",
"resourceLauncherTitle": "Lanceur de ressources",
"resourceLauncherDescription": "Afficher les détails des ressources et les lancer depuis un seul endroit",
"resourceLauncherSearchPlaceholder": "Rechercher sur tous les sites...",
"resourceLauncherDefaultView": "Par défaut",
"resourceLauncherSaveView": "Enregistrer la vue",
"resourceLauncherSaveToCurrentView": "Enregistrer dans la vue actuelle",
"resourceLauncherResetView": "Réinitialiser la vue",
"resourceLauncherSaveAsNewView": "Enregistrer comme nouvelle vue",
"resourceLauncherSaveAsNewViewDescription": "Donnez un nom à cette vue pour enregistrer vos filtres et mise en page actuels.",
"resourceLauncherSaveForEveryone": "Enregistrer pour tout le monde",
"resourceLauncherSaveForEveryoneDescription": "Partagez cette vue avec tous les membres de l'organisation. Lorsque décochée, la vue est visible uniquement par vous.",
"resourceLauncherMakePersonal": "Rendre personnel",
"resourceLauncherFilter": "Filtrer",
"resourceLauncherSort": "Trier",
"resourceLauncherSortAscending": "Trier par ordre croissant",
"resourceLauncherSortDescending": "Trier par ordre décroissant",
"resourceLauncherSettings": "Réglages",
"resourceLauncherGroupBy": "Grouper par",
"resourceLauncherGroupBySite": "Nœud",
"resourceLauncherGroupByLabel": "Étiquette",
"resourceLauncherLayout": "Mise en page",
"resourceLauncherLayoutGrid": "Grille",
"resourceLauncherLayoutList": "Liste",
"resourceLauncherShowLabels": "Afficher les étiquettes",
"resourceLauncherShowSiteTags": "Afficher les tags de site",
"resourceLauncherShowRecents": "Afficher les récents",
"resourceLauncherDeleteView": "Supprimer la vue",
"resourceLauncherViewAsAdmin": "Voir en tant qu'admin",
"resourceLauncherResourceDetailsDescription": "Afficher les détails pour cette ressource.",
"resourceLauncherUnlabeled": "Non étiqueté",
"resourceLauncherNoSite": "Aucun nœud",
"resourceLauncherNoResourcesInGroup": "Aucune ressource dans ce groupe",
"resourceLauncherEmptyStateTitle": "Aucune ressource disponible",
"resourceLauncherEmptyStateDescription": "Vous n'avez pas encore accès à des ressources. Contactez votre administrateur pour demander l'accès.",
"resourceLauncherEmptyStateNoResultsTitle": "Aucune ressource trouvée",
"resourceLauncherEmptyStateNoResultsDescription": "Aucune ressource ne correspond à votre recherche ou filtre actuel. Essayez de les ajuster pour trouver ce que vous cherchez.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Aucune ressource ne correspond à \"{query}\". Essayez d'ajuster votre recherche ou de supprimer les filtres pour voir toutes les ressources.",
"resourceLauncherCopiedToClipboard": "Copié dans le presse-papiers",
"resourceLauncherCopiedAccessDescription": "L'accès à la ressource a été copié dans votre presse-papiers.",
"resourceLauncherViewNamePlaceholder": "Nom de la vue",
"resourceLauncherViewNameLabel": "Nom de la vue",
"resourceLauncherViewSaved": "Vue enregistrée",
"resourceLauncherViewSavedDescription": "Votre vue de lancement a été enregistrée.",
"resourceLauncherViewSaveFailed": "Échec de l'enregistrement de la vue",
"resourceLauncherViewSaveFailedDescription": "Impossible d'enregistrer la vue de lancement. Veuillez réessayer.",
"resourceLauncherViewDeleted": "Vue supprimée",
"resourceLauncherViewDeletedDescription": "La vue de lancement a été supprimée.",
"resourceLauncherViewDeleteFailed": "Impossible de supprimer la vue",
"resourceLauncherViewDeleteFailedDescription": "Impossible de supprimer la vue de lancement. Veuillez réessayer.",
"memberPortalPrevious": "Précédent", "memberPortalPrevious": "Précédent",
"memberPortalNext": "Suivant", "memberPortalNext": "Suivant",
"httpSettings": "Paramètres HTTP", "httpSettings": "Paramètres HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Une clé privée est requise", "sshPrivateKeyRequired": "Une clé privée est requise",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Entrez votre mot de passe VNC pour vous connecter", "vncSignInDescription": "Entrez vos identifiants VNC pour vous connecter",
"vncUsernameOptional": "Nom d'utilisateur (optionnel)",
"vncPasswordOptional": "Mot de passe (facultatif)", "vncPasswordOptional": "Mot de passe (facultatif)",
"vncNoResourceTarget": "Aucune cible de ressource disponible", "vncNoResourceTarget": "Aucune cible de ressource disponible",
"vncFailedToLoadNovnc": "Échec du chargement de noVNC", "vncFailedToLoadNovnc": "Échec du chargement de noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Sito aggiornato", "siteUpdated": "Sito aggiornato",
"siteUpdatedDescription": "Il sito è stato aggiornato.", "siteUpdatedDescription": "Il sito è stato aggiornato.",
"siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteGeneralDescription": "Configura le impostazioni generali per questo sito",
"siteRestartTitle": "Riavvia Sito",
"siteRestartDescription": "Riavvia il tunnel WireGuard per questo sito. Questo interromperà brevemente la connettività.",
"siteRestartBody": "Usalo se il tunnel del sito non funziona correttamente e vuoi forzare un riconnessione senza riavviare l'host.",
"siteRestartButton": "Riavvia Sito",
"siteRestartDialogMessage": "Sei sicuro di voler riavviare il tunnel WireGuard per <b>{name}</b>? Il sito perderà brevemente la connettività.",
"siteRestartWarning": "Il sito si disconnette brevemente mentre il tunnel si riavvia.",
"siteRestarted": "Sito riavviato",
"siteRestartedDescription": "Il tunnel WireGuard è stato riavviato.",
"siteErrorRestart": "Impossibile riavviare il sito",
"siteErrorRestartDescription": "Si è verificato un errore durante il riavvio del sito.",
"siteSettingDescription": "Configura le impostazioni del sito", "siteSettingDescription": "Configura le impostazioni del sito",
"siteResourcesTab": "Risorse", "siteResourcesTab": "Risorse",
"siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.", "siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Applica Progetto", "actionApplyBlueprint": "Applica Progetto",
"actionListBlueprints": "Elenco Blueprints", "actionListBlueprints": "Elenco Blueprints",
"actionGetBlueprint": "Ottieni Blueprint", "actionGetBlueprint": "Ottieni Blueprint",
"actionCreateOrgWideLauncherView": "Crea Visualizzazione Lanscia Org-Wide",
"setupToken": "Configura Token", "setupToken": "Configura Token",
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
"setupTokenRequired": "Il token di configurazione è richiesto", "setupTokenRequired": "Il token di configurazione è richiesto",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sottorete", "subnetPlaceholder": "Sottorete",
"addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.", "addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.",
"selectSites": "Seleziona siti", "selectSites": "Seleziona siti",
"selectLabels": "Seleziona etichette",
"sitesDescription": "Il cliente avrà connettività ai siti selezionati", "sitesDescription": "Il cliente avrà connettività ai siti selezionati",
"clientInstallOlm": "Installa Olm", "clientInstallOlm": "Installa Olm",
"clientInstallOlmDescription": "Avvia Olm sul tuo sistema", "clientInstallOlmDescription": "Avvia Olm sul tuo sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Sito", "createInternalResourceDialogSite": "Sito",
"selectSite": "Seleziona sito...", "selectSite": "Seleziona sito...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}", "multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}",
"noSitesFound": "Nessun sito trovato.", "noSitesFound": "Nessun sito trovato.",
"createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogProtocol": "Protocollo",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nodi Remoti", "sidebarRemoteExitNodes": "Nodi Remoti",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Segreto", "remoteExitNodeSecretKey": "Segreto",
"remoteExitNodeNetworkingTitle": "Impostazioni di Rete",
"remoteExitNodeNetworkingDescription": "Configura come questo nodo di uscita remoto indirizza il traffico e quali siti preferiscono connettersi tramite esso. Caratteristiche avanzate da utilizzare con le configurazioni di rete backhaul.",
"remoteExitNodeNetworkingSave": "Salva Impostazioni",
"remoteExitNodeNetworkingSaveSuccessTitle": "Impostazioni di rete salvate",
"remoteExitNodeNetworkingSaveSuccessDescription": "Le impostazioni di rete sono state aggiornate con successo.",
"remoteExitNodeNetworkingSaveError": "Impossibile salvare le impostazioni di rete",
"remoteExitNodeNetworkingSubnetsTitle": "Sottoreti Remote",
"remoteExitNodeNetworkingSubnetsDescription": "Definisci gli intervalli CIDR che questo nodo di uscita remota inoltrerà il traffico. Digita un CIDR valido (ad esempio <code>10.0.0.0/8</code>) e premi Invio per aggiungerlo.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Crea Nodo Remoto", "title": "Crea Nodo Remoto",
"description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio", "description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider", "idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sottorete", "subnet": "Sottorete",
"utilitySubnet": "Sottorete di utilità",
"subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.",
"customDomain": "Dominio Personalizzato", "customDomain": "Dominio Personalizzato",
"authPage": "Pagine di Autenticazione", "authPage": "Pagine di Autenticazione",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista Autorizzazioni Email", "memberPortalEmailWhitelist": "Lista Autorizzazioni Email",
"memberPortalResourceDisabled": "Risorsa Disabilitata", "memberPortalResourceDisabled": "Risorsa Disabilitata",
"memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse", "memberPortalShowingResources": "Mostrando {start}-{end} di {total} risorse",
"resourceLauncherTitle": "Lanscia Risorse",
"resourceLauncherDescription": "Visualizza i dettagli delle risorse e lanciale da un solo posto",
"resourceLauncherSearchPlaceholder": "Cerca tutti i siti...",
"resourceLauncherDefaultView": "Predefinito",
"resourceLauncherSaveView": "Salva Visualizzazione",
"resourceLauncherSaveToCurrentView": "Salva alla Visualizzazione Corrente",
"resourceLauncherResetView": "Reimposta Visualizzazione",
"resourceLauncherSaveAsNewView": "Salva come Nuova Visualizzazione",
"resourceLauncherSaveAsNewViewDescription": "Dai un nome a questa visualizzazione per salvare i tuoi filtri e layout attuali.",
"resourceLauncherSaveForEveryone": "Salva per Tutti",
"resourceLauncherSaveForEveryoneDescription": "Condividi questa visualizzazione con tutti i membri dell'organizzazione. Quando non è selezionata, la visualizzazione è visibile solo a te.",
"resourceLauncherMakePersonal": "Rendi Personale",
"resourceLauncherFilter": "Filtro",
"resourceLauncherSort": "Ordina",
"resourceLauncherSortAscending": "Ordina in ordine crescente",
"resourceLauncherSortDescending": "Ordina in ordine decrescente",
"resourceLauncherSettings": "Impostazioni",
"resourceLauncherGroupBy": "Raggruppa per",
"resourceLauncherGroupBySite": "Sito",
"resourceLauncherGroupByLabel": "Etichetta",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Griglia",
"resourceLauncherLayoutList": "Lista",
"resourceLauncherShowLabels": "Mostra Etichette",
"resourceLauncherShowSiteTags": "Mostra Tag di Sito",
"resourceLauncherShowRecents": "Mostra Recenti",
"resourceLauncherDeleteView": "Elimina Visualizzazione",
"resourceLauncherViewAsAdmin": "Visualizza come Admin",
"resourceLauncherResourceDetailsDescription": "Visualizza i dettagli per questa risorsa.",
"resourceLauncherUnlabeled": "Non Etichettato",
"resourceLauncherNoSite": "Nessun Sito",
"resourceLauncherNoResourcesInGroup": "Nessuna risorsa in questo gruppo",
"resourceLauncherEmptyStateTitle": "Non ci sono risorse disponibili",
"resourceLauncherEmptyStateDescription": "Non hai ancora accesso a nessuna risorsa. Contatta il tuo amministratore per richiedere l'accesso.",
"resourceLauncherEmptyStateNoResultsTitle": "Nessuna risorsa trovata",
"resourceLauncherEmptyStateNoResultsDescription": "Nessuna risorsa corrisponde alla tua ricerca o ai tuoi filtri attuali. Prova a modificarli per trovare ciò che stai cercando.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Nessuna risorsa corrisponde a \"{query}\". Prova a modificare la tua ricerca o a cancellare i filtri per vedere tutte le risorse.",
"resourceLauncherCopiedToClipboard": "Copiato negli appunti",
"resourceLauncherCopiedAccessDescription": "L'accesso alla risorsa è stato copiato nei tuoi appunti.",
"resourceLauncherViewNamePlaceholder": "Nome Visualizzazione",
"resourceLauncherViewNameLabel": "Nome Visualizzazione",
"resourceLauncherViewSaved": "Visualizzazione salvata",
"resourceLauncherViewSavedDescription": "La tua visualizzazione del lanscia è stata salvata.",
"resourceLauncherViewSaveFailed": "Impossibile salvare la visualizzazione",
"resourceLauncherViewSaveFailedDescription": "Impossibile salvare la visualizzazione del lanscia. Per favore riprova.",
"resourceLauncherViewDeleted": "Visualizzazione eliminata",
"resourceLauncherViewDeletedDescription": "La visualizzazione del lanscia è stata eliminata.",
"resourceLauncherViewDeleteFailed": "Impossibile eliminare la visualizzazione",
"resourceLauncherViewDeleteFailedDescription": "Non è stato possibile eliminare la visualizzazione del lanscia. Per favore riprova.",
"memberPortalPrevious": "Precedente", "memberPortalPrevious": "Precedente",
"memberPortalNext": "Successivo", "memberPortalNext": "Successivo",
"httpSettings": "Impostazioni HTTP", "httpSettings": "Impostazioni HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "È richiesta una chiave privata", "sshPrivateKeyRequired": "È richiesta una chiave privata",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Inserisci la tua password VNC per connetterti", "vncSignInDescription": "Inserisci le tue credenziali VNC per connetterti",
"vncUsernameOptional": "Nome utente (facoltativo)",
"vncPasswordOptional": "Password (opzionale)", "vncPasswordOptional": "Password (opzionale)",
"vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile", "vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile",
"vncFailedToLoadNovnc": "Impossibile caricare noVNC", "vncFailedToLoadNovnc": "Impossibile caricare noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "사이트가 업데이트되었습니다", "siteUpdated": "사이트가 업데이트되었습니다",
"siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteUpdatedDescription": "사이트가 업데이트되었습니다.",
"siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.",
"siteRestartTitle": "사이트 다시 시작",
"siteRestartDescription": "이 사이트의 WireGuard 터널을 다시 시작합니다. 일시적으로 연결이 중단될 수 있습니다.",
"siteRestartBody": "사이트 터널이 제대로 작동하지 않을 경우, 호스트를 재시작하지 않고 다시 연결을 강제하려면 이 옵션을 사용하세요.",
"siteRestartButton": "사이트 다시 시작",
"siteRestartDialogMessage": "<b>{name}</b>의 WireGuard 터널을 재시작하시겠습니까? 이 작업으로 인해 사이트의 연결이 일시적으로 중단될 수 있습니다.",
"siteRestartWarning": "터널을 재시작하는 동안 사이트가 일시적으로 연결이 끊깁니다.",
"siteRestarted": "사이트가 재시작되었습니다",
"siteRestartedDescription": "WireGuard 터널이 재시작되었습니다.",
"siteErrorRestart": "사이트 재시작 실패",
"siteErrorRestartDescription": "사이트를 재시작하는 중 오류가 발생했습니다.",
"siteSettingDescription": "사이트에서 설정을 구성하세요.", "siteSettingDescription": "사이트에서 설정을 구성하세요.",
"siteResourcesTab": "리소스", "siteResourcesTab": "리소스",
"siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.", "siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "청사진 적용", "actionApplyBlueprint": "청사진 적용",
"actionListBlueprints": "청사진 목록", "actionListBlueprints": "청사진 목록",
"actionGetBlueprint": "청사진 가져오기", "actionGetBlueprint": "청사진 가져오기",
"actionCreateOrgWideLauncherView": "조직 전체 런처 보기 생성",
"setupToken": "설정 토큰", "setupToken": "설정 토큰",
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
"setupTokenRequired": "설정 토큰이 필요합니다", "setupTokenRequired": "설정 토큰이 필요합니다",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "서브넷", "subnetPlaceholder": "서브넷",
"addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.",
"selectSites": "사이트 선택", "selectSites": "사이트 선택",
"selectLabels": "레이블 선택",
"sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.", "sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.",
"clientInstallOlm": "Olm 설치", "clientInstallOlm": "Olm 설치",
"clientInstallOlmDescription": "시스템에서 Olm을 실행하기", "clientInstallOlmDescription": "시스템에서 Olm을 실행하기",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "사이트", "createInternalResourceDialogSite": "사이트",
"selectSite": "사이트 선택...", "selectSite": "사이트 선택...",
"multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}", "multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}",
"labelsSelectorLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}",
"noSitesFound": "사이트를 찾을 수 없습니다.", "noSitesFound": "사이트를 찾을 수 없습니다.",
"createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogProtocol": "프로토콜",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "원격 노드", "sidebarRemoteExitNodes": "원격 노드",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "비밀", "remoteExitNodeSecretKey": "비밀",
"remoteExitNodeNetworkingTitle": "네트워크 설정",
"remoteExitNodeNetworkingDescription": "이 원격 출구 노드의 트래픽 라우팅 방법과 어떤 사이트가 이를 통해 연결하는지 구성합니다. 백홀 네트워킹 구성을 사용한 고급 기능입니다.",
"remoteExitNodeNetworkingSave": "설정 저장",
"remoteExitNodeNetworkingSaveSuccessTitle": "네트워크 설정이 저장되었습니다",
"remoteExitNodeNetworkingSaveSuccessDescription": "네트워크 설정이 성공적으로 업데이트되었습니다.",
"remoteExitNodeNetworkingSaveError": "네트워크 설정 저장 실패",
"remoteExitNodeNetworkingSubnetsTitle": "원격 서브넷",
"remoteExitNodeNetworkingSubnetsDescription": "이 원격 출구 노드가 트래픽을 라우팅할 CIDR 범위를 정의합니다. 유효한 CIDR을 입력하고 Enter를 눌러 추가하세요 (예: <code>10.0.0.0/8</code>).",
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.",
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "원격 노드 생성", "title": "원격 노드 생성",
"description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.", "description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 공급자", "idpGoogleDescription": "Google OAuth2/OIDC 공급자",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
"subnet": "서브넷", "subnet": "서브넷",
"utilitySubnet": "유틸리티 서브넷",
"subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.",
"customDomain": "사용자 정의 도메인", "customDomain": "사용자 정의 도메인",
"authPage": "인증 페이지", "authPage": "인증 페이지",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "이메일 화이트리스트", "memberPortalEmailWhitelist": "이메일 화이트리스트",
"memberPortalResourceDisabled": "리소스 비활성화됨", "memberPortalResourceDisabled": "리소스 비활성화됨",
"memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중", "memberPortalShowingResources": "{start}-{end} 중 {total}개의 리소스를 표시 중",
"resourceLauncherTitle": "리소스 런처",
"resourceLauncherDescription": "리소스 세부 정보를 보고 한 곳에서 실행하세요",
"resourceLauncherSearchPlaceholder": "모든 사이트 검색...",
"resourceLauncherDefaultView": "기본값",
"resourceLauncherSaveView": "보기를 저장",
"resourceLauncherSaveToCurrentView": "현재 보기로 저장",
"resourceLauncherResetView": "보기를 재설정",
"resourceLauncherSaveAsNewView": "새 보기로 저장",
"resourceLauncherSaveAsNewViewDescription": "현재 필터와 레이아웃을 저장할 이름을 입력하세요.",
"resourceLauncherSaveForEveryone": "모두에게 저장",
"resourceLauncherSaveForEveryoneDescription": "이 보기를 모든 조직 구성원과 공유합니다. 체크 해제하면 해당 뷰는 사용자에게만 표시됩니다.",
"resourceLauncherMakePersonal": "개인적으로 만들기",
"resourceLauncherFilter": "필터",
"resourceLauncherSort": "정렬",
"resourceLauncherSortAscending": "오름차순 정렬",
"resourceLauncherSortDescending": "내림차순 정렬",
"resourceLauncherSettings": "설정",
"resourceLauncherGroupBy": "그룹화 기준",
"resourceLauncherGroupBySite": "사이트",
"resourceLauncherGroupByLabel": "레이블",
"resourceLauncherLayout": "레이아웃",
"resourceLauncherLayoutGrid": "그리드",
"resourceLauncherLayoutList": "목록",
"resourceLauncherShowLabels": "레이블 표시",
"resourceLauncherShowSiteTags": "사이트 태그 표시",
"resourceLauncherShowRecents": "최근 항목 표시",
"resourceLauncherDeleteView": "보기 삭제",
"resourceLauncherViewAsAdmin": "관리자로 보기",
"resourceLauncherResourceDetailsDescription": "이 리소스의 세부정보를 봅니다.",
"resourceLauncherUnlabeled": "레이블 없음",
"resourceLauncherNoSite": "사이트 없음",
"resourceLauncherNoResourcesInGroup": "이 그룹에는 리소스가 없습니다",
"resourceLauncherEmptyStateTitle": "사용 가능한 리소스 없음",
"resourceLauncherEmptyStateDescription": "아직 리소스에 대한 액세스 권한이 없습니다. 액세스를 요청하려면 관리자에게 문의하세요.",
"resourceLauncherEmptyStateNoResultsTitle": "리소스를 찾을 수 없음",
"resourceLauncherEmptyStateNoResultsDescription": "현재 검색이나 필터에 맞는 리소스가 없습니다. 필터를 조정하여 찾으려는 항목을 확인해보세요.",
"resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\"와 일치하는 리소스가 없습니다. 검색을 조정하거나 필터를 지워서 모든 리소스를 확인해보세요.",
"resourceLauncherCopiedToClipboard": "클립보드에 복사됨",
"resourceLauncherCopiedAccessDescription": "리소스 액세스가 클립보드에 복사되었습니다.",
"resourceLauncherViewNamePlaceholder": "보기 이름",
"resourceLauncherViewNameLabel": "뷰 이름",
"resourceLauncherViewSaved": "보기 저장됨",
"resourceLauncherViewSavedDescription": "런처 뷰가 저장되었습니다.",
"resourceLauncherViewSaveFailed": "뷰 저장 실패",
"resourceLauncherViewSaveFailedDescription": "런처 뷰를 저장할 수 없습니다. 다시 시도하세요.",
"resourceLauncherViewDeleted": "보기 삭제됨",
"resourceLauncherViewDeletedDescription": "런처 뷰가 삭제되었습니다.",
"resourceLauncherViewDeleteFailed": "뷰 삭제 실패",
"resourceLauncherViewDeleteFailedDescription": "런처 뷰를 삭제할 수 없습니다. 다시 시도하세요.",
"memberPortalPrevious": "이전", "memberPortalPrevious": "이전",
"memberPortalNext": "다음", "memberPortalNext": "다음",
"httpSettings": "HTTP 설정", "httpSettings": "HTTP 설정",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "프라이빗 키가 필요합니다", "sshPrivateKeyRequired": "프라이빗 키가 필요합니다",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "연결하려면 VNC 비밀번호를 입력하세요", "vncSignInDescription": "연결하기 위해 VNC 자격 증명을 입력하세요",
"vncUsernameOptional": "사용자 이름 (선택 사항)",
"vncPasswordOptional": "비밀번호 (선택 사항)", "vncPasswordOptional": "비밀번호 (선택 사항)",
"vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다", "vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다",
"vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다", "vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Område oppdatert", "siteUpdated": "Område oppdatert",
"siteUpdatedDescription": "Området har blitt oppdatert.", "siteUpdatedDescription": "Området har blitt oppdatert.",
"siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området",
"siteRestartTitle": "Start område på nytt",
"siteRestartDescription": "Start WireGuard-tunnelen for dette området på nytt. Dette vil midlertidig avbryte tilkoblingen.",
"siteRestartBody": "Bruk dette hvis områdetunnelen ikke fungerer riktig og du vil tvinge en ny tilkobling uten å starte verten på nytt.",
"siteRestartButton": "Start område på nytt",
"siteRestartDialogMessage": "Er du sikker på at du vil starte WireGuard-tunnelen for <b>{name}</b> på nytt? Området vil midlertidig miste tilkoblingen.",
"siteRestartWarning": "Området vil kobles kort fra mens tunnelen starter om.",
"siteRestarted": "Område startet på nytt",
"siteRestartedDescription": "WireGuard-tunnelen er startet på nytt.",
"siteErrorRestart": "Kan ikke starte område på nytt",
"siteErrorRestartDescription": "En feil oppstod ved omstart av området.",
"siteSettingDescription": "Konfigurere innstillingene på nettstedet", "siteSettingDescription": "Konfigurere innstillingene på nettstedet",
"siteResourcesTab": "Ressurser", "siteResourcesTab": "Ressurser",
"siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.", "siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Bruk blåkopi", "actionApplyBlueprint": "Bruk blåkopi",
"actionListBlueprints": "List opp blåkopier", "actionListBlueprints": "List opp blåkopier",
"actionGetBlueprint": "Hent blåkopi", "actionGetBlueprint": "Hent blåkopi",
"actionCreateOrgWideLauncherView": "Opprett lanseringsvisning for hele organisasjonen",
"setupToken": "Oppsetttoken", "setupToken": "Oppsetttoken",
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
"setupTokenRequired": "Oppsetttoken er nødvendig", "setupTokenRequired": "Oppsetttoken er nødvendig",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnett", "subnetPlaceholder": "Subnett",
"addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.", "addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.",
"selectSites": "Velg områder", "selectSites": "Velg områder",
"selectLabels": "Velg etiketter",
"sitesDescription": "Klienten vil ha tilkobling til de valgte områdene", "sitesDescription": "Klienten vil ha tilkobling til de valgte områdene",
"clientInstallOlm": "Installer Olm", "clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt", "clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Område", "createInternalResourceDialogSite": "Område",
"selectSite": "Velg område...", "selectSite": "Velg område...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}",
"labelsSelectorLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}",
"noSitesFound": "Ingen områder funnet.", "noSitesFound": "Ingen områder funnet.",
"createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogProtocol": "Protokoll",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Eksterne Noder", "sidebarRemoteExitNodes": "Eksterne Noder",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Sikkerhetsnøkkel", "remoteExitNodeSecretKey": "Sikkerhetsnøkkel",
"remoteExitNodeNetworkingTitle": "Nettverksinnstillinger",
"remoteExitNodeNetworkingDescription": "Konfigurer hvordan denne fjerne utgangsnoden ruter trafikk og hvilke områder som foretrekker å koble gjennom den. Avanserte funksjoner for å brukes med bakhalstilkoplingskonfigurasjoner.",
"remoteExitNodeNetworkingSave": "Lagre innstillinger",
"remoteExitNodeNetworkingSaveSuccessTitle": "Nettverksinnstillinger lagret",
"remoteExitNodeNetworkingSaveSuccessDescription": "Nettverksinnstillingene er oppdatert.",
"remoteExitNodeNetworkingSaveError": "Klarte ikke å lagre nettverksinnstillinger",
"remoteExitNodeNetworkingSubnetsTitle": "Fjern-subnett",
"remoteExitNodeNetworkingSubnetsDescription": "Definer CIDR-områdene som denne fjernutgangsnoden vil rute trafikk til. Skriv inn en gyldig CIDR (f.eks. <code>10.0.0.0/8</code>) og trykk Enter for å legge til.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
"remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Opprett ekstern node", "title": "Opprett ekstern node",
"description": "Opprett en ny egendrift ekstern relé- og proxyservernode", "description": "Opprett en ny egendrift ekstern relé- og proxyservernode",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpGoogleDescription": "Google OAuth2/OIDC leverandør",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnett", "subnet": "Subnett",
"utilitySubnet": "Nyttesubnett",
"subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.",
"customDomain": "Egendefinert domene", "customDomain": "Egendefinert domene",
"authPage": "Autentiseringssider", "authPage": "Autentiseringssider",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-post-hviteliste", "memberPortalEmailWhitelist": "E-post-hviteliste",
"memberPortalResourceDisabled": "Ressurs deaktivert", "memberPortalResourceDisabled": "Ressurs deaktivert",
"memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser", "memberPortalShowingResources": "Viser {start}-{end} av {total} ressurser",
"resourceLauncherTitle": "Ressurslansering",
"resourceLauncherDescription": "Vis ressursdetaljer og start dem fra ett sted",
"resourceLauncherSearchPlaceholder": "Søk i alle områder...",
"resourceLauncherDefaultView": "Standard",
"resourceLauncherSaveView": "Lagre visning",
"resourceLauncherSaveToCurrentView": "Lagre til nåværende visning",
"resourceLauncherResetView": "Tilbakestill visning",
"resourceLauncherSaveAsNewView": "Lagre som ny visning",
"resourceLauncherSaveAsNewViewDescription": "Gi denne visningen et navn for å lagre dine nåværende filtre og oppsett.",
"resourceLauncherSaveForEveryone": "Lagre for alle",
"resourceLauncherSaveForEveryoneDescription": "Del denne visningen med alle organisasjonsmedlemmer. Når avkrysset, er visningen synlig bare for deg.",
"resourceLauncherMakePersonal": "Gjør personlig",
"resourceLauncherFilter": "Filter",
"resourceLauncherSort": "Sorter",
"resourceLauncherSortAscending": "Sorter stigende",
"resourceLauncherSortDescending": "Sorter synkende",
"resourceLauncherSettings": "Innstillinger",
"resourceLauncherGroupBy": "Grupper etter",
"resourceLauncherGroupBySite": "Område",
"resourceLauncherGroupByLabel": "Etikett",
"resourceLauncherLayout": "Oppsett",
"resourceLauncherLayoutGrid": "Rutenett",
"resourceLauncherLayoutList": "Liste",
"resourceLauncherShowLabels": "Vis etiketter",
"resourceLauncherShowSiteTags": "Vis områdestikkord",
"resourceLauncherShowRecents": "Vis nylige",
"resourceLauncherDeleteView": "Slett visning",
"resourceLauncherViewAsAdmin": "Vis som administrator",
"resourceLauncherResourceDetailsDescription": "Vis detaljer for denne ressursen.",
"resourceLauncherUnlabeled": "Umerket",
"resourceLauncherNoSite": "Ingen område",
"resourceLauncherNoResourcesInGroup": "Ingen ressurser i denne gruppen",
"resourceLauncherEmptyStateTitle": "Ingen tilgjengelige ressurser",
"resourceLauncherEmptyStateDescription": "Du har ennå ikke tilgang til noen ressurser. Kontakt administratoren din for å be om tilgang.",
"resourceLauncherEmptyStateNoResultsTitle": "Ingen ressurser funnet",
"resourceLauncherEmptyStateNoResultsDescription": "Ingen ressurser matcher dine nåværende søk eller filtre. Prøv å justere dem for å finne det du leter etter.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Ingen ressurser samsvarer med \"{query}\". Prøv å justere søket eller fjern filtrene for å se alle ressursene.",
"resourceLauncherCopiedToClipboard": "Kopiert til utklippstavlen",
"resourceLauncherCopiedAccessDescription": "Ressurstilgang er kopiert til utklippstavlen din.",
"resourceLauncherViewNamePlaceholder": "Visningsnavn",
"resourceLauncherViewNameLabel": "Visningsnavn",
"resourceLauncherViewSaved": "Visning lagret",
"resourceLauncherViewSavedDescription": "Lanseringsvisningen din er lagret.",
"resourceLauncherViewSaveFailed": "Feilet å lagre visning",
"resourceLauncherViewSaveFailedDescription": "Kunne ikke lagre lanseringsvisningen. Vennligst prøv igjen.",
"resourceLauncherViewDeleted": "Visning slettet",
"resourceLauncherViewDeletedDescription": "Lanseringsvisningen er slettet.",
"resourceLauncherViewDeleteFailed": "Klarte ikke å slette visning",
"resourceLauncherViewDeleteFailedDescription": "Kunne ikke slette lanseringsvisningen. Vennligst prøv igjen.",
"memberPortalPrevious": "Forrige", "memberPortalPrevious": "Forrige",
"memberPortalNext": "Neste", "memberPortalNext": "Neste",
"httpSettings": "HTTP Innstillinger", "httpSettings": "HTTP Innstillinger",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----", "sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----",
"sshPrivateKeyRequired": "Privat nøkkel er påkrevd", "sshPrivateKeyRequired": "Privat nøkkel er påkrevd",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Skriv inn VNC-passordet for å koble til", "vncSignInDescription": "Skriv inn VNC-kredentialene dine for å koble til",
"vncUsernameOptional": "Brukernavn (valgfritt)",
"vncPasswordOptional": "Passord (valgfritt)", "vncPasswordOptional": "Passord (valgfritt)",
"vncNoResourceTarget": "Ingen ressursemål tilgjengelig", "vncNoResourceTarget": "Ingen ressursemål tilgjengelig",
"vncFailedToLoadNovnc": "Klarte ikke å laste noVNC", "vncFailedToLoadNovnc": "Klarte ikke å laste noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site bijgewerkt", "siteUpdated": "Site bijgewerkt",
"siteUpdatedDescription": "De site is bijgewerkt.", "siteUpdatedDescription": "De site is bijgewerkt.",
"siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteGeneralDescription": "Algemene instellingen voor deze site configureren",
"siteRestartTitle": "Herstart Site",
"siteRestartDescription": "Herstart de WireGuard-tunnel voor deze site. Dit zal de connectiviteit kort onderbreken.",
"siteRestartBody": "Gebruik dit als de sitetunnel niet correct functioneert en je wilt een herverbinding forceren zonder de host opnieuw op te starten.",
"siteRestartButton": "Herstart Site",
"siteRestartDialogMessage": "Weet u zeker dat u de WireGuard-tunnel voor <b>{name}</b> wilt herstarten? De site zal tijdelijk geen connectiviteit hebben.",
"siteRestartWarning": "De site zal kort worden losgekoppeld terwijl de tunnel opnieuw wordt gestart.",
"siteRestarted": "Site herstart",
"siteRestartedDescription": "De WireGuard-tunnel is opnieuw gestart.",
"siteErrorRestart": "Site herstarten mislukt",
"siteErrorRestartDescription": "Er is een fout opgetreden tijdens het herstarten van de site.",
"siteSettingDescription": "Configureer de instellingen van de site", "siteSettingDescription": "Configureer de instellingen van de site",
"siteResourcesTab": "Bronnen", "siteResourcesTab": "Bronnen",
"siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.", "siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Blauwdruk toepassen", "actionApplyBlueprint": "Blauwdruk toepassen",
"actionListBlueprints": "Lijst blauwdrukken", "actionListBlueprints": "Lijst blauwdrukken",
"actionGetBlueprint": "Krijg Blauwdruk", "actionGetBlueprint": "Krijg Blauwdruk",
"actionCreateOrgWideLauncherView": "Maak Organisatiebrede Launcher Weergave",
"setupToken": "Instel Token", "setupToken": "Instel Token",
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
"setupTokenRequired": "Setup-token is vereist", "setupTokenRequired": "Setup-token is vereist",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnet", "subnetPlaceholder": "Subnet",
"addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.", "addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.",
"selectSites": "Selecteer sites", "selectSites": "Selecteer sites",
"selectLabels": "Selecteer labels",
"sitesDescription": "De client heeft connectiviteit met de geselecteerde sites", "sitesDescription": "De client heeft connectiviteit met de geselecteerde sites",
"clientInstallOlm": "Installeer Olm", "clientInstallOlm": "Installeer Olm",
"clientInstallOlmDescription": "Laat Olm draaien op uw systeem", "clientInstallOlmDescription": "Laat Olm draaien op uw systeem",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Selecteer site...", "selectSite": "Selecteer site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
"noSitesFound": "Geen sites gevonden.", "noSitesFound": "Geen sites gevonden.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Externe knooppunten", "sidebarRemoteExitNodes": "Externe knooppunten",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Geheim", "remoteExitNodeSecretKey": "Geheim",
"remoteExitNodeNetworkingTitle": "Netwerkinstellingen",
"remoteExitNodeNetworkingDescription": "Configureer hoe dit externe exit-knooppunt verkeer routeert en welke sites de voorkeur hebben om er doorheen te verbinden. Geavanceerde functies te gebruiken met backhaul-netwerkconfiguraties.",
"remoteExitNodeNetworkingSave": "Instellingen opslaan",
"remoteExitNodeNetworkingSaveSuccessTitle": "Netwerkinstellingen opgeslagen",
"remoteExitNodeNetworkingSaveSuccessDescription": "Netwerkinstellingen zijn succesvol bijgewerkt.",
"remoteExitNodeNetworkingSaveError": "Kon netwerkinstellingen niet opslaan",
"remoteExitNodeNetworkingSubnetsTitle": "Externe Subnets",
"remoteExitNodeNetworkingSubnetsDescription": "Definieer de CIDR-bereiken waarnaar dit externe exit-knooppunt verkeer zal routeren. Voer een geldige CIDR in (bijv. <code>10.0.0.0/8</code>) en druk op Enter om toe te voegen.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Externe knoop aanmaken", "title": "Externe knoop aanmaken",
"description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule", "description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider", "idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet", "subnet": "Subnet",
"utilitySubnet": "Hulpmiddel Subnet",
"subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.",
"customDomain": "Aangepast domein", "customDomain": "Aangepast domein",
"authPage": "Authenticatiepagina's", "authPage": "Authenticatiepagina's",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-mail whitelist", "memberPortalEmailWhitelist": "E-mail whitelist",
"memberPortalResourceDisabled": "Bron Uitgeschakeld", "memberPortalResourceDisabled": "Bron Uitgeschakeld",
"memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen", "memberPortalShowingResources": "Toont {start}-{end} van {total} bronnen",
"resourceLauncherTitle": "Bron Launcher",
"resourceLauncherDescription": "Bekijk brongegevens en start ze vanaf één plek",
"resourceLauncherSearchPlaceholder": "Zoek alle sites...",
"resourceLauncherDefaultView": "Standaard",
"resourceLauncherSaveView": "Weergave Opslaan",
"resourceLauncherSaveToCurrentView": "Opslaan naar huidige weergave",
"resourceLauncherResetView": "Weergave Herstellen",
"resourceLauncherSaveAsNewView": "Opslaan als Nieuwe Weergave",
"resourceLauncherSaveAsNewViewDescription": "Geef deze weergave een naam om je huidige filters en indeling op te slaan.",
"resourceLauncherSaveForEveryone": "Opslaan voor Iedereen",
"resourceLauncherSaveForEveryoneDescription": "Deel deze weergave met alle organisatieleden. Als dit niet is aangevinkt, is de weergave alleen zichtbaar voor jou.",
"resourceLauncherMakePersonal": "Persoonlijk Maken",
"resourceLauncherFilter": "Filter",
"resourceLauncherSort": "Sorteren",
"resourceLauncherSortAscending": "Oplopend sorteren",
"resourceLauncherSortDescending": "Aflopend sorteren",
"resourceLauncherSettings": "Instellingen",
"resourceLauncherGroupBy": "Groep Op",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
"resourceLauncherLayout": "Lay-out",
"resourceLauncherLayoutGrid": "Raster",
"resourceLauncherLayoutList": "Lijst",
"resourceLauncherShowLabels": "Labels Weergeven",
"resourceLauncherShowSiteTags": "Site Tags Weergeven",
"resourceLauncherShowRecents": "Recente Weergeven",
"resourceLauncherDeleteView": "Weergave Verwijderen",
"resourceLauncherViewAsAdmin": "Bekijk als Admin",
"resourceLauncherResourceDetailsDescription": "Bekijk details voor deze bron.",
"resourceLauncherUnlabeled": "Geen label",
"resourceLauncherNoSite": "Geen Site",
"resourceLauncherNoResourcesInGroup": "Geen bronnen in deze groep",
"resourceLauncherEmptyStateTitle": "Geen Bronnen Beschikbaar",
"resourceLauncherEmptyStateDescription": "Je hebt nog geen toegang tot bronnen. Neem contact op met je beheerder om toegang aan te vragen.",
"resourceLauncherEmptyStateNoResultsTitle": "Geen Bronnen Gevonden",
"resourceLauncherEmptyStateNoResultsDescription": "Geen bronnen komen overeen met je huidige zoekopdracht of filters. Probeer ze aan te passen om te vinden wat je zoekt.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Geen bronnen komen overeen met \"{query}\". Probeer je zoekopdracht aan te passen of filters te wissen om alle bronnen te zien.",
"resourceLauncherCopiedToClipboard": "Gekopieerd naar klembord",
"resourceLauncherCopiedAccessDescription": "Toegang tot bron is gekopieerd naar je klembord.",
"resourceLauncherViewNamePlaceholder": "Weergavenaam",
"resourceLauncherViewNameLabel": "Weergavenaam",
"resourceLauncherViewSaved": "Weergave opgeslagen",
"resourceLauncherViewSavedDescription": "Je launcher-weergave is opgeslagen.",
"resourceLauncherViewSaveFailed": "Kon weergave niet opslaan",
"resourceLauncherViewSaveFailedDescription": "Kon de launcher-weergave niet opslaan. Probeer het opnieuw.",
"resourceLauncherViewDeleted": "Weergave verwijderd",
"resourceLauncherViewDeletedDescription": "De launcher-weergave is verwijderd.",
"resourceLauncherViewDeleteFailed": "Kon weergave niet verwijderen",
"resourceLauncherViewDeleteFailedDescription": "Kon de launcher-weergave niet verwijderen. Probeer het opnieuw.",
"memberPortalPrevious": "Vorige", "memberPortalPrevious": "Vorige",
"memberPortalNext": "Volgende", "memberPortalNext": "Volgende",
"httpSettings": "HTTP-instellingen", "httpSettings": "HTTP-instellingen",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Privésleutel is vereist", "sshPrivateKeyRequired": "Privésleutel is vereist",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Voer uw VNC-wachtwoord in om verbinding te maken", "vncSignInDescription": "Voer uw VNC-referenties in om verbinding te maken",
"vncUsernameOptional": "Gebruikersnaam (optioneel)",
"vncPasswordOptional": "Wachtwoord (optioneel)", "vncPasswordOptional": "Wachtwoord (optioneel)",
"vncNoResourceTarget": "Geen bron doelwit beschikbaar", "vncNoResourceTarget": "Geen bron doelwit beschikbaar",
"vncFailedToLoadNovnc": "Laden van noVNC mislukt", "vncFailedToLoadNovnc": "Laden van noVNC mislukt",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Strona zaktualizowana", "siteUpdated": "Strona zaktualizowana",
"siteUpdatedDescription": "Strona została zaktualizowana.", "siteUpdatedDescription": "Strona została zaktualizowana.",
"siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny",
"siteRestartTitle": "Restartuj Stronę",
"siteRestartDescription": "Uruchom ponownie tunel WireGuard dla tej strony. Spowoduje to tymczasowe przerwanie łączności.",
"siteRestartBody": "Użyj tego, jeśli tunel strony nie działa prawidłowo i chcesz wymusić ponowne połączenie bez ponownego uruchamiania hosta.",
"siteRestartButton": "Restartuj Stronę",
"siteRestartDialogMessage": "Czy na pewno chcesz uruchomić ponownie tunel WireGuard dla <b>{name}</b>? Strona tymczasowo straci łączność.",
"siteRestartWarning": "Strona tymczasowo rozłączy się podczas ponownego uruchamiania tunelu.",
"siteRestarted": "Strona zrestartowana",
"siteRestartedDescription": "Tunel WireGuard został ponownie uruchomiony.",
"siteErrorRestart": "Nie udało się zrestartować strony",
"siteErrorRestartDescription": "Wystąpił błąd podczas ponownego uruchamiania strony.",
"siteSettingDescription": "Skonfiguruj ustawienia na stronie", "siteSettingDescription": "Skonfiguruj ustawienia na stronie",
"siteResourcesTab": "Zasoby", "siteResourcesTab": "Zasoby",
"siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.", "siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Zastosuj schemat", "actionApplyBlueprint": "Zastosuj schemat",
"actionListBlueprints": "Lista planów", "actionListBlueprints": "Lista planów",
"actionGetBlueprint": "Pobierz plan", "actionGetBlueprint": "Pobierz plan",
"actionCreateOrgWideLauncherView": "Utwórz Widok Uruchamiacza dla Całej Organizacji",
"setupToken": "Skonfiguruj token", "setupToken": "Skonfiguruj token",
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
"setupTokenRequired": "Wymagany jest token konfiguracji", "setupTokenRequired": "Wymagany jest token konfiguracji",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Podsieć", "subnetPlaceholder": "Podsieć",
"addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.", "addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.",
"selectSites": "Wybierz witryny", "selectSites": "Wybierz witryny",
"selectLabels": "Wybierz etykiety",
"sitesDescription": "Klient będzie miał łączność z wybranymi witrynami", "sitesDescription": "Klient będzie miał łączność z wybranymi witrynami",
"clientInstallOlm": "Zainstaluj Olm", "clientInstallOlm": "Zainstaluj Olm",
"clientInstallOlmDescription": "Uruchom Olm na swoim systemie", "clientInstallOlmDescription": "Uruchom Olm na swoim systemie",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Witryna", "createInternalResourceDialogSite": "Witryna",
"selectSite": "Wybierz stronę...", "selectSite": "Wybierz stronę...",
"multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}", "multiSitesSelectorSitesCount": "{count, plural, one {# witryna} few {# witryny} many {# witryn} other {# witryn}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}",
"noSitesFound": "Nie znaleziono stron.", "noSitesFound": "Nie znaleziono stron.",
"createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogProtocol": "Protokół",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Zdalne węzły", "sidebarRemoteExitNodes": "Zdalne węzły",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Sekret", "remoteExitNodeSecretKey": "Sekret",
"remoteExitNodeNetworkingTitle": "Ustawienia sieciowe",
"remoteExitNodeNetworkingDescription": "Skonfiguruj, jak ten zdalny węzeł wyjściowy przekierowuje ruch i które strony preferują połączenie przez niego. Zaawansowane funkcje do użycia z konfiguracją sieci backhaul.",
"remoteExitNodeNetworkingSave": "Zapisz ustawienia",
"remoteExitNodeNetworkingSaveSuccessTitle": "Ustawienia sieciowe zapisane",
"remoteExitNodeNetworkingSaveSuccessDescription": "Ustawienia sieciowe zostały pomyślnie zaktualizowane.",
"remoteExitNodeNetworkingSaveError": "Nie udało się zapisać ustawień sieciowych",
"remoteExitNodeNetworkingSubnetsTitle": "Zdalne Podsieci",
"remoteExitNodeNetworkingSubnetsDescription": "Zdefiniuj zakresy CIDR, które ten zdalny węzeł wyjściowy przekieruje ruch do. Wpisz prawidłowy CIDR (np. <code>10.0.0.0/8</code>) i naciśnij Enter, aby dodać.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Utwórz zdalny węzeł", "title": "Utwórz zdalny węzeł",
"description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy", "description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC", "idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsieć", "subnet": "Podsieć",
"utilitySubnet": "Użyteczna podsieć",
"subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.",
"customDomain": "Niestandardowa domena", "customDomain": "Niestandardowa domena",
"authPage": "Strony uwierzytelniania", "authPage": "Strony uwierzytelniania",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Biała lista e-mail", "memberPortalEmailWhitelist": "Biała lista e-mail",
"memberPortalResourceDisabled": "Zasób wyłączony", "memberPortalResourceDisabled": "Zasób wyłączony",
"memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}", "memberPortalShowingResources": "Wyświetlanie zasobów od {start} do {end} z {total}",
"resourceLauncherTitle": "Uruchamiacz Zasobów",
"resourceLauncherDescription": "Przeglądaj szczegóły zasobów i uruchamiaj je z jednego miejsca",
"resourceLauncherSearchPlaceholder": "Szukaj we wszystkich stronach...",
"resourceLauncherDefaultView": "Domyślny",
"resourceLauncherSaveView": "Zapisz Widok",
"resourceLauncherSaveToCurrentView": "Zapisz do bieżącego widoku",
"resourceLauncherResetView": "Resetuj Widok",
"resourceLauncherSaveAsNewView": "Zapisz jako Nowy Widok",
"resourceLauncherSaveAsNewViewDescription": "Nadaj nazwę temu widokowi, aby zapisać swoje bieżące filtry i układ.",
"resourceLauncherSaveForEveryone": "Zapisz dla wszystkich",
"resourceLauncherSaveForEveryoneDescription": "Udostępnij ten widok wszystkim członkom organizacji. Gdy jest niezaznaczone, widok jest widoczny tylko dla Ciebie.",
"resourceLauncherMakePersonal": "Zrób osobisty",
"resourceLauncherFilter": "Filtr",
"resourceLauncherSort": "Sortuj",
"resourceLauncherSortAscending": "Sortuj rosnąco",
"resourceLauncherSortDescending": "Sortuj malejąco",
"resourceLauncherSettings": "Ustawienia",
"resourceLauncherGroupBy": "Grupuj według",
"resourceLauncherGroupBySite": "Witryna",
"resourceLauncherGroupByLabel": "Etykieta",
"resourceLauncherLayout": "Układ",
"resourceLauncherLayoutGrid": "Siatka",
"resourceLauncherLayoutList": "Lista",
"resourceLauncherShowLabels": "Pokaż etykiety",
"resourceLauncherShowSiteTags": "Pokaż tagi stron",
"resourceLauncherShowRecents": "Pokaż ostatnie",
"resourceLauncherDeleteView": "Usuń Widok",
"resourceLauncherViewAsAdmin": "Przeglądaj jako Administrator",
"resourceLauncherResourceDetailsDescription": "Pokaż szczegóły tego zasobu.",
"resourceLauncherUnlabeled": "Bez etykiety",
"resourceLauncherNoSite": "Brak strony",
"resourceLauncherNoResourcesInGroup": "W tej grupie nie ma zasobów",
"resourceLauncherEmptyStateTitle": "Brak dostępnych zasobów",
"resourceLauncherEmptyStateDescription": "Jeszcze nie masz dostępu do żadnych zasobów. Skontaktuj się z administratorem, aby poprosić o dostęp.",
"resourceLauncherEmptyStateNoResultsTitle": "Nie znaleziono zasobów",
"resourceLauncherEmptyStateNoResultsDescription": "Żadne zasoby nie spełniają twojego bieżącego wyszukiwania lub filtrów. Spróbuj je dostosować, aby znaleźć to, czego szukasz.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Żadne zasoby nie odpowiadają \"{query}\". Spróbuj dostosować swoje wyszukiwanie lub usunąć filtry, aby zobaczyć wszystkie zasoby.",
"resourceLauncherCopiedToClipboard": "Skopiowano do schowka",
"resourceLauncherCopiedAccessDescription": "Dostęp do zasobu został skopiowany do schowka.",
"resourceLauncherViewNamePlaceholder": "Nazwa widoku",
"resourceLauncherViewNameLabel": "Nazwa Widoku",
"resourceLauncherViewSaved": "Widok zapisany",
"resourceLauncherViewSavedDescription": "Twój widok uruchamiacza został zapisany.",
"resourceLauncherViewSaveFailed": "Nie udało się zapisać widoku",
"resourceLauncherViewSaveFailedDescription": "Nie można zapisać widoku uruchamiacza. Proszę spróbować ponownie.",
"resourceLauncherViewDeleted": "Widok usunięty",
"resourceLauncherViewDeletedDescription": "Widok uruchamiacza został usunięty.",
"resourceLauncherViewDeleteFailed": "Nie udało się usunąć widoku",
"resourceLauncherViewDeleteFailedDescription": "Nie można usunąć widoku uruchamiacza. Proszę spróbować ponownie.",
"memberPortalPrevious": "Poprzedni", "memberPortalPrevious": "Poprzedni",
"memberPortalNext": "Następny", "memberPortalNext": "Następny",
"httpSettings": "Ustawienia HTTP", "httpSettings": "Ustawienia HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Wymagany jest klucz prywatny", "sshPrivateKeyRequired": "Wymagany jest klucz prywatny",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Wprowadź hasło VNC, aby się połączyć", "vncSignInDescription": "Wprowadź swoje dane uwierzytelniające VNC aby się połączyć",
"vncUsernameOptional": "Nazwa użytkownika (opcjonalnie)",
"vncPasswordOptional": "Hasło (opcjonalne)", "vncPasswordOptional": "Hasło (opcjonalne)",
"vncNoResourceTarget": "Brak dostępnego celu zasobu", "vncNoResourceTarget": "Brak dostępnego celu zasobu",
"vncFailedToLoadNovnc": "Błąd ładowania noVNC", "vncFailedToLoadNovnc": "Błąd ładowania noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site atualizado", "siteUpdated": "Site atualizado",
"siteUpdatedDescription": "O site foi atualizado.", "siteUpdatedDescription": "O site foi atualizado.",
"siteGeneralDescription": "Configurar as configurações gerais para este site", "siteGeneralDescription": "Configurar as configurações gerais para este site",
"siteRestartTitle": "Reiniciar site",
"siteRestartDescription": "Reinicie o túnel WireGuard para este site. Isso interromperá brevemente a conectividade.",
"siteRestartBody": "Use isso se o túnel do site não estiver funcionando corretamente e você quiser forçar uma reconexão sem reiniciar o host.",
"siteRestartButton": "Reiniciar site",
"siteRestartDialogMessage": "Tem certeza de que deseja reiniciar o túnel WireGuard para <b>{name}</b>? O site perderá brevemente a conectividade.",
"siteRestartWarning": "O site será desconectado brevemente enquanto o túnel reinicia.",
"siteRestarted": "Site reiniciado",
"siteRestartedDescription": "O túnel WireGuard foi reiniciado.",
"siteErrorRestart": "Falha ao reiniciar o site",
"siteErrorRestartDescription": "Ocorreu um erro ao reiniciar o site.",
"siteSettingDescription": "Configurar as configurações no site", "siteSettingDescription": "Configurar as configurações no site",
"siteResourcesTab": "Recursos", "siteResourcesTab": "Recursos",
"siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.", "siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Aplicar Diagrama", "actionApplyBlueprint": "Aplicar Diagrama",
"actionListBlueprints": "Listar Modelos", "actionListBlueprints": "Listar Modelos",
"actionGetBlueprint": "Obter Modelo", "actionGetBlueprint": "Obter Modelo",
"actionCreateOrgWideLauncherView": "Criar Visualização do Lançador para Toda a Organização",
"setupToken": "Configuração do Token", "setupToken": "Configuração do Token",
"setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenDescription": "Digite o token de configuração do console do servidor.",
"setupTokenRequired": "Token de configuração é necessário", "setupTokenRequired": "Token de configuração é necessário",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sub-rede", "subnetPlaceholder": "Sub-rede",
"addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.", "addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.",
"selectSites": "Selecionar sites", "selectSites": "Selecionar sites",
"selectLabels": "Selecionar etiquetas",
"sitesDescription": "O cliente terá conectividade com os sites selecionados", "sitesDescription": "O cliente terá conectividade com os sites selecionados",
"clientInstallOlm": "Instalar Olm", "clientInstallOlm": "Instalar Olm",
"clientInstallOlmDescription": "Execute o Olm em seu sistema", "clientInstallOlmDescription": "Execute o Olm em seu sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Selecionar site...", "selectSite": "Selecionar site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}",
"noSitesFound": "Nenhum site encontrado.", "noSitesFound": "Nenhum site encontrado.",
"createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogProtocol": "Protocolo",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nós remotos", "sidebarRemoteExitNodes": "Nós remotos",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Chave Secreta", "remoteExitNodeSecretKey": "Chave Secreta",
"remoteExitNodeNetworkingTitle": "Configurações de Rede",
"remoteExitNodeNetworkingDescription": "Configure como este nó de saída remoto roteia o tráfego e quais sites preferem se conectar através dele. Recursos avançados para serem usados com configurações de rede de backhaul.",
"remoteExitNodeNetworkingSave": "Guardar Configurações",
"remoteExitNodeNetworkingSaveSuccessTitle": "Configurações de rede salvas",
"remoteExitNodeNetworkingSaveSuccessDescription": "As configurações de rede foram atualizadas com sucesso.",
"remoteExitNodeNetworkingSaveError": "Falha ao guardar as configurações de rede",
"remoteExitNodeNetworkingSubnetsTitle": "Sub-redes Remotas",
"remoteExitNodeNetworkingSubnetsDescription": "Defina os intervalos de CIDR que este nó de saída remoto irá rotear o tráfego. Digite um CIDR válido (por exemplo, <code>10.0.0.0/8</code>) e pressione Enter para adicionar.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
"remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Criar Nó Remoto", "title": "Criar Nó Remoto",
"description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado", "description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Provedor Google OAuth2/OIDC", "idpGoogleDescription": "Provedor Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sub-rede", "subnet": "Sub-rede",
"utilitySubnet": "Sub-rede de utilidade",
"subnetDescription": "A sub-rede para a configuração de rede dessa organização.", "subnetDescription": "A sub-rede para a configuração de rede dessa organização.",
"customDomain": "Domínio Personalizado", "customDomain": "Domínio Personalizado",
"authPage": "Páginas de Autenticação", "authPage": "Páginas de Autenticação",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista de E-mails Permitidos", "memberPortalEmailWhitelist": "Lista de E-mails Permitidos",
"memberPortalResourceDisabled": "Recurso Desativado", "memberPortalResourceDisabled": "Recurso Desativado",
"memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos", "memberPortalShowingResources": "Mostrando {start}-{end} de {total} recursos",
"resourceLauncherTitle": "Lançador de Recursos",
"resourceLauncherDescription": "Veja os detalhes do recurso e lance-os de um só lugar",
"resourceLauncherSearchPlaceholder": "Procurar todos os sites...",
"resourceLauncherDefaultView": "Padrão",
"resourceLauncherSaveView": "Salvar Visualização",
"resourceLauncherSaveToCurrentView": "Salvar na Visualização Atual",
"resourceLauncherResetView": "Redefinir Visualização",
"resourceLauncherSaveAsNewView": "Salvar como Nova Visualização",
"resourceLauncherSaveAsNewViewDescription": "Dê um nome a esta visualização para salvar os filtros e layout atuais.",
"resourceLauncherSaveForEveryone": "Salvar para Todos",
"resourceLauncherSaveForEveryoneDescription": "Compartilhe esta visualização com todos os membros da organização. Quando desmarcado, a visualização é visível apenas para você.",
"resourceLauncherMakePersonal": "Tornar Pessoal",
"resourceLauncherFilter": "Filtro",
"resourceLauncherSort": "Ordenar",
"resourceLauncherSortAscending": "Ordenar ascendente",
"resourceLauncherSortDescending": "Ordenar descendente",
"resourceLauncherSettings": "Configurações",
"resourceLauncherGroupBy": "Agrupar por",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Marcador",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grade",
"resourceLauncherLayoutList": "Lista",
"resourceLauncherShowLabels": "Mostrar Marcadores",
"resourceLauncherShowSiteTags": "Mostrar Etiquetas de Site",
"resourceLauncherShowRecents": "Mostrar Recents",
"resourceLauncherDeleteView": "Excluir Visualização",
"resourceLauncherViewAsAdmin": "Visualizar como Administrador",
"resourceLauncherResourceDetailsDescription": "Veja detalhes deste recurso.",
"resourceLauncherUnlabeled": "Sem Etiqueta",
"resourceLauncherNoSite": "Sem Site",
"resourceLauncherNoResourcesInGroup": "Nenhum recurso neste grupo",
"resourceLauncherEmptyStateTitle": "Nenhum Recurso Disponível",
"resourceLauncherEmptyStateDescription": "Você não tem acesso a nenhum recurso ainda. Entre em contato com seu administrador para solicitar acesso.",
"resourceLauncherEmptyStateNoResultsTitle": "Nenhum Recurso Encontrado",
"resourceLauncherEmptyStateNoResultsDescription": "Nenhum recurso corresponde à sua busca ou filtros atuais. Experimente ajustá-los para encontrar o que está procurando.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Nenhum recurso corresponde a \"{query}\". Tente ajustar sua busca ou limpar os filtros para ver todos os recursos.",
"resourceLauncherCopiedToClipboard": "Copiado para a área de transferência",
"resourceLauncherCopiedAccessDescription": "O acesso ao recurso foi copiado para sua área de transferência.",
"resourceLauncherViewNamePlaceholder": "Nome da Visualização",
"resourceLauncherViewNameLabel": "Nome da Visualização",
"resourceLauncherViewSaved": "Visualização salva",
"resourceLauncherViewSavedDescription": "Sua visualização do lançador foi salva.",
"resourceLauncherViewSaveFailed": "Falha ao salvar visualização",
"resourceLauncherViewSaveFailedDescription": "Não foi possível salvar a visualização do lançador. Por favor, tente novamente.",
"resourceLauncherViewDeleted": "Visualização excluída",
"resourceLauncherViewDeletedDescription": "A visualização do lançador foi excluída.",
"resourceLauncherViewDeleteFailed": "Falha ao excluir visualização",
"resourceLauncherViewDeleteFailedDescription": "Não foi possível excluir a visualização do lançador. Por favor, tente novamente.",
"memberPortalPrevious": "Anterior", "memberPortalPrevious": "Anterior",
"memberPortalNext": "Próximo", "memberPortalNext": "Próximo",
"httpSettings": "Configurações HTTP", "httpSettings": "Configurações HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Chave privada é necessária", "sshPrivateKeyRequired": "Chave privada é necessária",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Digite sua senha VNC para conectar", "vncSignInDescription": "Digite suas credenciais VNC para conectar",
"vncUsernameOptional": "Nome de usuário (opcional)",
"vncPasswordOptional": "Senha (opcional)", "vncPasswordOptional": "Senha (opcional)",
"vncNoResourceTarget": "Nenhum alvo de recurso disponível", "vncNoResourceTarget": "Nenhum alvo de recurso disponível",
"vncFailedToLoadNovnc": "Falha ao carregar noVNC", "vncFailedToLoadNovnc": "Falha ao carregar noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Сайт обновлён", "siteUpdated": "Сайт обновлён",
"siteUpdatedDescription": "Сайт был успешно обновлён.", "siteUpdatedDescription": "Сайт был успешно обновлён.",
"siteGeneralDescription": "Настройте общие параметры для этого сайта", "siteGeneralDescription": "Настройте общие параметры для этого сайта",
"siteRestartTitle": "Перезагрузить сайт",
"siteRestartDescription": "Перезапустите туннель WireGuard для этого сайта. Это кратковременно прервет соединение.",
"siteRestartBody": "Используйте это, если туннель сайта не работает должным образом и вам нужно принудительно переподключиться без перезапуска хоста.",
"siteRestartButton": "Перезагрузить сайт",
"siteRestartDialogMessage": "Вы уверены, что хотите перезапустить туннель WireGuard для <b>{name}</b>? Сайт кратковременно потеряет соединение.",
"siteRestartWarning": "Сайт кратковременно отключится во время перезапуска туннеля.",
"siteRestarted": "Сайт перезапущен",
"siteRestartedDescription": "Туннель WireGuard был перезапущен.",
"siteErrorRestart": "Не удалось перезапустить сайт",
"siteErrorRestartDescription": "Произошла ошибка во время перезапуска сайта.",
"siteSettingDescription": "Настройка параметров на сайте", "siteSettingDescription": "Настройка параметров на сайте",
"siteResourcesTab": "Ресурсы", "siteResourcesTab": "Ресурсы",
"siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.", "siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Применить чертёж", "actionApplyBlueprint": "Применить чертёж",
"actionListBlueprints": "Список чертежей", "actionListBlueprints": "Список чертежей",
"actionGetBlueprint": "Получить чертёж", "actionGetBlueprint": "Получить чертёж",
"actionCreateOrgWideLauncherView": "Создать вид запуска на уровне организации",
"setupToken": "Код настройки", "setupToken": "Код настройки",
"setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenDescription": "Введите токен настройки из консоли сервера.",
"setupTokenRequired": "Токен настройки обязателен", "setupTokenRequired": "Токен настройки обязателен",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Подсеть", "subnetPlaceholder": "Подсеть",
"addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.", "addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.",
"selectSites": "Выберите сайты", "selectSites": "Выберите сайты",
"selectLabels": "Выберите метки",
"sitesDescription": "Клиент будет иметь подключение к выбранным сайтам", "sitesDescription": "Клиент будет иметь подключение к выбранным сайтам",
"clientInstallOlm": "Установить Olm", "clientInstallOlm": "Установить Olm",
"clientInstallOlmDescription": "Запустите Olm на вашей системе", "clientInstallOlmDescription": "Запустите Olm на вашей системе",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Сайт", "createInternalResourceDialogSite": "Сайт",
"selectSite": "Выберите сайт...", "selectSite": "Выберите сайт...",
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}", "multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}",
"labelsSelectorLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
"noSitesFound": "Сайты не найдены.", "noSitesFound": "Сайты не найдены.",
"createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogProtocol": "Протокол",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Удаленные узлы", "sidebarRemoteExitNodes": "Удаленные узлы",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "Секретный ключ", "remoteExitNodeSecretKey": "Секретный ключ",
"remoteExitNodeNetworkingTitle": "Настройки сети",
"remoteExitNodeNetworkingDescription": "Настройте, как этот удаленный узел выхода маршрутизирует трафик и какие сайты предпочитают подключаться через него. Расширенные функции для использования с конфигурациями магистральной сети.",
"remoteExitNodeNetworkingSave": "Сохранить настройки",
"remoteExitNodeNetworkingSaveSuccessTitle": "Сетевые настройки сохранены",
"remoteExitNodeNetworkingSaveSuccessDescription": "Сетевые настройки были успешно обновлены.",
"remoteExitNodeNetworkingSaveError": "Не удалось сохранить сетевые настройки",
"remoteExitNodeNetworkingSubnetsTitle": "Удалённые подсети",
"remoteExitNodeNetworkingSubnetsDescription": "Определите диапазоны CIDR, которые этот удаленный узел выхода будет использовать для маршрутизации трафика. Введите действительный CIDR (например, <code>10.0.0.0/8</code>) и нажмите Enter, чтобы добавить.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Создать удалённый узел", "title": "Создать удалённый узел",
"description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера", "description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC провайдер", "idpGoogleDescription": "Google OAuth2/OIDC провайдер",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Подсеть", "subnet": "Подсеть",
"utilitySubnet": "Утилита подсети",
"subnetDescription": "Подсеть для конфигурации сети этой организации.", "subnetDescription": "Подсеть для конфигурации сети этой организации.",
"customDomain": "Пользовательский домен", "customDomain": "Пользовательский домен",
"authPage": "Страницы аутентификации", "authPage": "Страницы аутентификации",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Белый список email", "memberPortalEmailWhitelist": "Белый список email",
"memberPortalResourceDisabled": "Ресурс отключён", "memberPortalResourceDisabled": "Ресурс отключён",
"memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов", "memberPortalShowingResources": "Показаны {start}-{end} из {total} ресурсов",
"resourceLauncherTitle": "Запуск ресурса",
"resourceLauncherDescription": "Просмотр деталей ресурса и запуск их из одного места",
"resourceLauncherSearchPlaceholder": "Поиск всех сайтов...",
"resourceLauncherDefaultView": "По умолчанию",
"resourceLauncherSaveView": "Сохранить вид",
"resourceLauncherSaveToCurrentView": "Сохранить в текущий вид",
"resourceLauncherResetView": "Сбросить вид",
"resourceLauncherSaveAsNewView": "Сохранить как новый вид",
"resourceLauncherSaveAsNewViewDescription": "Дайте этому виду имя, чтобы сохранить текущие фильтры и макет.",
"resourceLauncherSaveForEveryone": "Сохранить для всех",
"resourceLauncherSaveForEveryoneDescription": "Поделитесь этим видом со всеми членами организации. Если не отмечено, видимость только для вас.",
"resourceLauncherMakePersonal": "Сделать личным",
"resourceLauncherFilter": "Фильтр",
"resourceLauncherSort": "Сортировать",
"resourceLauncherSortAscending": "Сортировать по возрастанию",
"resourceLauncherSortDescending": "Сортировать по убыванию",
"resourceLauncherSettings": "Настройки",
"resourceLauncherGroupBy": "Группировать по",
"resourceLauncherGroupBySite": "Сайт",
"resourceLauncherGroupByLabel": "Метка",
"resourceLauncherLayout": "Макет",
"resourceLauncherLayoutGrid": "Сетка",
"resourceLauncherLayoutList": "Список",
"resourceLauncherShowLabels": "Показать метки",
"resourceLauncherShowSiteTags": "Показать теги сайта",
"resourceLauncherShowRecents": "Показать недавно",
"resourceLauncherDeleteView": "Удалить вид",
"resourceLauncherViewAsAdmin": "Просмотр как администратор",
"resourceLauncherResourceDetailsDescription": "Просмотр деталей этого ресурса.",
"resourceLauncherUnlabeled": "Без меток",
"resourceLauncherNoSite": "Без сайта",
"resourceLauncherNoResourcesInGroup": "Нет ресурсов в данной группе",
"resourceLauncherEmptyStateTitle": "Нет доступных ресурсов",
"resourceLauncherEmptyStateDescription": "У вас пока нет доступа ни к одному ресурсу. Обратитесь к администратору, чтобы запросить доступ.",
"resourceLauncherEmptyStateNoResultsTitle": "Ресурсы не найдены",
"resourceLauncherEmptyStateNoResultsDescription": "Ни один ресурс не соответствует вашему текущему поисковому запросу или фильтрам. Попробуйте их изменить, чтобы найти нужное.",
"resourceLauncherEmptyStateNoResultsWithQuery": "Ни один ресурс не соответствует \"{query}\". Попробуйте изменить параметры поиска или очистить фильтры, чтобы увидеть все ресурсы.",
"resourceLauncherCopiedToClipboard": "Скопировано в буфер обмена",
"resourceLauncherCopiedAccessDescription": "Доступ к ресурсу был скопирован в ваш буфер обмена.",
"resourceLauncherViewNamePlaceholder": "Имя вида",
"resourceLauncherViewNameLabel": "Имя вида",
"resourceLauncherViewSaved": "Вид сохранён",
"resourceLauncherViewSavedDescription": "Ваш вид запуска был сохранён.",
"resourceLauncherViewSaveFailed": "Не удалось сохранить вид",
"resourceLauncherViewSaveFailedDescription": "Не удалось сохранить вид. Пожалуйста, попробуйте еще раз.",
"resourceLauncherViewDeleted": "Вид удалён",
"resourceLauncherViewDeletedDescription": "Вид запуска был удалён.",
"resourceLauncherViewDeleteFailed": "Не удалось удалить вид",
"resourceLauncherViewDeleteFailedDescription": "Не удалось удалить вид. Пожалуйста, попробуйте еще раз.",
"memberPortalPrevious": "Предыдущий", "memberPortalPrevious": "Предыдущий",
"memberPortalNext": "Следующий", "memberPortalNext": "Следующий",
"httpSettings": "Настройки HTTP", "httpSettings": "Настройки HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----", "sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----",
"sshPrivateKeyRequired": "Требуется личный ключ", "sshPrivateKeyRequired": "Требуется личный ключ",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Введите пароль VNC для подключения", "vncSignInDescription": "Введите ваши учетные данные VNC для подключения",
"vncUsernameOptional": "Имя пользователя (необязательно)",
"vncPasswordOptional": "Пароль (необязательно)", "vncPasswordOptional": "Пароль (необязательно)",
"vncNoResourceTarget": "Отсутствует целевой ресурс", "vncNoResourceTarget": "Отсутствует целевой ресурс",
"vncFailedToLoadNovnc": "Не удалось загрузить noVNC", "vncFailedToLoadNovnc": "Не удалось загрузить noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site güncellendi", "siteUpdated": "Site güncellendi",
"siteUpdatedDescription": "Site güncellendi.", "siteUpdatedDescription": "Site güncellendi.",
"siteGeneralDescription": "Bu site için genel ayarları yapılandırın", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın",
"siteRestartTitle": "Siteyi Yeniden Başlat",
"siteRestartDescription": "Bu site için WireGuard tünelini yeniden başlatın. Bu, bağlantıyı kısa süreliğine keser.",
"siteRestartBody": "Site tüneli düzgün çalışmadığında ve ana bilgisayarı yeniden başlatmadan bağlantıyı yeniden sağlamak istiyorsanız bunu kullanın.",
"siteRestartButton": "Siteyi Yeniden Başlat",
"siteRestartDialogMessage": "<b>{name}</b> için WireGuard tünelini yeniden başlatmak istediğinizden emin misiniz? Site kısa süreliğine bağlantıyı kaybedecektir.",
"siteRestartWarning": "Tünel yeniden başlatılırken site kısa süreliğine kesintiye uğrar.",
"siteRestarted": "Site yeniden başlatıldı",
"siteRestartedDescription": "WireGuard tüneli yeniden başlatıldı.",
"siteErrorRestart": "Sitenin yeniden başlatılması başarısız oldu",
"siteErrorRestartDescription": "Site yeniden başlatılırken bir hata oluştu.",
"siteSettingDescription": "Sitenizdeki ayarları yapılandırın", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın",
"siteResourcesTab": "Kaynaklar", "siteResourcesTab": "Kaynaklar",
"siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.", "siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Planı Uygula", "actionApplyBlueprint": "Planı Uygula",
"actionListBlueprints": "Plan Listesini Görüntüle", "actionListBlueprints": "Plan Listesini Görüntüle",
"actionGetBlueprint": "Planı Elde Et", "actionGetBlueprint": "Planı Elde Et",
"actionCreateOrgWideLauncherView": "Kuruluş Genelinde Başlatıcı Görünümü Oluşturma",
"setupToken": "Kurulum Simgesi", "setupToken": "Kurulum Simgesi",
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
"setupTokenRequired": "Kurulum simgesi gerekli", "setupTokenRequired": "Kurulum simgesi gerekli",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Alt ağ", "subnetPlaceholder": "Alt ağ",
"addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.", "addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.",
"selectSites": "Siteleri seçin", "selectSites": "Siteleri seçin",
"selectLabels": "Etiketleri seçin",
"sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır", "sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır",
"clientInstallOlm": "Olm Yükle", "clientInstallOlm": "Olm Yükle",
"clientInstallOlmDescription": "Sisteminizde Olm çalıştırın", "clientInstallOlmDescription": "Sisteminizde Olm çalıştırın",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"selectSite": "Site seç...", "selectSite": "Site seç...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}",
"noSitesFound": "Site bulunamadı.", "noSitesFound": "Site bulunamadı.",
"createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Uzak Düğümler", "sidebarRemoteExitNodes": "Uzak Düğümler",
"remoteExitNodeId": "Kimlik", "remoteExitNodeId": "Kimlik",
"remoteExitNodeSecretKey": "Gizli", "remoteExitNodeSecretKey": "Gizli",
"remoteExitNodeNetworkingTitle": "Ağ Ayarları",
"remoteExitNodeNetworkingDescription": "Bu uzak çıkış düğümünün trafiği nasıl yönlendireceğini ve hangi sitelerin bu üzerinden bağlanmayı tercih edeceğini yapılandırın. Gelişmiş özellikler geri bağlantı ağ konfigürasyonları ile kullanılmalıdır.",
"remoteExitNodeNetworkingSave": "Ayarları Kaydet",
"remoteExitNodeNetworkingSaveSuccessTitle": "Ağ ayarları kaydedildi",
"remoteExitNodeNetworkingSaveSuccessDescription": "Ağ ayarları başarıyla güncellendi.",
"remoteExitNodeNetworkingSaveError": "Ağ ayarları kaydedilemedi",
"remoteExitNodeNetworkingSubnetsTitle": "Uzak Alt Ağlar",
"remoteExitNodeNetworkingSubnetsDescription": "Bu uzak çıkış düğümünün trafiği taşıyacağı CIDR aralıklarını tanımlayın. Geçerli bir CIDR (örneğin, <code>10.0.0.0/8</code>) yazın ve eklemek için Enter tuşuna basın.",
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "Uzak Düğüm Oluştur", "title": "Uzak Düğüm Oluştur",
"description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun", "description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
"subnet": "Alt ağ", "subnet": "Alt ağ",
"utilitySubnet": "Yardımcı Alt Ağ",
"subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.",
"customDomain": "Özel Alan", "customDomain": "Özel Alan",
"authPage": "Kimlik Sayfaları", "authPage": "Kimlik Sayfaları",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-posta Beyaz Listesi", "memberPortalEmailWhitelist": "E-posta Beyaz Listesi",
"memberPortalResourceDisabled": "Kaynak Devre Dışı", "memberPortalResourceDisabled": "Kaynak Devre Dışı",
"memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor", "memberPortalShowingResources": "{total} kaynaktan {start}-{end} gösteriliyor",
"resourceLauncherTitle": "Kaynak Başlatıcı",
"resourceLauncherDescription": "Kaynağın detaylarını görüntüleyin ve tek bir yerden başlatın",
"resourceLauncherSearchPlaceholder": "Tüm siteleri ara...",
"resourceLauncherDefaultView": "Varsayılan",
"resourceLauncherSaveView": "Görünümü Kaydet",
"resourceLauncherSaveToCurrentView": "Mevcut Görünüme Kaydet",
"resourceLauncherResetView": "Görünümü Sıfırla",
"resourceLauncherSaveAsNewView": "Yeni Görünüm Olarak Kaydet",
"resourceLauncherSaveAsNewViewDescription": "Geçerli filtrelerinizi ve düzeninizi kaydetmek için bu görünüme bir ad verin.",
"resourceLauncherSaveForEveryone": "Herkes İçin Kaydet",
"resourceLauncherSaveForEveryoneDescription": "Bu görünümü tüm kuruluş üyeleriyle paylaşın. İşaretli değilse, görünüm yalnızca size görünür olur.",
"resourceLauncherMakePersonal": "Kişisel Yap",
"resourceLauncherFilter": "Filtre",
"resourceLauncherSort": "Sıralama",
"resourceLauncherSortAscending": "Artan sırala",
"resourceLauncherSortDescending": "Azalan sırala",
"resourceLauncherSettings": "Ayarlar",
"resourceLauncherGroupBy": "Grupla",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Etiket",
"resourceLauncherLayout": "Düzen",
"resourceLauncherLayoutGrid": "Izgara",
"resourceLauncherLayoutList": "Liste",
"resourceLauncherShowLabels": "Etiketleri Göster",
"resourceLauncherShowSiteTags": "Site Etiketlerini Göster",
"resourceLauncherShowRecents": "Son Eklenenleri Göster",
"resourceLauncherDeleteView": "Görünümü Sil",
"resourceLauncherViewAsAdmin": "Yönetici Olarak Görüntüle",
"resourceLauncherResourceDetailsDescription": "Bu kaynağın detaylarını görüntüleyin.",
"resourceLauncherUnlabeled": "Etiketsiz",
"resourceLauncherNoSite": "Site Yok",
"resourceLauncherNoResourcesInGroup": "Bu grupta kaynak yok",
"resourceLauncherEmptyStateTitle": "Kullanılabilir Kaynak Yok",
"resourceLauncherEmptyStateDescription": "Henüz hiçbir kaynağa erişiminiz yok. Erişim istemek için yöneticinizle iletişime geçin.",
"resourceLauncherEmptyStateNoResultsTitle": "Kaynak Bulunamadı",
"resourceLauncherEmptyStateNoResultsDescription": "Mevcut arama veya filtrelerinizle eşleşen kaynak yok. Aradığınızı bulmak için ayarları değiştirmeyi deneyin.",
"resourceLauncherEmptyStateNoResultsWithQuery": "\"{query}\" ile eşleşen kaynak yok. Tüm kaynakları görmek için aramayı düzenlemeyi veya filtreleri temizlemeyi deneyin.",
"resourceLauncherCopiedToClipboard": "Panoya kopyalandı",
"resourceLauncherCopiedAccessDescription": "Kaynağa erişim panonuza kopyalandı.",
"resourceLauncherViewNamePlaceholder": "Görünüm adı",
"resourceLauncherViewNameLabel": "Görünüm Adı",
"resourceLauncherViewSaved": "Görünüm kaydedildi",
"resourceLauncherViewSavedDescription": "Başlatıcı görünümünüz kaydedildi.",
"resourceLauncherViewSaveFailed": "Görünüm kaydedilemedi",
"resourceLauncherViewSaveFailedDescription": "Başlatıcı görünümü kaydedilemedi. Lütfen yeniden deneyin.",
"resourceLauncherViewDeleted": "Görünüm silindi",
"resourceLauncherViewDeletedDescription": "Başlatıcı görünüm silindi.",
"resourceLauncherViewDeleteFailed": "Görünüm silinemedi",
"resourceLauncherViewDeleteFailedDescription": "Başlatıcı görünümü silinemedi. Lütfen tekrar deneyin.",
"memberPortalPrevious": "Önceki", "memberPortalPrevious": "Önceki",
"memberPortalNext": "Sonraki", "memberPortalNext": "Sonraki",
"httpSettings": "HTTP Ayarları", "httpSettings": "HTTP Ayarları",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----", "sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----",
"sshPrivateKeyRequired": "Özel anahtar gereklidir", "sshPrivateKeyRequired": "Özel anahtar gereklidir",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "Bağlanmak için VNC parolanızı girin", "vncSignInDescription": "Bağlanmak için VNC kimlik bilgilerinizi girin",
"vncUsernameOptional": "Kullanıcı Adı (isteğe bağlı)",
"vncPasswordOptional": "Parola (isteğe bağlı)", "vncPasswordOptional": "Parola (isteğe bağlı)",
"vncNoResourceTarget": "Kaynak hedefi mevcut değil", "vncNoResourceTarget": "Kaynak hedefi mevcut değil",
"vncFailedToLoadNovnc": "NoVNC yüklenemedi", "vncFailedToLoadNovnc": "NoVNC yüklenemedi",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "站点已更新", "siteUpdated": "站点已更新",
"siteUpdatedDescription": "网站已更新。", "siteUpdatedDescription": "网站已更新。",
"siteGeneralDescription": "配置此站点的常规设置", "siteGeneralDescription": "配置此站点的常规设置",
"siteRestartTitle": "重启站点",
"siteRestartDescription": "重启此站点的WireGuard隧道。此操作将暂时中断连接。",
"siteRestartBody": "如果站点隧道无法正常工作,并且您希望在不重启主机的情况下强制重新连接,请使用此选项。",
"siteRestartButton": "重启站点",
"siteRestartDialogMessage": "确定要重启<b>{name}</b>的WireGuard隧道吗站点将暂时断开连接。",
"siteRestartWarning": "隧道重启时,站点将暂时断开连接。",
"siteRestarted": "站点已重启",
"siteRestartedDescription": "WireGuard隧道已重启。",
"siteErrorRestart": "重启站点失败",
"siteErrorRestartDescription": "重启站点时发生错误。",
"siteSettingDescription": "配置站点设置", "siteSettingDescription": "配置站点设置",
"siteResourcesTab": "资源", "siteResourcesTab": "资源",
"siteResourcesNoneOnSite": "此站点尚无公开或私人资源。", "siteResourcesNoneOnSite": "此站点尚无公开或私人资源。",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "应用蓝图", "actionApplyBlueprint": "应用蓝图",
"actionListBlueprints": "列表蓝图", "actionListBlueprints": "列表蓝图",
"actionGetBlueprint": "获取蓝图", "actionGetBlueprint": "获取蓝图",
"actionCreateOrgWideLauncherView": "创建组织范围的启动器视图",
"setupToken": "设置令牌", "setupToken": "设置令牌",
"setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenDescription": "从服务器控制台输入设置令牌。",
"setupTokenRequired": "需要设置令牌", "setupTokenRequired": "需要设置令牌",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "子网", "subnetPlaceholder": "子网",
"addressDescription": "客户的内部地址。必须属于组织的子网。", "addressDescription": "客户的内部地址。必须属于组织的子网。",
"selectSites": "选择站点", "selectSites": "选择站点",
"selectLabels": "选择标签",
"sitesDescription": "客户端将与所选站点进行连接", "sitesDescription": "客户端将与所选站点进行连接",
"clientInstallOlm": "安装 Olm", "clientInstallOlm": "安装 Olm",
"clientInstallOlmDescription": "在您的系统上运行 Olm", "clientInstallOlmDescription": "在您的系统上运行 Olm",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "站点", "createInternalResourceDialogSite": "站点",
"selectSite": "选择站点...", "selectSite": "选择站点...",
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}", "multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
"labelsSelectorLabelsCount": "{count, plural, other {# 标签}}",
"noSitesFound": "未找到站点。", "noSitesFound": "未找到站点。",
"createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogProtocol": "协议",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "远程节点", "sidebarRemoteExitNodes": "远程节点",
"remoteExitNodeId": "ID", "remoteExitNodeId": "ID",
"remoteExitNodeSecretKey": "密钥", "remoteExitNodeSecretKey": "密钥",
"remoteExitNodeNetworkingTitle": "网络设置",
"remoteExitNodeNetworkingDescription": "配置此远程出口节点如何路由流量以及哪些站点优先通过其连接。高级功能可用于回程网络配置。",
"remoteExitNodeNetworkingSave": "保存设置",
"remoteExitNodeNetworkingSaveSuccessTitle": "网络设置已保存",
"remoteExitNodeNetworkingSaveSuccessDescription": "网络设置已成功更新。",
"remoteExitNodeNetworkingSaveError": "保存网络设置失败",
"remoteExitNodeNetworkingSubnetsTitle": "远程子网",
"remoteExitNodeNetworkingSubnetsDescription": "定义此远程出口节点将路由流量的CIDR范围。输入有效的CIDR例如<code>10.0.0.0/8</code>并按Enter键添加。",
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围例如10.0.0.0/8",
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
"remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。",
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
"remoteExitNodeCreate": { "remoteExitNodeCreate": {
"title": "创建远程节点", "title": "创建远程节点",
"description": "创建一个新的自托管远程中继和代理服务器节点", "description": "创建一个新的自托管远程中继和代理服务器节点",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpGoogleDescription": "Google OAuth2/OIDC 提供商",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "子网", "subnet": "子网",
"utilitySubnet": "实用程序子网",
"subnetDescription": "此组织网络配置的子网。", "subnetDescription": "此组织网络配置的子网。",
"customDomain": "自定义域", "customDomain": "自定义域",
"authPage": "身份验证页面", "authPage": "身份验证页面",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "电子邮件白名单", "memberPortalEmailWhitelist": "电子邮件白名单",
"memberPortalResourceDisabled": "资源已禁用", "memberPortalResourceDisabled": "资源已禁用",
"memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源", "memberPortalShowingResources": "显示 {start}-{end} 共 {total} 个资源",
"resourceLauncherTitle": "资源启动器",
"resourceLauncherDescription": "查看资源详情并从一个地方启动它们",
"resourceLauncherSearchPlaceholder": "搜索所有站点……",
"resourceLauncherDefaultView": "默认",
"resourceLauncherSaveView": "保存视图",
"resourceLauncherSaveToCurrentView": "保存至当前视图",
"resourceLauncherResetView": "重置视图",
"resourceLauncherSaveAsNewView": "另存为新视图",
"resourceLauncherSaveAsNewViewDescription": "为此视图命名,以便保存您当前的过滤器和布局。",
"resourceLauncherSaveForEveryone": "为所有人保存",
"resourceLauncherSaveForEveryoneDescription": "与所有组织成员共享此视图。如果未选中,此视图仅对您可见。",
"resourceLauncherMakePersonal": "创建个人",
"resourceLauncherFilter": "筛选",
"resourceLauncherSort": "排序",
"resourceLauncherSortAscending": "按升序排序",
"resourceLauncherSortDescending": "按降序排序",
"resourceLauncherSettings": "设置",
"resourceLauncherGroupBy": "按组",
"resourceLauncherGroupBySite": "站点",
"resourceLauncherGroupByLabel": "标签",
"resourceLauncherLayout": "布局",
"resourceLauncherLayoutGrid": "网格",
"resourceLauncherLayoutList": "列表",
"resourceLauncherShowLabels": "显示标签",
"resourceLauncherShowSiteTags": "显示站点标签",
"resourceLauncherShowRecents": "显示最近使用",
"resourceLauncherDeleteView": "删除视图",
"resourceLauncherViewAsAdmin": "以管理员身份查看",
"resourceLauncherResourceDetailsDescription": "查看此资源的详细信息。",
"resourceLauncherUnlabeled": "未标记",
"resourceLauncherNoSite": "无站点",
"resourceLauncherNoResourcesInGroup": "此组中没有资源",
"resourceLauncherEmptyStateTitle": "没有可用资源",
"resourceLauncherEmptyStateDescription": "您还没有访问任何资源。请联系您的管理员以请求访问。",
"resourceLauncherEmptyStateNoResultsTitle": "未找到资源",
"resourceLauncherEmptyStateNoResultsDescription": "没有资源与您当前的搜索或过滤器匹配。尝试调整它们以找到您想要的内容。",
"resourceLauncherEmptyStateNoResultsWithQuery": "没有资源匹配\"{query}\"。尝试调整您的搜索或清除过滤器以查看所有资源。",
"resourceLauncherCopiedToClipboard": "已复制到剪贴板",
"resourceLauncherCopiedAccessDescription": "资源访问权限已复制到剪贴板。",
"resourceLauncherViewNamePlaceholder": "查看名称",
"resourceLauncherViewNameLabel": "查看名称",
"resourceLauncherViewSaved": "视图已保存",
"resourceLauncherViewSavedDescription": "您的启动器视图已保存。",
"resourceLauncherViewSaveFailed": "保存视图失败",
"resourceLauncherViewSaveFailedDescription": "无法保存启动器视图。请再试一次。",
"resourceLauncherViewDeleted": "视图已删除",
"resourceLauncherViewDeletedDescription": "启动器视图已删除。",
"resourceLauncherViewDeleteFailed": "删除视图失败",
"resourceLauncherViewDeleteFailedDescription": "无法删除启动器视图。请再试一次。",
"memberPortalPrevious": "上一页", "memberPortalPrevious": "上一页",
"memberPortalNext": "下一页", "memberPortalNext": "下一页",
"httpSettings": "HTTP 设置", "httpSettings": "HTTP 设置",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----", "sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "需要私钥", "sshPrivateKeyRequired": "需要私钥",
"vncTitle": "VNC", "vncTitle": "VNC",
"vncSignInDescription": "输入您的 VNC 密码以连接", "vncSignInDescription": "输入您的VNC凭据以连接",
"vncUsernameOptional": "用户名(可选)",
"vncPasswordOptional": "密码 (可选)", "vncPasswordOptional": "密码 (可选)",
"vncNoResourceTarget": "没有可用的资源目标", "vncNoResourceTarget": "没有可用的资源目标",
"vncFailedToLoadNovnc": "加载 noVNC 失败", "vncFailedToLoadNovnc": "加载 noVNC 失败",

View File

@@ -21,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite", getSite = "getSite",
listSites = "listSites", listSites = "listSites",
updateSite = "updateSite", updateSite = "updateSite",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth", resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret", reGenerateSecret = "reGenerateSecret",
createResource = "createResource", createResource = "createResource",
@@ -178,7 +179,8 @@ export enum ActionsEnum {
setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist", setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules" setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(

View File

@@ -2,6 +2,7 @@ import {
pgTable, pgTable,
serial, serial,
varchar, varchar,
unique,
boolean, boolean,
integer, integer,
bigint, bigint,
@@ -19,12 +20,13 @@ import {
roles, roles,
users, users,
exitNodes, exitNodes,
sessions,
clients,
resources, resources,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
sites sites,
clients,
sessions,
labels
} from "./schema"; } from "./schema";
export const certificates = pgTable("certificates", { export const certificates = pgTable("certificates", {
@@ -197,6 +199,42 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
}) })
}); });
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
.notNull()
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
}),
destination: varchar("destination").notNull() // a cidr range
});
export const remoteExitNodePreferenceLabels = pgTable(
// this controls what sites are enforced to connect to this node
"remoteExitNodePreferenceLabels",
{
remoteExitNodePreferenceLabelId: serial(
"remoteExitNodePreferenceLabelId"
).primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [
unique("remote_exit_node_preference_label_uniq").on(
t.remoteExitNodeId,
t.labelId
)
]
);
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
sessionId: varchar("id").primaryKey(), sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId") remoteExitNodeId: varchar("remoteExitNodeId")

View File

@@ -25,7 +25,8 @@ export const domains = pgTable("domains", {
certResolver: varchar("certResolver"), certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"), customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert"), preferWildcardCert: boolean("preferWildcardCert"),
errorMessage: text("errorMessage") errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
}); });
export const dnsRecords = pgTable("dnsRecords", { export const dnsRecords = pgTable("dnsRecords", {
@@ -128,7 +129,8 @@ export const sites = pgTable(
t.exitNodeId, t.exitNodeId,
t.type, t.type,
t.siteId t.siteId
) ),
index("idx_sites_orgid_niceid").on(t.orgId, t.niceId)
] ]
); );
@@ -203,7 +205,9 @@ export const resources = pgTable(
(t) => [ (t) => [
index("idx_resources_fulldomain") index("idx_resources_fulldomain")
.on(t.fullDomain) .on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`) .where(sql`${t.fullDomain} IS NOT NULL`),
index("idx_resources_niceid").on(t.niceId),
index("idx_resources_orgid_niceid").on(t.orgId, t.niceId)
] ]
); );
@@ -218,6 +222,20 @@ export const labels = pgTable("labels", {
.notNull() .notNull()
}); });
export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
export const siteLabels = pgTable( export const siteLabels = pgTable(
"siteLabels", "siteLabels",
{ {
@@ -384,63 +402,77 @@ export const exitNodes = pgTable("exitNodes", {
region: varchar("region") region: varchar("region")
}); });
export const siteResources = pgTable("siteResources", { export const siteResources = pgTable(
// this is for the clients "siteResources",
siteResourceId: serial("siteResourceId").primaryKey(), {
orgId: varchar("orgId") // this is for the clients
.notNull() siteResourceId: serial("siteResourceId").primaryKey(),
.references(() => orgs.orgId, { onDelete: "cascade" }), orgId: varchar("orgId")
networkId: integer("networkId").references(() => networks.networkId, { .notNull()
onDelete: "set null" .references(() => orgs.orgId, { onDelete: "cascade" }),
}), networkId: integer("networkId").references(() => networks.networkId, {
defaultNetworkId: integer("defaultNetworkId").references( onDelete: "set null"
() => networks.networkId, }),
{ defaultNetworkId: integer("defaultNetworkId").references(
onDelete: "restrict" () => networks.networkId,
} {
), onDelete: "restrict"
niceId: varchar("niceId").notNull(), }
name: varchar("name").notNull(), ),
ssl: boolean("ssl").notNull().default(false), niceId: varchar("niceId").notNull(),
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http" name: varchar("name").notNull(),
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode ssl: boolean("ssl").notNull().default(false),
proxyPort: integer("proxyPort"), // only for port mode mode: varchar("mode")
destinationPort: integer("destinationPort"), // only for port mode .$type<"host" | "cidr" | "http" | "ssh">()
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode .notNull(), // "host" | "cidr" | "http"
enabled: boolean("enabled").notNull().default(true), scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
alias: varchar("alias"), proxyPort: integer("proxyPort"), // only for port mode
aliasAddress: varchar("aliasAddress"), destinationPort: integer("destinationPort"), // only for port mode
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), enabled: boolean("enabled").notNull().default(true),
disableIcmp: boolean("disableIcmp").notNull().default(false), alias: varchar("alias"),
authDaemonPort: integer("authDaemonPort").default(22123), aliasAddress: varchar("aliasAddress"),
pamMode: varchar("pamMode", { length: 32 }) tcpPortRangeString: varchar("tcpPortRangeString")
.$type<"passthrough" | "push">() .notNull()
.default("passthrough"), .default("*"),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) udpPortRangeString: varchar("udpPortRangeString")
.$type<"site" | "remote" | "native">() .notNull()
.default("site"), .default("*"),
domainId: varchar("domainId").references(() => domains.domainId, { disableIcmp: boolean("disableIcmp").notNull().default(false),
onDelete: "set null" authDaemonPort: integer("authDaemonPort").default(22123),
}), pamMode: varchar("pamMode", { length: 32 })
subdomain: varchar("subdomain"), .$type<"passthrough" | "push">()
fullDomain: varchar("fullDomain") .default("passthrough"),
}); authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain")
},
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);
export const networks = pgTable("networks", { export const networks = pgTable(
networkId: serial("networkId").primaryKey(), "networks",
niceId: text("niceId"), {
name: text("name"), networkId: serial("networkId").primaryKey(),
scope: varchar("scope") niceId: text("niceId"),
.$type<"global" | "resource">() name: text("name"),
.notNull() scope: varchar("scope")
.default("global"), .$type<"global" | "resource">()
orgId: varchar("orgId") .notNull()
.references(() => orgs.orgId, { .default("global"),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull() onDelete: "cascade"
}); })
.notNull()
},
(t) => [index("idx_networks_orgid").on(t.orgId)]
);
export const siteNetworks = pgTable( export const siteNetworks = pgTable(
"siteNetworks", "siteNetworks",
@@ -986,28 +1018,32 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
export const resourcePolicies = pgTable("resourcePolicies", { export const resourcePolicies = pgTable(
resourcePolicyId: serial("resourcePolicyId").primaryKey(), "resourcePolicies",
sso: boolean("sso").notNull().default(true), {
applyRules: boolean("applyRules").notNull().default(false), resourcePolicyId: serial("resourcePolicyId").primaryKey(),
scope: varchar("scope") sso: boolean("sso").notNull().default(true),
.$type<"global" | "resource">() applyRules: boolean("applyRules").notNull().default(false),
.notNull() scope: varchar("scope")
.default("global"), .$type<"global" | "resource">()
emailWhitelistEnabled: boolean("emailWhitelistEnabled") .notNull()
.notNull() .default("global"),
.default(false), emailWhitelistEnabled: boolean("emailWhitelistEnabled")
idpId: integer("idpId").references(() => idp.idpId, { .notNull()
onDelete: "set null" .default(false),
}), idpId: integer("idpId").references(() => idp.idpId, {
niceId: text("niceId").notNull(), onDelete: "set null"
name: varchar("name").notNull(), }),
orgId: varchar("orgId") niceId: text("niceId").notNull(),
.references(() => orgs.orgId, { name: varchar("name").notNull(),
onDelete: "cascade" orgId: varchar("orgId")
}) .references(() => orgs.orgId, {
.notNull() onDelete: "cascade"
}); })
.notNull()
},
(t) => [index("idx_resourcepolicies_orgid_niceid").on(t.orgId, t.niceId)]
);
export const supporterKey = pgTable("supporterKey", { export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(), keyId: serial("keyId").primaryKey(),
@@ -1131,7 +1167,10 @@ export const clients = pgTable(
"pending" | "approved" | "denied" "pending" | "approved" | "denied"
>() >()
}, },
(t) => [index("idx_clients_userid").on(t.userId)] (t) => [
index("idx_clients_userid").on(t.userId),
index("idx_clients_orgid_niceid").on(t.orgId, t.niceId)
]
); );
export const clientSitesAssociationsCache = pgTable( export const clientSitesAssociationsCache = pgTable(
@@ -1550,6 +1589,7 @@ export type RoundTripMessageTracker = InferSelectModel<
export type Network = InferSelectModel<typeof networks>; export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>; export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>; export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -12,6 +12,7 @@ import {
clients, clients,
domains, domains,
exitNodes, exitNodes,
labels,
orgs, orgs,
resources, resources,
roles, roles,
@@ -21,9 +22,6 @@ import {
targetHealthCheck, targetHealthCheck,
users users
} from "./schema"; } from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -195,6 +193,44 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
}) })
}); });
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
autoIncrement: true
}),
remoteExitNodeId: text("remoteExitNodeId")
.notNull()
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
}),
destination: text("destination").notNull() // a cidr range
});
export const remoteExitNodePreferenceLabels = sqliteTable(
// this controls what sites are enforced to connect to this node
"remoteExitNodePreferenceLabels",
{
remoteExitNodePreferenceLabelId: integer(
"remoteExitNodePreferenceLabelId"
).primaryKey({ autoIncrement: true }),
remoteExitNodeId: text("remoteExitNodeId")
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [
uniqueIndex("remote_exit_node_preference_label_uniq").on(
t.remoteExitNodeId,
t.labelId
)
]
);
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
sessionId: text("id").primaryKey(), sessionId: text("id").primaryKey(),
remoteExitNodeId: text("remoteExitNodeId") remoteExitNodeId: text("remoteExitNodeId")

View File

@@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", {
failed: integer("failed", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0), tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"), certResolver: text("certResolver"),
customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage") errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
}); });
export const dnsRecords = sqliteTable("dnsRecords", { export const dnsRecords = sqliteTable("dnsRecords", {
@@ -221,6 +223,20 @@ export const labels = sqliteTable("labels", {
.notNull() .notNull()
}); });
export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name").notNull(),
config: text("config").notNull(),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const siteLabels = sqliteTable( export const siteLabels = sqliteTable(
"siteLabels", "siteLabels",
{ {
@@ -1549,6 +1565,7 @@ export type RoundTripMessageTracker = InferSelectModel<
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>; export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>; export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel< export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode typeof resourcePolicyPincode

View File

@@ -1,28 +1,39 @@
export enum FeatureId { export enum LimitId {
USERS = "users", USERS = "users",
SITES = "sites", SITES = "sites",
EGRESS_DATA_MB = "egressDataMb", EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains", DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes", REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations", ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
TIER1 = "tier1" TIER1 = "tier1"
} }
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> { export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
switch (featureId) { switch (featureId) {
case FeatureId.USERS: case LimitId.USERS:
return "Users"; return "Users";
case FeatureId.SITES: case LimitId.SITES:
return "Sites"; return "Sites";
case FeatureId.EGRESS_DATA_MB: case LimitId.EGRESS_DATA_MB:
return "Egress Data (MB)"; return "Egress Data (MB)";
case FeatureId.DOMAINS: case LimitId.DOMAINS:
return "Domains"; return "Domains";
case FeatureId.REMOTE_EXIT_NODES: case LimitId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes"; return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS: case LimitId.ORGANIZATIONS:
return "Organizations"; return "Organizations";
case FeatureId.TIER1: case LimitId.PUBLIC_RESOURCES:
return "Public Resources";
case LimitId.PRIVATE_RESOURCES:
return "Private Resources";
case LimitId.MACHINE_CLIENTS:
return "Machine Clients";
case LimitId.TIER1:
return "Home Lab"; return "Home Lab";
default: default:
return featureId; return featureId;
@@ -30,15 +41,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
} }
// this is from the old system // this is from the old system
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
// right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
}; };
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = { export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
}; };
export function getFeatureMeterId(featureId: FeatureId): string | undefined { export function getFeatureMeterId(featureId: LimitId): string | undefined {
if ( if (
process.env.ENVIRONMENT == "prod" && process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true" process.env.SANDBOX_MODE !== "true"
@@ -49,22 +61,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
} }
} }
export function getFeatureIdByMetricId( export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
metricId: string return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
([_, v]) => v === metricId ([_, v]) => v === metricId
)?.[0]; )?.[0];
} }
export type FeaturePriceSet = Partial<Record<FeatureId, string>>; export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = { export const tier1FeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" [LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
}; };
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = { export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" [LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
}; };
export function getTier1FeaturePriceSet(): FeaturePriceSet { export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -79,11 +89,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
} }
export const tier2FeaturePriceSet: FeaturePriceSet = { export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" [LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
}; };
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" [LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
}; };
export function getTier2FeaturePriceSet(): FeaturePriceSet { export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -98,11 +108,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
} }
export const tier3FeaturePriceSet: FeaturePriceSet = { export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" [LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
}; };
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" [LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
}; };
export function getTier3FeaturePriceSet(): FeaturePriceSet { export function getTier3FeaturePriceSet(): FeaturePriceSet {
@@ -116,7 +126,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
} }
} }
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
// Check all feature price sets // Check all feature price sets
const allPriceSets = [ const allPriceSets = [
getTier1FeaturePriceSet(), getTier1FeaturePriceSet(),
@@ -125,7 +135,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
]; ];
for (const priceSet of allPriceSets) { for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
([_, price]) => price === priceId ([_, price]) => price === priceId
); );
if (entry) { if (entry) {

View File

@@ -1,19 +1,19 @@
import Stripe from "stripe"; import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features"; import { LimitId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService"; import { usageService } from "./usageService";
export async function getLineItems( export async function getLineItems(
featurePriceSet: FeaturePriceSet, featurePriceSet: FeaturePriceSet,
orgId: string, orgId: string
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> { ): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS); const users = await usageService.getUsage(orgId, LimitId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => { return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined; let quantity: number | undefined;
if (featureId === FeatureId.USERS) { if (featureId === LimitId.USERS) {
quantity = users?.instantaneousValue || 1; quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) { } else if (featureId === LimitId.TIER1) {
quantity = 1; quantity = 1;
} }

View File

@@ -1,70 +1,82 @@
import { FeatureId } from "./features"; import { LimitId } from "./features";
export type LimitSet = Partial<{ export type LimitSet = Partial<{
[key in FeatureId]: { [key in LimitId]: {
value: number | null; // null indicates no limit value: number | null; // null indicates no limit
description?: string; description?: string;
}; };
}>; }>;
export const freeLimitSet: LimitSet = { export const freeLimitSet: LimitSet = {
[FeatureId.SITES]: { value: 5, description: "Basic limit" }, [LimitId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" }, [LimitId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" }, [LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" }, [LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
}; };
export const tier1LimitSet: LimitSet = { export const tier1LimitSet: LimitSet = {
[FeatureId.USERS]: { value: 7, description: "Home limit" }, [LimitId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" }, [LimitId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, [LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, [LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" }, [LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
}; };
export const tier2LimitSet: LimitSet = { export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: { [LimitId.USERS]: {
value: 50, value: 50,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.SITES]: { [LimitId.SITES]: {
value: 50, value: 50,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.DOMAINS]: { [LimitId.DOMAINS]: {
value: 50, value: 50,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.REMOTE_EXIT_NODES]: { [LimitId.REMOTE_EXIT_NODES]: {
value: 3, value: 3,
description: "Team limit" description: "Team limit"
}, },
[FeatureId.ORGINIZATIONS]: { [LimitId.ORGANIZATIONS]: {
value: 1, value: 1,
description: "Team limit" description: "Team limit"
} },
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
}; };
export const tier3LimitSet: LimitSet = { export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: { [LimitId.USERS]: {
value: 250, value: 250,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.SITES]: { [LimitId.SITES]: {
value: 250, value: 250,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.DOMAINS]: { [LimitId.DOMAINS]: {
value: 100, value: 100,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.REMOTE_EXIT_NODES]: { [LimitId.REMOTE_EXIT_NODES]: {
value: 20, value: 20,
description: "Business limit" description: "Business limit"
}, },
[FeatureId.ORGINIZATIONS]: { [LimitId.ORGANIZATIONS]: {
value: 5, value: 5,
description: "Business limit" description: "Business limit"
}, },
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
}; };

View File

@@ -1,7 +1,7 @@
import { db, limits } from "@server/db"; import { db, limits } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet"; import { LimitSet } from "./limitSet";
import { FeatureId } from "./features"; import { LimitId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
class LimitService { class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit( async getOrgLimit(
orgId: string, orgId: string,
featureId: FeatureId featureId: LimitId
): Promise<number | null> { ): Promise<number | null> {
const limitId = `${orgId}-${featureId}`; const limitId = `${orgId}-${featureId}`;
const [limit] = await db const [limit] = await db

View File

@@ -9,7 +9,7 @@ import {
Transaction, Transaction,
orgs orgs
} from "@server/db"; } from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features"; import { LimitId, getFeatureMeterId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
import { build } from "@server/build"; import { build } from "@server/build";
import { regionalCache as cache } from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
@@ -37,7 +37,7 @@ export class UsageService {
public async add( public async add(
orgId: string, orgId: string,
featureId: FeatureId, featureId: LimitId,
value: number, value: number,
transaction: any = null transaction: any = null
): Promise<Usage | null> { ): Promise<Usage | null> {
@@ -114,7 +114,7 @@ export class UsageService {
private async internalAddUsage( private async internalAddUsage(
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId, featureId: LimitId,
value: number, value: number,
trx: Transaction trx: Transaction
): Promise<Usage> { ): Promise<Usage> {
@@ -163,7 +163,7 @@ export class UsageService {
async updateCount( async updateCount(
orgId: string, orgId: string,
featureId: FeatureId, featureId: LimitId,
value?: number, value?: number,
customerId?: string customerId?: string
): Promise<void> { ): Promise<void> {
@@ -227,7 +227,7 @@ export class UsageService {
private async getCustomerId( private async getCustomerId(
orgId: string, orgId: string,
featureId: FeatureId featureId: LimitId
): Promise<string | null> { ): Promise<string | null> {
const orgIdToUse = await this.getBillingOrg(orgId); const orgIdToUse = await this.getBillingOrg(orgId);
@@ -269,18 +269,19 @@ export class UsageService {
public async getUsage( public async getUsage(
orgId: string, orgId: string,
featureId: FeatureId, featureId: LimitId,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<Usage | null> { ): Promise<Usage | null> {
if (noop()) { if (noop()) {
return null; return null;
} }
const orgIdToUse = await this.getBillingOrg(orgId, trx); let orgIdToUse = orgId;
const usageId = `${orgIdToUse}-${featureId}`;
try { try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
const [result] = await trx const [result] = await trx
.select() .select()
.from(usage) .from(usage)
@@ -340,8 +341,12 @@ export class UsageService {
`Failed to get usage for ${orgIdToUse}/${featureId}:`, `Failed to get usage for ${orgIdToUse}/${featureId}:`,
error error
); );
throw error; if (process.env.NODE_ENV !== "development") {
throw error;
}
} }
return null;
} }
public async getBillingOrg( public async getBillingOrg(
@@ -376,7 +381,7 @@ export class UsageService {
public async checkLimitSet( public async checkLimitSet(
orgId: string, orgId: string,
featureId?: FeatureId, featureId?: LimitId,
usage?: Usage, usage?: Usage,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<boolean> { ): Promise<boolean> {
@@ -384,13 +389,13 @@ export class UsageService {
return false; return false;
} }
const orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization // This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org // and kick out all of the sites on the org
let hasExceededLimits = false; let hasExceededLimits = false;
let orgIdToUse = orgId;
try { try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
let orgLimits: Limit[] = []; let orgLimits: Limit[] = [];
if (featureId) { if (featureId) {
// Get all limits set for this organization // Get all limits set for this organization
@@ -424,7 +429,7 @@ export class UsageService {
} else { } else {
currentUsage = await this.getUsage( currentUsage = await this.getUsage(
orgIdToUse, orgIdToUse,
limit.featureId as FeatureId, limit.featureId as LimitId,
trx trx
); );
} }

View File

@@ -8,7 +8,7 @@ import {
userSiteResources, userSiteResources,
clientSiteResources clientSiteResources
} from "@server/db"; } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import { import {
PublicResourcesResults, PublicResourcesResults,
updatePublicResources updatePublicResources
@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource, rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations"; } from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
@@ -64,6 +70,7 @@ export async function applyBlueprint({
let publicResourcesResults: PublicResourcesResults = []; let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = []; let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx); await updateResourcePolicies(orgId, config, trx);
@@ -172,7 +179,9 @@ export async function applyBlueprint({
} catch (err) { } catch (err) {
blueprintSucceeded = false; blueprintSucceeded = false;
blueprintMessage = `Blueprint applied with errors: ${err}`; blueprintMessage = `Blueprint applied with errors: ${err}`;
logger.error(blueprintMessage); logger.debug(
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
);
error = err; error = err;
} }

View File

@@ -25,6 +25,12 @@ import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
async function getDomainForSiteResource( async function getDomainForSiteResource(
siteResourceId: number | undefined, siteResourceId: number | undefined,
@@ -413,6 +419,34 @@ export async function updatePrivateResources(
oldSites: existingSiteIds oldSites: existingSiteIds
}); });
} else { } else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Private resource limit exceeded. Please upgrade your plan."
);
}
}
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null; let releaseAliasLock: (() => Promise<void>) | null = null;
if ( if (
@@ -609,6 +643,8 @@ export async function updatePrivateResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
); );
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
results.push({ results.push({
newSiteResource: newResource, newSiteResource: newResource,
newSites: allSites, newSites: allSites,

View File

@@ -51,6 +51,11 @@ import { build } from "@server/build";
import { encrypt } from "@server/lib/crypto"; import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import serverConfig from "@server/lib/config"; import serverConfig from "@server/lib/config";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
export type PublicResourcesResults = { export type PublicResourcesResults = {
proxyResource: Resource; proxyResource: Resource;
@@ -1005,6 +1010,33 @@ export async function updatePublicResources(
logger.debug(`Updated resource ${existingResource.resourceId}`); logger.debug(`Updated resource ${existingResource.resourceId}`);
} else { } else {
// create a brand new resource // create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PUBLIC_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Public resource limit exceeded. Please upgrade your plan."
);
}
}
let domain; let domain;
if ( if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "") ["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
@@ -1294,6 +1326,8 @@ export async function updatePublicResources(
await createTarget(newResource.resourceId, targetData); await createTarget(newResource.resourceId, targetData);
} }
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
logger.debug(`Created resource ${newResource.resourceId}`); logger.debug(`Created resource ${newResource.resourceId}`);
} }

View File

@@ -29,7 +29,7 @@ type ClientRow = typeof clients.$inferSelect;
function runQueuedClientAssociationRebuilds( function runQueuedClientAssociationRebuilds(
userId: string, userId: string,
queuedClients: ClientRow[] queuedClients: ClientRow[]
): void { ) {
if (queuedClients.length === 0) { if (queuedClients.length === 0) {
return; return;
} }
@@ -39,425 +39,403 @@ function runQueuedClientAssociationRebuilds(
uniqueClientsById.set(client.clientId, client); uniqueClientsById.set(client.clientId, client);
} }
void (async () => { for (const client of uniqueClientsById.values()) {
for (const client of uniqueClientsById.values()) { rebuildClientAssociationsFromClient(client).catch((error) => {
try { logger.error(
await rebuildClientAssociationsFromClient(client); `Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
} catch (error) { error
logger.error( )}`
`Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` );
); });
} }
}
logger.debug( logger.debug(
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})` `Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
); );
})();
} }
export async function calculateUserClientsForOrgs( export async function calculateUserClientsForOrgs(
userId: string userId: string
): Promise<void> { ): Promise<void> {
const trx = primaryDb; const trx = primaryDb;
const queuedAssociationRebuilds: ClientRow[] = []; const queuedAssociationRebuilds: ClientRow[] = [];
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const execute = async (transaction: Transaction | typeof db) => { const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
const orgCache = new Map<string, typeof orgs.$inferSelect | null>(); const getRoleClientKey = (roleId: number, clientId: number) =>
const adminRoleCache = new Map< `${roleId}:${clientId}`;
string, const getUserClientKey = (cachedUserId: string, clientId: number) =>
typeof roles.$inferSelect | null `${cachedUserId}:${clientId}`;
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) => const getOrg = async (orgId: string) => {
`${orgId}:${olmId}`; if (orgCache.has(orgId)) {
const getRoleClientKey = (roleId: number, clientId: number) => return orgCache.get(orgId) ?? null;
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(
userId,
transaction,
[],
queuedAssociationRebuilds
);
return;
} }
// Get all user orgs with all roles (for org list and role-based logic) const [org] = await trx
const userOrgRoleRows = await transaction
.select() .select()
.from(userOrgs) .from(orgs)
.innerJoin( .where(eq(orgs.orgId, orgId));
userOrgRoles, orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await trx
.select()
.from(clients)
.where(
and( and(
eq(userOrgs.userId, userOrgRoles.userId), eq(clients.userId, userId),
eq(userOrgs.orgId, userOrgRoles.orgId) eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
) )
) )
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .limit(1);
.where(eq(userOrgs.userId, userId));
const userOrgIds = [ existingClientCache.set(key, existingClient ?? null);
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
]; return existingClient ?? null;
const orgIdToRoleRows = new Map< };
string,
(typeof userOrgRoleRows)[0][] const hasRoleClientAccess = async (roleId: number, clientId: number) => {
>(); const key = getRoleClientKey(roleId, clientId);
for (const r of userOrgRoleRows) { if (roleClientAccessCache.has(key)) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; return roleClientAccessCache.get(key)!;
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
} }
// For each OLM, ensure there's a client in each org the user is in const [existingRoleClient] = await trx
for (const olm of userOlms) { .select()
for (const orgId of orgIdToRoleRows.keys()) { .from(roleClients)
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; .where(
const userOrg = roleRowsForOrg[0].userOrgs; and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const org = await getOrg(orgId); const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
if (!org) { return hasAccess;
logger.warn( };
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
if (!org.subnet) { const hasUserClientAccess = async (
logger.warn( cachedUserId: string,
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured` clientId: number
); ) => {
continue; const key = getUserClientKey(cachedUserId, clientId);
} if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[
Math.floor(Math.random() * exitNodesList.length)
];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, transaction);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await transaction
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
await transaction
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
} }
// Clean up clients in orgs the user is no longer in const [existingUserClient] = await trx
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await trx
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients( await cleanupOrphanedClients(
userId, userId,
transaction, trx,
userOrgIds, [],
queuedAssociationRebuilds queuedAssociationRebuilds
); );
}; return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await trx
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(orgId, olm.olmId);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!hasUserAccess) {
await trx.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await trx
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
// create approval request
if (requireApproval) {
await trx
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await trx.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(
userId,
trx,
userOrgIds,
queuedAssociationRebuilds
);
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds); runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
} }
@@ -496,7 +474,7 @@ async function cleanupOrphanedClients(
) )
.returning(); .returning();
// Queue deleted clients for post-transaction association cleanup. // Queue deleted clients for post-trx association cleanup.
for (const deletedClient of deletedClients) { for (const deletedClient of deletedClients) {
queuedAssociationRebuilds.push(deletedClient); queuedAssociationRebuilds.push(deletedClient);

83
server/lib/dbRetry.ts Normal file
View File

@@ -0,0 +1,83 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}

View File

@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error"; import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate"; import { sendTerminateClient } from "@server/routers/client/terminate";
import { usageService } from "./billing/usageService"; import { usageService } from "./billing/usageService";
import { FeatureId } from "./billing"; import { LimitId } from "./billing";
export type DeleteOrgByIdResult = { export type DeleteOrgByIdResult = {
deletedNewtIds: string[]; deletedNewtIds: string[];
@@ -140,7 +140,9 @@ export async function deleteOrgById(
.select({ count: count() }) .select({ count: count() })
.from(orgDomains) .from(orgDomains)
.where(eq(orgDomains.domainId, domainId)); .where(eq(orgDomains.domainId, domainId));
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); logger.info(
`Found ${orgCount.count} orgs using domain ${domainId}`
);
if (orgCount.count === 1) { if (orgCount.count === 1) {
domainIdsToDelete.push(domainId); domainIdsToDelete.push(domainId);
} }
@@ -152,7 +154,7 @@ export async function deleteOrgById(
.where(inArray(domains.domainId, domainIdsToDelete)); .where(inArray(domains.domainId, domainIdsToDelete));
} }
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await trx.delete(orgs).where(eq(orgs.orgId, orgId)); await trx.delete(orgs).where(eq(orgs.orgId, orgId));
@@ -199,22 +201,22 @@ export async function deleteOrgById(
if (org.billingOrgId) { if (org.billingOrgId) {
usageService.updateCount( usageService.updateCount(
org.billingOrgId, org.billingOrgId,
FeatureId.DOMAINS, LimitId.DOMAINS,
domainCount ?? 0 domainCount ?? 0
); );
usageService.updateCount( usageService.updateCount(
org.billingOrgId, org.billingOrgId,
FeatureId.SITES, LimitId.SITES,
siteCount ?? 0 siteCount ?? 0
); );
usageService.updateCount( usageService.updateCount(
org.billingOrgId, org.billingOrgId,
FeatureId.USERS, LimitId.USERS,
userCount ?? 0 userCount ?? 0
); );
usageService.updateCount( usageService.updateCount(
org.billingOrgId, org.billingOrgId,
FeatureId.REMOTE_EXIT_NODES, LimitId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0 remoteExitNodeCount ?? 0
); );
} }

View File

@@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes( export async function listExitNodes(
orgId: string, orgId: string,
filterOnline = false, filterOnline = false,
noCloud = false noCloud = false,
// Accepted for parity with the enterprise implementation (used there for
// site-label filtering of remote exit nodes). The OSS build has no remote
// exit nodes, so it is unused here.
siteId?: number
) { ) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote // TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db const allExitNodes = await db

View File

@@ -1,30 +1,55 @@
import { db, exitNodes } from "@server/db"; import { db, exitNodes, Transaction } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getNextAvailableSubnet(): Promise<string> { /**
// Get all existing subnets from routes table * Reserves the next available exit node subnet.
const existingAddresses = await db *
.select({ * Exit node subnets must never overlap with one another - regardless of
address: exitNodes.address * which org(s) they belong to - since HA exit nodes can end up routing for
}) * the same org. This acquires a lock that the caller MUST release (via the
.from(exitNodes); * returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
const addresses = existingAddresses.map((a) => a.address); * concurrent callers can race and pick the same subnet.
let subnet = findNextAvailableCidr( */
addresses, export async function getNextAvailableSubnet(
config.getRawConfig().gerbil.block_size, trx: Transaction | typeof db = db
config.getRawConfig().gerbil.subnet_group ): Promise<{ value: string; release: () => Promise<void> }> {
); const lockKey = "exit-node-subnet-allocation";
if (!subnet) { const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
throw new Error("No available subnets remaining in space"); if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey, acquired);
// replace the last octet with 1 try {
subnet = // Get all existing subnets from routes table
subnet.split(".").slice(0, 3).join(".") + const existingAddresses = await trx
".1" + .select({
"/" + address: exitNodes.address
subnet.split("/")[1]; })
return subnet; .from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }

View File

@@ -333,7 +333,7 @@ export async function getNextAvailableClientSubnet(
if (!acquired) { if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`); throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey); const release = () => lockManager.releaseLock(lockKey, acquired);
try { try {
const [org] = await transaction const [org] = await transaction
@@ -395,7 +395,7 @@ export async function getNextAvailableAliasAddress(
if (!acquired) { if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`); throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey); const release = () => lockManager.releaseLock(lockKey, acquired);
try { try {
const [org] = await trx const [org] = await trx
@@ -463,7 +463,7 @@ export async function getNextAvailableOrgSubnet(): Promise<{
if (!acquired) { if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`); throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
const release = () => lockManager.releaseLock(lockKey); const release = () => lockManager.releaseLock(lockKey, acquired);
try { try {
const existingAddresses = await db const existingAddresses = await db

View File

@@ -1,3 +1,5 @@
import { randomUUID } from "crypto";
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = { type LocalLockRecord = {
@@ -15,58 +17,60 @@ export class LockManager {
} }
} }
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/** /**
* Acquire a distributed lock using Redis SET with NX and PX options * Acquire a local in-process lock using an optimistic Map-based check.
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param ttlMs - Time to live in milliseconds * @param ttlMs - Time to live in milliseconds
* @returns Promise<boolean> - true if lock acquired, false otherwise * @returns Promise<string | null> - a token identifying this specific acquisition
* (truthy) on success, or null if the lock could not be acquired.
*/ */
async acquireLock( async acquireLock(
lockKey: string, lockKey: string,
ttlMs: number = 30000, ttlMs: number = 30000,
maxRetries: number = 3, maxRetries: number = 3,
retryDelayMs: number = 100 retryDelayMs: number = 100
): Promise<boolean> { ): Promise<string | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (!existing) { if (!existing) {
const token = `${instanceId}:${randomUUID()}`;
localLocks.set(lockKey, { localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(), owner: token,
expiresAt: Date.now() + ttlMs expiresAt: Date.now() + ttlMs
}); });
return true; return token;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
} }
// The lock is currently held -- possibly by a different, unrelated
// caller in this same process. We intentionally do NOT treat
// same-process holders as automatically reentrant here: two
// independent logical operations (e.g. two different API requests)
// running concurrently in the same process must not both believe
// they hold the lock, or their writes under it can interleave
// unguarded. Just retry with backoff like any other contended lock.
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt); const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
return false; return null;
} }
/** /**
* Release a lock using Lua script to ensure atomicity * Release a lock previously acquired via acquireLock/acquireLockWithRetry.
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param token - the exact token returned by the acquisition being released.
* Required so a caller whose TTL already expired can't delete a
* different, currently-active holder's lock.
*/ */
async releaseLock(lockKey: string): Promise<void> { async releaseLock(lockKey: string, token: string): Promise<void> {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) { if (existing && existing.owner === token) {
localLocks.delete(lockKey); localLocks.delete(lockKey);
} }
} }
@@ -100,23 +104,29 @@ export class LockManager {
const ttl = Math.max(0, existing.expiresAt - Date.now()); const ttl = Math.max(0, existing.expiresAt - Date.now());
return { return {
exists: true, exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(), ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl, ttl,
owner: existing.owner.split(":")[0] owner: existing.owner.split(":")[0]
}; };
} }
/** /**
* Extend the TTL of an existing lock owned by this worker * Extend the TTL of an existing lock, provided the token matches the
* acquisition currently holding it.
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param ttlMs - New TTL in milliseconds * @param ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @returns Promise<boolean> - true if extended successfully * @returns Promise<boolean> - true if extended successfully
*/ */
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> { async extendLock(
lockKey: string,
ttlMs: number,
token: string
): Promise<boolean> {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) { if (!existing || existing.owner !== token) {
return false; return false;
} }
@@ -131,14 +141,14 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds * @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts * @param maxRetries - Maximum number of retry attempts
* @param baseDelayMs - Base delay between retries in milliseconds * @param baseDelayMs - Base delay between retries in milliseconds
* @returns Promise<boolean> - true if lock acquired * @returns Promise<string | null> - token if acquired, null otherwise
*/ */
async acquireLockWithRetry( async acquireLockWithRetry(
lockKey: string, lockKey: string,
ttlMs: number = 30000, ttlMs: number = 30000,
maxRetries: number = 5, maxRetries: number = 5,
baseDelayMs: number = 100 baseDelayMs: number = 100
): Promise<boolean> { ): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock( const acquired = await this.acquireLock(
lockKey, lockKey,
@@ -148,7 +158,7 @@ export class LockManager {
); );
if (acquired) { if (acquired) {
return true; return acquired;
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
@@ -158,7 +168,7 @@ export class LockManager {
} }
} }
return false; return null;
} }
/** /**
@@ -173,16 +183,16 @@ export class LockManager {
fn: () => Promise<T>, fn: () => Promise<T>,
ttlMs: number = 30000 ttlMs: number = 30000
): Promise<T> { ): Promise<T> {
const acquired = await this.acquireLock(lockKey, ttlMs); const token = await this.acquireLock(lockKey, ttlMs);
if (!acquired) { if (!token) {
throw new Error(`Failed to acquire lock: ${lockKey}`); throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
try { try {
return await fn(); return await fn();
} finally { } finally {
await this.releaseLock(lockKey); await this.releaseLock(lockKey, token);
} }
} }
@@ -204,7 +214,7 @@ export class LockManager {
let locksOwnedByMe = 0; let locksOwnedByMe = 0;
for (const value of localLocks.values()) { for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) { if (value.owner.startsWith(`${instanceId}:`)) {
locksOwnedByMe++; locksOwnedByMe++;
} }
} }

View File

@@ -0,0 +1,24 @@
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
const orgActiveRebuilds = new Map<string, number>();
export async function incrementOrgRebuildCount(orgId: string): Promise<void> {
orgActiveRebuilds.set(orgId, (orgActiveRebuilds.get(orgId) ?? 0) + 1);
}
export async function decrementOrgRebuildCount(orgId: string): Promise<void> {
const current = orgActiveRebuilds.get(orgId) ?? 0;
if (current <= 1) {
orgActiveRebuilds.delete(orgId);
} else {
orgActiveRebuilds.set(orgId, current - 1);
}
}
export async function getOrgActiveRebuildCount(orgId: string): Promise<number> {
return orgActiveRebuilds.get(orgId) ?? 0;
}
export async function checkOrgRebuildRateLimit(orgId: string): Promise<boolean> {
return (orgActiveRebuilds.get(orgId) ?? 0) >= ORG_REBUILD_CONCURRENCY_LIMIT;
}

View File

@@ -45,11 +45,24 @@ import {
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
import { withRetry, isTransientError } from "@server/lib/dbRetry";
import {
checkOrgRebuildRateLimit,
decrementOrgRebuildCount,
incrementOrgRebuildCount,
ORG_REBUILD_CONCURRENCY_LIMIT
} from "#dynamic/lib/orgRebuildCounter";
export { ORG_REBUILD_CONCURRENCY_LIMIT };
// TTL for rebuild-association locks. These functions can fan out into many // TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window. // peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function isOrgRebuildRateLimited(orgId: string): Promise<boolean> {
return checkOrgRebuildRateLimit(orgId);
}
const REBUILD_IDLE_POLL_INTERVAL_MS = 300; const REBUILD_IDLE_POLL_INTERVAL_MS = 300;
const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL
const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000; const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000;
@@ -271,11 +284,22 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource( export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource siteResource: SiteResource
) { ) {
await incrementOrgRebuildCount(siteResource.orgId);
try { try {
return await lockManager.withLock( // The whole locked rebuild is idempotent (it diffs full expected vs.
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, // actual state each time), so on a transient DB error it's safe to
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource), // retry the entire thing rather than just the failed query.
REBUILD_ASSOCIATIONS_LOCK_TTL_MS return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() =>
rebuildClientAssociationsFromSiteResourceImpl(
siteResource
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -291,7 +315,20 @@ export async function rebuildClientAssociationsFromSiteResource(
}); });
return { mergedAllClients: [] }; return { mergedAllClients: [] };
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err; throw err;
} finally {
await decrementOrgRebuildCount(siteResource.orgId);
} }
} }
@@ -448,6 +485,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSiteResourcesAssociationsCache) .insert(clientSiteResourcesAssociationsCache)
.values(clientSiteResourcesToInsert) .values(clientSiteResourcesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
@@ -495,121 +533,148 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
for (const site of sitesToProcess) { for (const site of sitesToProcess) {
const siteId = site.siteId; const siteId = site.siteId;
logger.debug( try {
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` logger.debug(
); `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
);
const existingClientSites = await trx const existingClientSites = await trx
.select({ .select({
clientId: clientSitesAssociationsCache.clientId clientId: clientSitesAssociationsCache.clientId
}) })
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId)); .where(eq(clientSitesAssociationsCache.siteId, siteId));
const existingClientSiteIds = existingClientSites.map( const existingClientSiteIds = existingClientSites.map(
(row) => row.clientId (row) => row.clientId
); );
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
); );
// Get full client details for existing clients (needed for sending delete messages) // Get full client details for existing clients (needed for sending delete messages)
const existingClients = const existingClients =
existingClientSiteIds.length > 0 existingClientSiteIds.length > 0
? await trx ? await trx
.select({ .select({
clientId: clients.clientId, clientId: clients.clientId,
pubKey: clients.pubKey, pubKey: clients.pubKey,
subnet: clients.subnet subnet: clients.subnet
}) })
.from(clients) .from(clients)
.where(inArray(clients.clientId, existingClientSiteIds)) .where(
inArray(clients.clientId, existingClientSiteIds)
)
: [];
const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ??
new Set<number>();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
);
// Expected clients from this resource are site-scoped: if this site is
// no longer attached to the resource, the expected set is empty.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
? mergedAllClientIds
: []; : [];
const otherResourceClientIds = // Note: we deliberately do NOT exclude clients covered by another
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>(); // site resource here (unlike clientSitesToRemove below). Doing so
// previously caused a permanent gap: if resource A saw resource B's
logger.debug( // cache row and skipped adding (assuming B would maintain it), and
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` // B's own rebuild made the same assumption about A, the site-level
); // row could end up never inserted by anyone even though both
// resources' client associations were otherwise correct.
// Expected clients from this resource are site-scoped: if this site is // onConflictDoNothing makes a redundant insert harmless, so there's
// no longer attached to the resource, the expected set is empty. // no correctness reason to skip here.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId) const clientSitesToAdd = expectedClientIdsForSite.filter(
? mergedAllClientIds (clientId) => !existingClientSiteIds.includes(clientId)
: [];
const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) =>
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
);
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
clientId,
siteId
}));
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
);
if (clientSitesToInsert.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
); );
await trx
.insert(clientSitesAssociationsCache)
.values(clientSitesToInsert)
.returning();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
);
} else {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
);
}
// Now remove any client-site associations that should no longer exist const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
const clientSitesToRemove = existingClientSiteIds.filter( clientId,
(clientId) => siteId
!expectedClientIdsForSite.includes(clientId) && }));
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
);
if (clientSitesToRemove.length > 0) {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
); );
await trx
.delete(clientSitesAssociationsCache) if (clientSitesToInsert.length > 0) {
.where( logger.debug(
and( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
); );
} await trx
.insert(clientSitesAssociationsCache)
.values(clientSitesToInsert)
.onConflictDoNothing()
.returning();
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
);
} else {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides // Now remove any client-site associations that should no longer exist
await handleMessagesForSiteClients( const clientSitesToRemove = existingClientSiteIds.filter(
site, (clientId) =>
siteId, !expectedClientIdsForSite.includes(clientId) &&
mergedAllClients, !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
existingClients, );
clientSitesToAdd,
clientSitesToRemove, logger.debug(
trx `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
); );
if (clientSitesToRemove.length > 0) {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
);
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
} catch (err) {
// Don't let a failure on one site abort processing of every
// other site queued after it in this run. Since we're not
// re-throwing, the outer wrapper's retry/requeue logic never
// sees this failure, so explicitly queue this resource for a
// follow-up pass to reconcile whatever this site didn't get to.
logger.error(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
}
} }
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
@@ -642,7 +707,7 @@ async function handleMessagesForSiteClients(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.warn( logger.debug(
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
@@ -656,14 +721,14 @@ async function handleMessagesForSiteClients(
.limit(1); .limit(1);
if (!exitNode) { if (!exitNode) {
logger.warn( logger.debug(
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
} }
if (!site.publicKey) { if (!site.publicKey) {
logger.warn( logger.debug(
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients` `Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
); );
return; return;
@@ -677,7 +742,7 @@ async function handleMessagesForSiteClients(
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
logger.warn( logger.debug(
`Newt not found for site ${siteId} so cannot add peers to clients` `Newt not found for site ${siteId} so cannot add peers to clients`
); );
return; return;
@@ -902,7 +967,7 @@ export async function updateClientSiteDestinations(
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.subnet) {
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1638,12 +1703,20 @@ export async function handleMessagingForUpdatedSiteResource(
export async function rebuildClientAssociationsFromClient( export async function rebuildClientAssociationsFromClient(
client: Client client: Client
): Promise<void> { ): Promise<void> {
const trx = primaryDb; await incrementOrgRebuildCount(client.orgId);
try { try {
return await lockManager.withLock( const trx = primaryDb;
`rebuild-client-associations:client:${client.clientId}`, // The whole locked rebuild is idempotent (it diffs full expected vs.
() => rebuildClientAssociationsFromClientImpl(client, trx), // actual state each time), so on a transient DB error it's safe to
REBUILD_ASSOCIATIONS_LOCK_TTL_MS // retry the entire thing rather than just the failed query.
return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromClient:${client.clientId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -1659,7 +1732,20 @@ export async function rebuildClientAssociationsFromClient(
}); });
return; return;
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err; throw err;
} finally {
await decrementOrgRebuildCount(client.orgId);
} }
} }
@@ -1808,12 +1894,15 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new associations // Insert new associations
if (resourcesToAdd.length > 0) { if (resourcesToAdd.length > 0) {
await trx.insert(clientSiteResourcesAssociationsCache).values( await trx
resourcesToAdd.map((siteResourceId) => ({ .insert(clientSiteResourcesAssociationsCache)
clientId: client.clientId, .values(
siteResourceId resourcesToAdd.map((siteResourceId) => ({
})) clientId: client.clientId,
); siteResourceId
}))
)
.onConflictDoNothing();
} }
// Remove old associations // Remove old associations
@@ -1851,12 +1940,15 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new site associations // Insert new site associations
if (sitesToAdd.length > 0) { if (sitesToAdd.length > 0) {
await trx.insert(clientSitesAssociationsCache).values( await trx
sitesToAdd.map((siteId) => ({ .insert(clientSitesAssociationsCache)
clientId: client.clientId, .values(
siteId sitesToAdd.map((siteId) => ({
})) clientId: client.clientId,
); siteId
}))
)
.onConflictDoNothing();
} }
// Remove old site associations // Remove old site associations

View File

@@ -1,8 +1,14 @@
import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -16,12 +22,127 @@ export interface RebuildQueueManager {
isQueued(job: RebuildJob): Promise<boolean>; isQueued(job: RebuildJob): Promise<boolean>;
} }
class NoopRebuildQueue implements RebuildQueueManager { // In-process FIFO used when there is no Redis to back a distributed queue
async enqueue(_job: RebuildJob): Promise<void> {} // (OSS build, or Redis unavailable). A job that loses the per-resource
startProcessing(_handlers: RebuildJobHandlers): void {} // rebuild lock race lands here instead of being silently dropped, and gets
async isQueued(_job: RebuildJob): Promise<boolean> { // retried shortly after against fresh DB state.
return false; const POLL_INTERVAL_MS = 500;
const BATCH_SIZE = 5;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
function dedupeKey(job: RebuildJob): string {
return `${job.type}:${job.id}`;
}
class InMemoryRebuildQueue implements RebuildQueueManager {
private queue: RebuildJob[] = [];
private queuedSet = new Set<string>();
private processing = false;
private processingStarted = false;
private handlers: RebuildJobHandlers | null = null;
async isQueued(job: RebuildJob): Promise<boolean> {
return this.queuedSet.has(dedupeKey(job));
}
async enqueue(job: RebuildJob): Promise<void> {
const key = dedupeKey(job);
if (this.queuedSet.has(key)) {
logger.debug(
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
);
return;
}
this.queuedSet.add(key);
this.queue.push(job);
logger.debug(
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
);
}
startProcessing(handlers: RebuildJobHandlers): void {
if (this.processingStarted) return;
this.processingStarted = true;
this.handlers = handlers;
setInterval(() => {
this.tryProcessBatch().catch((err) => {
logger.error(
"Rebuild queue: unhandled error in process loop:",
err
);
});
}, POLL_INTERVAL_MS);
logger.info("Rebuild queue processor started (in-memory)");
}
private async tryProcessBatch(): Promise<void> {
if (this.processing || !this.handlers || this.queue.length === 0) {
return;
}
this.processing = true;
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const job = this.queue.shift();
if (!job) break; // queue drained
// Remove from the dedupe set once dequeued so the same job
// can be re-queued while this one is in progress.
this.queuedSet.delete(dedupeKey(job));
logger.debug(
`Rebuild queue: processing ${job.type}:${job.id}`
);
try {
if (job.type === "site-resource") {
await this.handlers.onSiteResource(job.id);
} else if (job.type === "client") {
await this.handlers.onClient(job.id);
} else {
logger.warn(
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
);
}
logger.debug(
`Rebuild queue: completed ${job.type}:${job.id}`
);
} catch (err) {
const attempt = (job.attempt ?? 0) + 1;
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
const delay =
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
}
}
} finally {
this.processing = false;
}
} }
} }
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); export const rebuildQueue: RebuildQueueManager = new InMemoryRebuildQueue();

View File

@@ -14,7 +14,7 @@ import {
} from "@server/db"; } from "@server/db";
import { eq, and, inArray, ne, exists } from "drizzle-orm"; import { eq, and, inArray, ne, exists } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
export async function assignUserToOrg( export async function assignUserToOrg(
org: Org, org: Org,
@@ -61,7 +61,7 @@ export async function assignUserToOrg(
); );
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, 1, trx); await usageService.add(org.orgId, LimitId.USERS, 1, trx);
} }
} }
} }
@@ -157,7 +157,7 @@ export async function removeUserFromOrg(
); );
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
await usageService.add(org.orgId, FeatureId.USERS, -1, trx); await usageService.add(org.orgId, LimitId.USERS, -1, trx);
} }
} }
} }

View File

@@ -18,12 +18,15 @@ import {
resources, resources,
targets, targets,
sites, sites,
siteLabels,
remoteExitNodes,
remoteExitNodePreferenceLabels,
targetHealthCheck, targetHealthCheck,
Transaction Transaction
} from "@server/db"; } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt"; import { ExitNodePingResult } from "@server/routers/newt";
import { eq, and, or, ne, isNull } from "drizzle-orm"; import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
import axios from "axios"; import axios from "axios";
import config from "../config"; import config from "../config";
@@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes( export async function listExitNodes(
orgId: string, orgId: string,
filterOnline = false, filterOnline = false,
noCloud = false noCloud = false,
siteId?: number
) { ) {
const allExitNodes = await db const allExitNodes = await db
.select({ .select({
@@ -237,7 +241,7 @@ export async function listExitNodes(
// }) // })
// ); // );
const remoteExitNodes = allExitNodes.filter( let remoteExitNodesList = allExitNodes.filter(
(node) => (node) =>
node.type === "remoteExitNode" && (!filterOnline || node.online) node.type === "remoteExitNode" && (!filterOnline || node.online)
); );
@@ -246,9 +250,82 @@ export async function listExitNodes(
node.type === "gerbil" && (!filterOnline || node.online) && !noCloud node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
); );
// Apply label-based filtering to remote exit nodes if siteId is provided
if (siteId !== undefined && remoteExitNodesList.length > 0) {
// Get the site's labels
const siteLabelRows = await db
.select({ labelId: siteLabels.labelId })
.from(siteLabels)
.where(eq(siteLabels.siteId, siteId));
const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId));
// Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId
const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId);
const remoteNodeRows = await db
.select({
exitNodeId: remoteExitNodes.exitNodeId,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
})
.from(remoteExitNodes)
.where(inArray(remoteExitNodes.exitNodeId, exitNodeIds));
const exitNodeIdToRemoteId = new Map(
remoteNodeRows
.filter((r) => r.exitNodeId !== null)
.map((r) => [r.exitNodeId!, r.remoteExitNodeId])
);
// Get preference labels for all remote exit nodes
const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId);
const prefLabelRows =
remoteExitNodeIds.length > 0
? await db
.select({
remoteExitNodeId:
remoteExitNodePreferenceLabels.remoteExitNodeId,
labelId: remoteExitNodePreferenceLabels.labelId
})
.from(remoteExitNodePreferenceLabels)
.where(
inArray(
remoteExitNodePreferenceLabels.remoteExitNodeId,
remoteExitNodeIds
)
)
: [];
// Build a map of remoteExitNodeId -> Set of labelIds
const prefLabelsMap = new Map<string, Set<number>>();
for (const row of prefLabelRows) {
if (!prefLabelsMap.has(row.remoteExitNodeId)) {
prefLabelsMap.set(row.remoteExitNodeId, new Set());
}
prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId);
}
// Filter: include node if it has no preference labels, or if site shares at least one label
const filtered = remoteExitNodesList.filter((node) => {
const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId);
if (!remoteId) return true; // no remoteExitNode record, don't filter
const prefLabels = prefLabelsMap.get(remoteId);
if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include
// include only if site has at least one matching label
for (const labelId of siteLabelIds) {
if (prefLabels.has(labelId)) return true;
}
return false;
});
// Only apply the filtered list if at least one remote node remains;
// otherwise fall through to the gerbil fallback below
if (filtered.length > 0 || remoteExitNodesList.length === 0) {
remoteExitNodesList = filtered;
}
}
// THIS PROVIDES THE FALL // THIS PROVIDES THE FALL
const exitNodesList = const exitNodesList =
remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes;
return exitNodesList; return exitNodesList;
} }

View File

@@ -32,55 +32,59 @@ export class LockManager {
} }
} }
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/** /**
* Acquire a distributed lock using Redis SET with NX and PX options * Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param ttlMs - Time to live in milliseconds * @param ttlMs - Time to live in milliseconds
* @returns Promise<boolean> - true if lock acquired, false otherwise * @returns Promise<string | null> - a token identifying this specific acquisition
* (truthy) on success, or null if the lock could not be acquired.
*/ */
async acquireLock( async acquireLock(
lockKey: string, lockKey: string,
ttlMs: number = 30000, ttlMs: number = 30000,
maxRetries: number = 3, maxRetries: number = 3,
retryDelayMs: number = 100 retryDelayMs: number = 100
): Promise<boolean> { ): Promise<string | null> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (!existing) { if (!existing) {
const token = `${instanceId}:${uuidv4()}`;
localLocks.set(lockKey, { localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(), owner: token,
expiresAt: Date.now() + ttlMs expiresAt: Date.now() + ttlMs
}); });
return true; return token;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
} }
// Do not treat a same-process holder as automatically
// reentrant -- see the note in the Redis branch below.
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt); const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} }
return false; return null;
} }
const lockValue = `${instanceId}:${Date.now()}`;
const redisKey = `lock:${lockKey}`; const redisKey = `lock:${lockKey}`;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
try { try {
// Every acquisition attempt gets its own unique token, even
// within the same process. Two independent logical operations
// (e.g. two different API requests handled by the same server)
// racing for this key must never both believe they hold the
// lock -- if we treated "existing value starts with my
// instanceId" as reentrant success, a second unrelated caller
// on this process could barge in while the first is still
// mid-flight, and their writes under the lock would interleave
// unguarded.
const lockValue = `${instanceId}:${uuidv4()}`;
// Use SET with NX (only set if not exists) and PX (expire in milliseconds) // Use SET with NX (only set if not exists) and PX (expire in milliseconds)
// This is atomic and handles both setting and expiration // This is atomic and handles both setting and expiration
const result = await redis.set( const result = await redis.set(
@@ -93,19 +97,7 @@ export class LockManager {
if (result === "OK") { if (result === "OK") {
logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`);
return true; return lockValue;
}
// Check if the existing lock is from this worker (reentrant behavior)
const existingValue = await redis.get(redisKey);
if (
existingValue &&
existingValue.startsWith(`${instanceId}:`)
) {
// Extend the lock TTL since it's the same worker
await redis.pexpire(redisKey, ttlMs);
logger.debug(`Lock extended: ${lockKey} by ${instanceId}`);
return true;
} }
// If this isn't our last attempt, wait before retrying with exponential backoff // If this isn't our last attempt, wait before retrying with exponential backoff
@@ -132,18 +124,23 @@ export class LockManager {
logger.debug( logger.debug(
`Failed to acquire lock ${lockKey} after ${maxRetries} attempts` `Failed to acquire lock ${lockKey} after ${maxRetries} attempts`
); );
return false; return null;
} }
/** /**
* Release a lock using Lua script to ensure atomicity * Release a lock previously acquired via acquireLock/acquireLockWithRetry,
* using a Lua script to ensure we only delete it if it still matches the
* exact token from that acquisition (not just "owned by this process") --
* this ensures a caller whose TTL already expired can't delete a
* different, currently-active holder's lock.
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param token - the exact token returned by the acquisition being released
*/ */
async releaseLock(lockKey: string): Promise<void> { async releaseLock(lockKey: string, token: string): Promise<void> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) { if (existing && existing.owner === token) {
localLocks.delete(lockKey); localLocks.delete(lockKey);
} }
return; return;
@@ -151,13 +148,12 @@ export class LockManager {
const redisKey = `lock:${lockKey}`; const redisKey = `lock:${lockKey}`;
// Lua script to ensure we only delete the lock if it belongs to this worker
const luaScript = ` const luaScript = `
local key = KEYS[1] local key = KEYS[1]
local worker_prefix = ARGV[1] local expected_value = ARGV[1]
local current_value = redis.call('GET', key) local current_value = redis.call('GET', key)
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then if current_value and current_value == expected_value then
return redis.call('DEL', key) return redis.call('DEL', key)
else else
return 0 return 0
@@ -169,16 +165,14 @@ export class LockManager {
luaScript, luaScript,
1, 1,
redisKey, redisKey,
`${instanceId}:` token
)) as number; )) as number;
if (result === 1) { if (result === 1) {
logger.debug(`Lock released: ${lockKey} by ${instanceId}`); logger.debug(`Lock released: ${lockKey} by ${instanceId}`);
} else { } else {
logger.warn( logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${ `Lock not released - token did not match current holder: ${lockKey} (attempted by ${instanceId})`
instanceId
}`
); );
} }
} catch (error) { } catch (error) {
@@ -230,7 +224,7 @@ export class LockManager {
const ttl = Math.max(0, existing.expiresAt - Date.now()); const ttl = Math.max(0, existing.expiresAt - Date.now());
return { return {
exists: true, exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(), ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl, ttl,
owner: existing.owner.split(":")[0] owner: existing.owner.split(":")[0]
}; };
@@ -261,17 +255,23 @@ export class LockManager {
} }
/** /**
* Extend the TTL of an existing lock owned by this worker * Extend the TTL of an existing lock, provided the token matches the
* acquisition currently holding it.
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
* @param ttlMs - New TTL in milliseconds * @param ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @returns Promise<boolean> - true if extended successfully * @returns Promise<boolean> - true if extended successfully
*/ */
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> { async extendLock(
lockKey: string,
ttlMs: number,
token: string
): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey); this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey); const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) { if (!existing || existing.owner !== token) {
return false; return false;
} }
@@ -282,14 +282,13 @@ export class LockManager {
const redisKey = `lock:${lockKey}`; const redisKey = `lock:${lockKey}`;
// Lua script to extend TTL only if lock is owned by this worker
const luaScript = ` const luaScript = `
local key = KEYS[1] local key = KEYS[1]
local worker_prefix = ARGV[1] local expected_value = ARGV[1]
local ttl = tonumber(ARGV[2]) local ttl = tonumber(ARGV[2])
local current_value = redis.call('GET', key) local current_value = redis.call('GET', key)
if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then if current_value and current_value == expected_value then
return redis.call('PEXPIRE', key, ttl) return redis.call('PEXPIRE', key, ttl)
else else
return 0 return 0
@@ -301,7 +300,7 @@ export class LockManager {
luaScript, luaScript,
1, 1,
redisKey, redisKey,
`${instanceId}:`, token,
ttlMs.toString() ttlMs.toString()
)) as number; )) as number;
@@ -324,14 +323,14 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds * @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts * @param maxRetries - Maximum number of retry attempts
* @param baseDelayMs - Base delay between retries in milliseconds * @param baseDelayMs - Base delay between retries in milliseconds
* @returns Promise<boolean> - true if lock acquired * @returns Promise<string | null> - token if acquired, null otherwise
*/ */
async acquireLockWithRetry( async acquireLockWithRetry(
lockKey: string, lockKey: string,
ttlMs: number = 30000, ttlMs: number = 30000,
maxRetries: number = 5, maxRetries: number = 5,
baseDelayMs: number = 100 baseDelayMs: number = 100
): Promise<boolean> { ): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock( const acquired = await this.acquireLock(
lockKey, lockKey,
@@ -341,7 +340,7 @@ export class LockManager {
); );
if (acquired) { if (acquired) {
return true; return acquired;
} }
if (attempt < maxRetries) { if (attempt < maxRetries) {
@@ -355,7 +354,7 @@ export class LockManager {
logger.warn( logger.warn(
`Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts` `Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts`
); );
return false; return null;
} }
/** /**
@@ -370,16 +369,16 @@ export class LockManager {
fn: () => Promise<T>, fn: () => Promise<T>,
ttlMs: number = 30000 ttlMs: number = 30000
): Promise<T> { ): Promise<T> {
const acquired = await this.acquireLock(lockKey, ttlMs); const token = await this.acquireLock(lockKey, ttlMs);
if (!acquired) { if (!token) {
throw new Error(`Failed to acquire lock: ${lockKey}`); throw new Error(`Failed to acquire lock: ${lockKey}`);
} }
try { try {
return await fn(); return await fn();
} finally { } finally {
await this.releaseLock(lockKey); await this.releaseLock(lockKey, token);
} }
} }
@@ -402,7 +401,7 @@ export class LockManager {
let locksOwnedByMe = 0; let locksOwnedByMe = 0;
for (const value of localLocks.values()) { for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) { if (value.owner.startsWith(`${instanceId}:`)) {
locksOwnedByMe++; locksOwnedByMe++;
} }
} }

View File

@@ -0,0 +1,105 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { redis } from "#private/lib/redis";
import logger from "@server/logger";
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
// server process dies while holding a rebuild, this ensures the counter key
// eventually expires rather than staying inflated forever.
const ORG_REBUILD_COUNT_TTL_MS = 180000;
const KEY_PREFIX = "rebuild-org-count:";
// In-memory fallback used when Redis is unavailable.
const localFallback = new Map<string, number>();
function isRedisReady(): boolean {
return !!(redis && redis.status === "ready");
}
export async function incrementOrgRebuildCount(orgId: string): Promise<void> {
if (!isRedisReady()) {
localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1);
return;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
await redis!.incr(key);
// Always refresh the TTL so the key doesn't expire while rebuilds are
// still in progress. The TTL is purely a crash-recovery safety net.
await redis!.pexpire(key, ORG_REBUILD_COUNT_TTL_MS);
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis increment failed for org ${orgId}, falling back to local:`,
err
);
localFallback.set(orgId, (localFallback.get(orgId) ?? 0) + 1);
}
}
export async function decrementOrgRebuildCount(orgId: string): Promise<void> {
if (!isRedisReady()) {
const current = localFallback.get(orgId) ?? 0;
if (current <= 1) {
localFallback.delete(orgId);
} else {
localFallback.set(orgId, current - 1);
}
return;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
const count = await redis!.decr(key);
if (count <= 0) {
await redis!.del(key);
}
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis decrement failed for org ${orgId}, falling back to local:`,
err
);
const current = localFallback.get(orgId) ?? 0;
if (current <= 1) {
localFallback.delete(orgId);
} else {
localFallback.set(orgId, current - 1);
}
}
}
export async function getOrgActiveRebuildCount(orgId: string): Promise<number> {
if (!isRedisReady()) {
return localFallback.get(orgId) ?? 0;
}
try {
const key = `${KEY_PREFIX}${orgId}`;
const val = await redis!.get(key);
return val ? parseInt(val, 10) : 0;
} catch (err) {
logger.warn(
`orgRebuildCounter: Redis get failed for org ${orgId}, falling back to local:`,
err
);
return localFallback.get(orgId) ?? 0;
}
}
export async function checkOrgRebuildRateLimit(
orgId: string
): Promise<boolean> {
return (
(await getOrgActiveRebuildCount(orgId)) >= ORG_REBUILD_CONCURRENCY_LIMIT
);
}

View File

@@ -12,7 +12,7 @@
*/ */
import logger from "@server/logger"; import logger from "@server/logger";
import redisManager from "#private/lib/redis"; import { regionalRedisManager as redisManager } from "#private/lib/redis";
import { build } from "@server/build"; import { build } from "@server/build";
// Rate limiting configuration // Rate limiting configuration
@@ -152,10 +152,9 @@ export class RateLimitService {
); );
// Set TTL using the client directly - this prevents the key from persisting forever // Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) { const writeClient = redisManager.getClient();
await redisManager if (writeClient) {
.getClient() await writeClient.expire(globalKey, RATE_LIMIT_WINDOW + 10);
.expire(globalKey, RATE_LIMIT_WINDOW + 10);
} }
// Update tracking // Update tracking
@@ -204,10 +203,12 @@ export class RateLimitService {
); );
// Set TTL using the client directly - this prevents the key from persisting forever // Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) { const writeClient = redisManager.getClient();
await redisManager if (writeClient) {
.getClient() await writeClient.expire(
.expire(messageTypeKey, RATE_LIMIT_WINDOW + 10); messageTypeKey,
RATE_LIMIT_WINDOW + 10
);
} }
// Update tracking // Update tracking
@@ -487,16 +488,13 @@ export class RateLimitService {
await redisManager.del(globalKey); await redisManager.del(globalKey);
// Get all message type keys for this client and delete them // Get all message type keys for this client and delete them
const client = redisManager.getClient(); const messageTypeKeys = await redisManager.keys(
if (client) { `ratelimit:${clientId}:*`
const messageTypeKeys = await client.keys( );
`ratelimit:${clientId}:*` if (messageTypeKeys.length > 0) {
await Promise.all(
messageTypeKeys.map((key) => redisManager.del(key))
); );
if (messageTypeKeys.length > 0) {
await Promise.all(
messageTypeKeys.map((key) => redisManager.del(key))
);
}
} }
} }
} }

View File

@@ -14,12 +14,16 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import { lockManager } from "#private/lib/lock"; import { lockManager } from "#private/lib/lock";
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -43,6 +47,11 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
class RedisRebuildQueue { class RedisRebuildQueue {
private processingStarted = false; private processingStarted = false;
@@ -180,10 +189,33 @@ class RedisRebuildQueue {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
logger.error( const attempt = (job.attempt ?? 0) + 1;
`Rebuild queue: job ${job.type}:${job.id} threw an error:`, if (
err isTransientError(err) &&
); attempt <= MAX_JOB_ATTEMPTS
) {
const delay =
JOB_RETRY_BASE_DELAY_MS *
Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
} }
} }
}, },

View File

@@ -894,6 +894,19 @@ class RegionalRedisManager {
return opts; return opts;
} }
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
// (primary). The replica (redis-1) is only reachable through the
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
// if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
if (!match) return null;
const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
}
private initializeClients(): void { private initializeClients(): void {
const cfg = this.getConfig(); const cfg = this.getConfig();
const baseOpts = { const baseOpts = {
@@ -907,35 +920,42 @@ class RegionalRedisManager {
try { try {
this.writeClient = new Redis(baseOpts); this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary const replicaHost = this.getReplicaHost(cfg.host!);
// The real replica routing is handled by the StatefulSet headless service this.readClient = replicaHost
this.readClient = this.writeClient; ? new Redis({ ...baseOpts, host: replicaHost })
: this.writeClient;
this.writeClient.on("ready", () => { this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready"); logger.info("Regional Redis write client ready");
this.isHealthy = true; this.isHealthy = true;
}); });
this.writeClient.on("error", (err) => { this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err); logger.error("Regional Redis write client error:", err);
this.isHealthy = false; this.isHealthy = false;
}); });
this.writeClient.on("reconnecting", () => { this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting..."); logger.info("Regional Redis write client reconnecting...");
this.isHealthy = false; this.isHealthy = false;
}); });
logger.info("Regional Redis client initialized"); if (this.readClient !== this.writeClient) {
this.readClient.on("ready", () => {
logger.info("Regional Redis read client ready");
});
this.readClient.on("error", (err) => {
logger.error("Regional Redis read client error:", err);
});
this.readClient.on("reconnecting", () => {
logger.info("Regional Redis read client reconnecting...");
});
}
logger.info(
replicaHost
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
);
} catch (error) { } catch (error) {
logger.error("Failed to initialize regional Redis client:", error); logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false; this.isEnabled = false;
@@ -1000,13 +1020,55 @@ class RegionalRedisManager {
} }
} }
public getClient(): Redis | null {
return this.writeClient;
}
public async hget(key: string, field: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.hget(key, field);
} catch (error) {
logger.error("Regional Redis HGET error:", error);
return null;
}
}
public async hset(
key: string,
field: string,
value: string
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.hset(key, field, value);
return true;
} catch (error) {
logger.error("Regional Redis HSET error:", error);
return false;
}
}
public async hgetall(key: string): Promise<Record<string, string>> {
if (!this.isRedisEnabled() || !this.readClient) return {};
try {
return await this.readClient.hgetall(key);
} catch (error) {
logger.error("Regional Redis HGETALL error:", error);
return {};
}
}
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
try { try {
if (this.readClient && this.readClient !== this.writeClient) {
await this.readClient.quit();
}
this.readClient = null;
if (this.writeClient) { if (this.writeClient) {
await this.writeClient.quit(); await this.writeClient.quit();
this.writeClient = null; this.writeClient = null;
} }
this.readClient = null;
logger.info("Regional Redis client disconnected"); logger.info("Regional Redis client disconnected");
} catch (error) { } catch (error) {
logger.error("Error disconnecting regional Redis client:", error); logger.error("Error disconnecting regional Redis client:", error);

View File

@@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog";
export * from "./exportAccessAuditLog"; export * from "./exportAccessAuditLog";
export * from "./queryConnectionAuditLog"; export * from "./queryConnectionAuditLog";
export * from "./exportConnectionAuditLog"; export * from "./exportConnectionAuditLog";
export * from "./logAccessAuditAttempt";

View File

@@ -0,0 +1,95 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction } from "express";
import { Request, Response } from "express";
import { z } from "zod";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error";
import response from "@server/lib/response";
import logger from "@server/logger";
import { logAccessAudit } from "#private/lib/logAccessAudit";
export const logAccessAuditAttemptSchema = z.object({
resourceId: z.number().int().positive(),
action: z.boolean(),
type: z.enum(["login", "ssh", "vnc", "rdp"])
});
export const logAccessAuditAttemptParams = z.object({
orgId: z.string()
});
export async function logAccessAuditAttempt(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = logAccessAuditAttemptSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error)
)
);
}
const parsedParams = logAccessAuditAttemptParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
const { resourceId, action, type } = parsedBody.data;
const username = req.user?.username;
const userId = req.user?.userId;
await logAccessAudit({
orgId: orgId,
resourceId: resourceId,
action: action,
...(username && userId
? {
user: {
username,
userId
}
}
: {}),
type: type,
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Access audit attempt logged successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -22,7 +22,7 @@ import {
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -120,7 +120,10 @@ function getWhere(data: Q) {
lt(accessAuditLog.timestamp, data.timeEnd), lt(accessAuditLog.timestamp, data.timeEnd),
eq(accessAuditLog.orgId, data.orgId), eq(accessAuditLog.orgId, data.orgId),
data.resourceId data.resourceId
? eq(accessAuditLog.resourceId, data.resourceId) ? or(
eq(accessAuditLog.resourceId, data.resourceId),
eq(accessAuditLog.siteResourceId, data.resourceId)
)
: undefined, : undefined,
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
data.actorType data.actorType
@@ -233,7 +236,6 @@ async function enrichWithResourceDetails(
const details = siteResourceMap.get(log.siteResourceId); const details = siteResourceMap.get(log.siteResourceId);
return { return {
...log, ...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null, resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null resourceNiceId: details?.niceId ?? null
}; };

View File

@@ -25,7 +25,7 @@ import {
getTier1FeaturePriceSet, getTier1FeaturePriceSet,
getTier3FeaturePriceSet, getTier3FeaturePriceSet,
getTier2FeaturePriceSet, getTier2FeaturePriceSet,
FeatureId, LimitId,
type FeaturePriceSet type FeaturePriceSet
} from "@server/lib/billing"; } from "@server/lib/billing";
import { getLineItems } from "@server/lib/billing/getLineItems"; import { getLineItems } from "@server/lib/billing/getLineItems";
@@ -214,7 +214,7 @@ export async function changeTier(
} }
// Map to the corresponding feature in the new tier // Map to the corresponding feature in the new tier
const newPriceId = targetPriceSet[FeatureId.USERS]; const newPriceId = targetPriceSet[LimitId.USERS];
if (newPriceId) { if (newPriceId) {
return { return {

View File

@@ -24,7 +24,7 @@ import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { Limit, limits, Usage, usage } from "@server/db"; import { Limit, limits, Usage, usage } from "@server/db";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
import { GetOrgUsageResponse } from "@server/routers/billing/types"; import { GetOrgUsageResponse } from "@server/routers/billing/types";
const getOrgSchema = z.strictObject({ const getOrgSchema = z.strictObject({
@@ -93,16 +93,28 @@ export async function getOrgUsage(
// Get usage for org // Get usage for org
const usageData = []; const usageData = [];
const sites = await usageService.getUsage(orgId, FeatureId.SITES); const sites = await usageService.getUsage(orgId, LimitId.SITES);
const users = await usageService.getUsage(orgId, FeatureId.USERS); const users = await usageService.getUsage(orgId, LimitId.USERS);
const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); const domains = await usageService.getUsage(orgId, LimitId.DOMAINS);
const remoteExitNodes = await usageService.getUsage( const remoteExitNodes = await usageService.getUsage(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES LimitId.REMOTE_EXIT_NODES
); );
const organizations = await usageService.getUsage( const organizations = await usageService.getUsage(
orgId, orgId,
FeatureId.ORGINIZATIONS LimitId.ORGANIZATIONS
);
const publicResources = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
const privateResources = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
const machineClients = await usageService.getUsage(
orgId,
LimitId.MACHINE_CLIENTS
); );
// const egressData = await usageService.getUsage( // const egressData = await usageService.getUsage(
// orgId, // orgId,
@@ -127,6 +139,15 @@ export async function getOrgUsage(
if (organizations) { if (organizations) {
usageData.push(organizations); usageData.push(organizations);
} }
if (publicResources) {
usageData.push(publicResources);
}
if (privateResources) {
usageData.push(privateResources);
}
if (machineClients) {
usageData.push(machineClients);
}
const orgLimits = await db const orgLimits = await db
.select() .select()

View File

@@ -329,6 +329,44 @@ authenticated.delete(
remoteExitNode.deleteRemoteExitNode remoteExitNode.deleteRemoteExitNode
); );
authenticated.get(
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
remoteExitNode.listRemoteExitNodeResources
);
authenticated.post(
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
logActionAudit(ActionsEnum.updateRemoteExitNode),
remoteExitNode.setRemoteExitNodeResources
);
authenticated.get(
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
remoteExitNode.listRemoteExitNodePreferenceLabels
);
authenticated.post(
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
verifyValidLicense,
verifyOrgAccess,
verifyRemoteExitNodeAccess,
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
logActionAudit(ActionsEnum.updateRemoteExitNode),
remoteExitNode.setRemoteExitNodePreferenceLabels
);
authenticated.put( authenticated.put(
"/org/:orgId/login-page", "/org/:orgId/login-page",
verifyValidLicense, verifyValidLicense,
@@ -495,29 +533,31 @@ authRouter.post(
auth.transferSession auth.transferSession
); );
authenticated.post( if (build !== "saas") {
"/license/activate", authenticated.post(
verifyUserIsServerAdmin, "/license/activate",
license.activateLicense verifyUserIsServerAdmin,
); license.activateLicense
);
authenticated.get( authenticated.get(
"/license/keys", "/license/keys",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
license.listLicenseKeys license.listLicenseKeys
); );
authenticated.delete( authenticated.delete(
"/license/:licenseKey", "/license/:licenseKey",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
license.deleteLicenseKey license.deleteLicenseKey
); );
authenticated.post( authenticated.post(
"/license/recheck", "/license/recheck",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
license.recheckStatus license.recheckStatus
); );
}
authenticated.get( authenticated.get(
"/org/:orgId/logs/action", "/org/:orgId/logs/action",
@@ -878,3 +918,9 @@ authenticated.post(
verifyClientAccess, verifyClientAccess,
client.rebuildClientAssociationsCacheRoute client.rebuildClientAssociationsCacheRoute
); );
authenticated.post(
"/org/:orgId/logs/access/attempt",
verifyOrgAccess,
logs.logAccessAuditAttempt
);

View File

@@ -29,37 +29,41 @@ export async function createExitNode(
.where(eq(exitNodes.publicKey, publicKey)); .where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes try {
// const listenPort = await getNextAvailablePort(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
const listenPort = config.getRawConfig().gerbil.start_port; // const listenPort = await getNextAvailablePort();
let subEndpoint = ""; const listenPort = config.getRawConfig().gerbil.start_port;
if (config.getRawConfig().gerbil.use_subdomain) { let subEndpoint = "";
subEndpoint = await getUniqueExitNodeEndpointName(); if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
listenPort,
online: true,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else { } else {
// update the reachable at // update the reachable at
[exitNode] = await db [exitNode] = await db

View File

@@ -215,7 +215,7 @@ export async function sendTrialNotification(
if (resetLimits) { if (resetLimits) {
// this will only fire if they have not upgraded yet because when upgrading we delete the trial // this will only fire if they have not upgraded yet because when upgrading we delete the trial
await handleSubscriptionLifesycle(orgId, "cancled"); await handleSubscriptionLifesycle(orgId, "canceled");
logger.debug( logger.debug(
`Trial ended for org ${orgId}, limits reset to free tier` `Trial ended for org ${orgId}, limits reset to free tier`
); );

View File

@@ -23,6 +23,7 @@ import { and, eq, sql } from "drizzle-orm";
import { removeUserFromOrg } from "@server/lib/userOrg"; import { removeUserFromOrg } from "@server/lib/userOrg";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -90,6 +91,15 @@ export async function unassociateOrgIdp(
); );
} }
if (await isOrgRebuildRateLimited(org.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const orgUsersFromIdp = await db const orgUsersFromIdp = await db
.select({ .select({
userId: userOrgs.userId, userId: userOrgs.userId,

View File

@@ -35,7 +35,7 @@ import logger from "@server/logger";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import { getNextAvailableSubnet } from "@server/lib/exitNodes";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types";
export const paramsSchema = z.object({ export const paramsSchema = z.object({
@@ -79,7 +79,10 @@ export async function createRemoteExitNode(
const { remoteExitNodeId, secret } = parsedBody.data; const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { if (
req.user &&
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -87,13 +90,13 @@ export async function createRemoteExitNode(
const usage = await usageService.getUsage( const usage = await usageService.getUsage(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES LimitId.REMOTE_EXIT_NODES
); );
if (usage) { if (usage) {
const rejectRemoteExitNodes = await usageService.checkLimitSet( const rejectRemoteExitNodes = await usageService.checkLimitSet(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, LimitId.REMOTE_EXIT_NODES,
{ {
...usage, ...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1 instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -111,8 +114,6 @@ export async function createRemoteExitNode(
} }
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -188,89 +189,106 @@ export async function createRemoteExitNode(
); );
} }
await db.transaction(async (trx) => { // If this remote exit node isn't already backing an exit node in
if (!existingExitNode) { // another org, we're about to create a brand new one. Reserve a
const [res] = await trx // subnet for it up front so the allocation lock is held across the
.insert(exitNodes) // whole insert - this guarantees exit node subnets never overlap,
.values({ // even under concurrent creation, which matters for HA setups.
name: remoteExitNodeId, let releaseSubnetLock: (() => Promise<void>) | null = null;
address, let newExitNodeAddress: string | null = null;
endpoint: "", if (!existingExitNode) {
publicKey: "", const { value, release } = await getNextAvailableSubnet();
listenPort: 0, newExitNodeAddress = value;
online: false, releaseSubnetLock = release;
type: "remoteExitNode" }
})
.returning();
existingExitNode = res;
}
if (!existingRemoteExitNode) { try {
await trx.insert(remoteExitNodes).values({ await db.transaction(async (trx) => {
remoteExitNodeId: remoteExitNodeId, if (!existingExitNode) {
secretHash, const [res] = await trx
dateCreated: moment().toISOString(), .insert(exitNodes)
exitNodeId: existingExitNode.exitNodeId .values({
}); name: remoteExitNodeId,
} else { address: newExitNodeAddress!,
// update the existing remote exit node endpoint: "",
await trx publicKey: "",
.update(remoteExitNodes) listenPort: 0,
.set({ online: false,
exitNodeId: existingExitNode.exitNodeId type: "remoteExitNode"
}) })
.where( .returning();
eq( existingExitNode = res;
remoteExitNodes.remoteExitNodeId,
existingRemoteExitNode.remoteExitNodeId
)
);
}
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
FeatureId.REMOTE_EXIT_NODES,
1,
trx
);
} }
}
}); if (!existingRemoteExitNode) {
await trx.insert(remoteExitNodes).values({
remoteExitNodeId: remoteExitNodeId,
secretHash,
dateCreated: moment().toISOString(),
exitNodeId: existingExitNode.exitNodeId
});
} else {
// update the existing remote exit node
await trx
.update(remoteExitNodes)
.set({
exitNodeId: existingExitNode.exitNodeId
})
.where(
eq(
remoteExitNodes.remoteExitNodeId,
existingRemoteExitNode.remoteExitNodeId
)
);
}
if (!existingExitNodeOrg) {
await trx.insert(exitNodeOrgs).values({
exitNodeId: existingExitNode.exitNodeId,
orgId: orgId
});
}
// calculate if the node is in any other of the orgs before we count it as an add to the billing org
if (org.billingOrgId) {
const otherBillingOrgs = await trx
.select()
.from(orgs)
.where(
and(
eq(orgs.billingOrgId, org.billingOrgId),
ne(orgs.orgId, orgId)
)
);
const billingOrgIds = otherBillingOrgs.map((o) => o.orgId);
const orgsInBillingDomainThatTheNodeIsStillIn = await trx
.select()
.from(exitNodeOrgs)
.where(
and(
eq(
exitNodeOrgs.exitNodeId,
existingExitNode.exitNodeId
),
inArray(exitNodeOrgs.orgId, billingOrgIds)
)
);
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add(
orgId,
LimitId.REMOTE_EXIT_NODES,
1,
trx
);
}
}
});
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);

View File

@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().min(1), orgId: z.string().min(1),
@@ -117,7 +117,7 @@ export async function deleteRemoteExitNode(
if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) {
await usageService.add( await usageService.add(
orgId, orgId,
FeatureId.REMOTE_EXIT_NODES, LimitId.REMOTE_EXIT_NODES,
-1, -1,
trx trx
); );

View File

@@ -23,3 +23,7 @@ export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode"; export * from "./quickStartRemoteExitNode";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./exitNodeReconnectScheduler"; export * from "./exitNodeReconnectScheduler";
export * from "./listRemoteExitNodeResources";
export * from "./setRemoteExitNodeResources";
export * from "./listRemoteExitNodePreferenceLabels";
export * from "./setRemoteExitNodePreferenceLabels";

View File

@@ -0,0 +1,102 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import {
db,
labels,
remoteExitNodePreferenceLabels,
remoteExitNodes
} from "@server/db";
import { eq } from "drizzle-orm";
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 { ListRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode";
const paramsSchema = z.strictObject({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
});
export async function listRemoteExitNodePreferenceLabels(
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()
)
);
}
const { remoteExitNodeId } = parsedParams.data;
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
const rows = await db
.select({
remoteExitNodePreferenceLabelId:
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
labelId: remoteExitNodePreferenceLabels.labelId,
name: labels.name,
color: labels.color
})
.from(remoteExitNodePreferenceLabels)
.innerJoin(
labels,
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
)
.where(
eq(
remoteExitNodePreferenceLabels.remoteExitNodeId,
remoteExitNodeId
)
);
return response<ListRemoteExitNodePreferenceLabelsResponse>(res, {
data: { labels: rows },
success: true,
error: false,
message:
"Remote exit node preference labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,83 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db";
import { eq } from "drizzle-orm";
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 { ListRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode/types";
const paramsSchema = z.strictObject({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
});
export async function listRemoteExitNodeResources(
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()
)
);
}
const { remoteExitNodeId } = parsedParams.data;
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
const resources = await db
.select()
.from(remoteExitNodeResources)
.where(
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
);
return response<ListRemoteExitNodeResourcesResponse>(res, {
data: { resources },
success: true,
error: false,
message: "Remote exit node resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,160 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import {
db,
labels,
remoteExitNodePreferenceLabels,
remoteExitNodes
} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
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 { SetRemoteExitNodePreferenceLabelsResponse } from "@server/routers/remoteExitNode";
const paramsSchema = z.strictObject({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
});
const bodySchema = z.strictObject({
labelIds: z.array(z.number().int().positive())
});
export type SetRemoteExitNodePreferenceLabelsBody = z.infer<typeof bodySchema>;
export async function setRemoteExitNodePreferenceLabels(
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()
)
);
}
const { orgId, remoteExitNodeId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { labelIds } = parsedBody.data;
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
// Validate all provided labelIds belong to this org
if (labelIds.length > 0) {
const existingLabels = await db
.select({ labelId: labels.labelId })
.from(labels)
.where(
and(
eq(labels.orgId, orgId),
inArray(labels.labelId, labelIds)
)
);
if (existingLabels.length !== labelIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"One or more label IDs are invalid or do not belong to this organization"
)
);
}
}
// Replace all preference labels atomically
await db
.delete(remoteExitNodePreferenceLabels)
.where(
eq(
remoteExitNodePreferenceLabels.remoteExitNodeId,
remoteExitNodeId
)
);
if (labelIds.length > 0) {
await db.insert(remoteExitNodePreferenceLabels).values(
labelIds.map((labelId) => ({
remoteExitNodeId,
labelId
}))
);
}
const rows = await db
.select({
remoteExitNodePreferenceLabelId:
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
labelId: remoteExitNodePreferenceLabels.labelId,
name: labels.name,
color: labels.color
})
.from(remoteExitNodePreferenceLabels)
.innerJoin(
labels,
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
)
.where(
eq(
remoteExitNodePreferenceLabels.remoteExitNodeId,
remoteExitNodeId
)
);
return response<SetRemoteExitNodePreferenceLabelsResponse>(res, {
data: { labels: rows },
success: true,
error: false,
message: "Remote exit node preference labels updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,153 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { NextFunction, Request, Response } from "express";
import { z } from "zod";
import {
db,
newts,
remoteExitNodeResources,
remoteExitNodes,
sites
} from "@server/db";
import { eq } from "drizzle-orm";
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 { sendToClientsBatch } from "#private/routers/ws";
import { canCompress } from "@server/lib/clientVersionChecks";
import { SetRemoteExitNodeResourcesResponse } from "@server/routers/remoteExitNode";
const paramsSchema = z.strictObject({
orgId: z.string().min(1),
remoteExitNodeId: z.string().min(1)
});
const cidrRegex =
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
const bodySchema = z.strictObject({
destinations: z.array(
z.string().regex(cidrRegex, "Must be a valid CIDR range")
)
});
export type SetRemoteExitNodeResourcesBody = z.infer<typeof bodySchema>;
export async function setRemoteExitNodeResources(
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()
)
);
}
const { remoteExitNodeId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { destinations } = parsedBody.data;
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
.limit(1);
if (!remoteExitNode) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Remote exit node with ID ${remoteExitNodeId} not found`
)
);
}
// Replace all resources atomically
await db
.delete(remoteExitNodeResources)
.where(
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
);
if (destinations.length > 0) {
await db.insert(remoteExitNodeResources).values(
destinations.map((destination) => ({
remoteExitNodeId,
destination
}))
);
}
const resources = await db
.select()
.from(remoteExitNodeResources)
.where(
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
);
// Notify all newts connected to this remote exit node's exit node
if (remoteExitNode.exitNodeId) {
const connectedNewts = await db
.select({ newtId: newts.newtId, version: newts.version })
.from(newts)
.innerJoin(sites, eq(newts.siteId, sites.siteId))
.where(eq(sites.exitNodeId, remoteExitNode.exitNodeId));
await sendToClientsBatch(
connectedNewts.map(({ newtId, version }) => ({
clientId: newtId,
message: {
type: "newt/wg/subnets/update",
data: { subnets: destinations }
},
options: {
incrementConfigVersion: true,
compress: canCompress(version, "newt")
}
}))
);
}
return response<SetRemoteExitNodeResourcesResponse>(res, {
data: { resources },
success: true,
error: false,
message: "Remote exit node resources updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -23,7 +23,10 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addUserRoleParamsSchema = z.strictObject({ const addUserRoleParamsSchema = z.strictObject({
userId: z.string(), userId: z.string(),
@@ -128,6 +131,15 @@ export async function addUserRole(
); );
} }
if (await isOrgRebuildRateLimited(role.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
let newUserRole: { let newUserRole: {
userId: string; userId: string;
orgId: string; orgId: string;

View File

@@ -21,7 +21,10 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const setUserOrgRolesParamsSchema = z.strictObject({ const setUserOrgRolesParamsSchema = z.strictObject({
orgId: z.string(), orgId: z.string(),
@@ -87,6 +90,15 @@ export async function setUserOrgRoles(
); );
} }
if (await isOrgRebuildRateLimited(orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const orgRoles = await db const orgRoles = await db
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin }) .select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles) .from(roles)

View File

@@ -26,7 +26,7 @@ import {
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
import { recordPing } from "@server/routers/newt/pingAccumulator"; import { recordSitePing } from "@server/routers/newt/pingAccumulator";
import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateNewtSessionToken } from "@server/auth/sessions/newt";
import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { validateOlmSessionToken } from "@server/auth/sessions/olm";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -1063,7 +1063,7 @@ const setupConnection = async (
// pending pings in a single batched UPDATE every ~10s, which // pending pings in a single batched UPDATE every ~10s, which
// prevents connection pool exhaustion under load (especially // prevents connection pool exhaustion under load (especially
// with cross-region latency to the database). // with cross-region latency to the database).
recordPing(newtClient.siteId); recordSitePing(newtClient.siteId);
}); });
} }

View File

@@ -68,6 +68,7 @@ export type QueryAccessAuditLogResponse = {
actorType: string | null; actorType: string | null;
actorId: string | null; actorId: string | null;
resourceId: number | null; resourceId: number | null;
siteResourceId: number | null;
resourceName: string | null; resourceName: string | null;
resourceNiceId: string | null; resourceNiceId: string | null;
ip: string | null; ip: string | null;

View File

@@ -20,7 +20,7 @@ import { getOrgTierData } from "#dynamic/lib/billing";
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
const deleteMyAccountBody = z.strictObject({ const deleteMyAccountBody = z.strictObject({
password: z.string().optional(), password: z.string().optional(),
@@ -220,7 +220,7 @@ export async function deleteMyAccount(
await trx.delete(users).where(eq(users.userId, userId)); await trx.delete(users).where(eq(users.userId, userId));
// loop through the other orgs and decrement the count // loop through the other orgs and decrement the count
for (const userOrg of otherOrgsTheUserWasIn) { for (const userOrg of otherOrgsTheUserWasIn) {
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx); await usageService.add(userOrg.orgId, LimitId.USERS, -1, trx);
} }
}); });

View File

@@ -24,9 +24,14 @@ import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes"; import { listExitNodes } from "#dynamic/lib/exitNodes";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names"; import { getUniqueClientName } from "@server/db/names";
import { build } from "@server/build"; import { build } from "@server/build";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const createClientParamsSchema = z.strictObject({ const createClientParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -125,6 +130,38 @@ export async function createClient(
); );
} }
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.MACHINE_CLIENTS
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
const rejectClient = await usageService.checkLimitSet(
orgId,
LimitId.MACHINE_CLIENTS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectClient) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Machine client limit exceeded. Please upgrade your plan."
)
);
}
}
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) { if (!org) {
@@ -154,6 +191,15 @@ export async function createClient(
); );
} }
if (await isOrgRebuildRateLimited(orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique // make sure the subnet is unique
@@ -277,6 +323,8 @@ export async function createClient(
clientId: newClient.clientId, clientId: newClient.clientId,
dateCreated: moment().toISOString() dateCreated: moment().toISOString()
}); });
await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx);
}); });
if (newClient) { if (newClient) {
@@ -291,7 +339,7 @@ export async function createClient(
data: newClient, data: newClient,
success: true, success: true,
error: false, error: false,
message: "Site created successfully", message: "Client created successfully",
status: HttpCode.CREATED status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {

View File

@@ -21,7 +21,10 @@ import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip"; import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes"; import { listExitNodes } from "#dynamic/lib/exitNodes";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { getUniqueClientName } from "@server/db/names"; import { getUniqueClientName } from "@server/db/names";
const paramsSchema = z const paramsSchema = z
@@ -146,6 +149,15 @@ export async function createUserClient(
); );
} }
if (await isOrgRebuildRateLimited(orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org
// make sure the subnet is unique // make sure the subnet is unique

View File

@@ -9,9 +9,14 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error"; import { OlmErrorCodes } from "../olm/error";
import { LimitId } from "@server/lib/billing/features";
import { usageService } from "@server/lib/billing/usageService";
const deleteClientSchema = z.strictObject({ const deleteClientSchema = z.strictObject({
clientId: z.coerce.number().int().positive() clientId: z.coerce.number().int().positive()
@@ -76,6 +81,15 @@ export async function deleteClient(
); );
} }
if (await isOrgRebuildRateLimited(client.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Only allow deletion of machine clients (clients without userId) // Only allow deletion of machine clients (clients without userId)
if (client.userId) { if (client.userId) {
return next( return next(
@@ -106,6 +120,13 @@ export async function deleteClient(
if (!client.userId && client.olmId) { if (!client.userId && client.olmId) {
await trx.delete(olms).where(eq(olms.olmId, client.olmId)); await trx.delete(olms).where(eq(olms.olmId, client.olmId));
} }
await usageService.add(
deletedClient.orgId,
LimitId.MACHINE_CLIENTS,
-1,
trx
);
}); });
if (deletedClient) { if (deletedClient) {

View File

@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -60,6 +60,15 @@ export async function rebuildClientAssociationsCacheRoute(
); );
} }
if (await isOrgRebuildRateLimited(client.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
rebuildClientAssociationsFromClient(client).catch((e) => { rebuildClientAssociationsFromClient(client).catch((e) => {
logger.error( logger.error(
`Failed to rebuild client associations for client ${clientId}: ${e}` `Failed to rebuild client associations for client ${clientId}: ${e}`

View File

@@ -17,7 +17,7 @@ import { subdomainSchema } from "@server/lib/schemas";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators"; import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
import { build } from "@server/build"; import { build } from "@server/build";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -120,7 +120,7 @@ export async function createOrgDomain(
} }
if (build == "saas") { if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS); const usage = await usageService.getUsage(orgId, LimitId.DOMAINS);
if (!usage) { if (!usage) {
return next( return next(
createHttpError( createHttpError(
@@ -132,7 +132,7 @@ export async function createOrgDomain(
const rejectDomains = await usageService.checkLimitSet( const rejectDomains = await usageService.checkLimitSet(
orgId, orgId,
FeatureId.DOMAINS, LimitId.DOMAINS,
{ {
...usage, ...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1 instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -346,7 +346,7 @@ export async function createOrgDomain(
await trx.insert(dnsRecords).values(recordsToInsert); await trx.insert(dnsRecords).values(recordsToInsert);
} }
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx); await usageService.add(orgId, LimitId.DOMAINS, 1, trx);
}); });
if (!returned) { if (!returned) {

View File

@@ -8,7 +8,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
domainId: z.string(), domainId: z.string(),
@@ -77,7 +77,7 @@ export async function deleteAccountDomain(
await trx.delete(domains).where(eq(domains.domainId, domainId)); await trx.delete(domains).where(eq(domains.domainId, domainId));
await usageService.add(orgId, FeatureId.DOMAINS, -1, trx); await usageService.add(orgId, LimitId.DOMAINS, -1, trx);
}); });
return response<DeleteAccountDomainResponse>(res, { return response<DeleteAccountDomainResponse>(res, {

View File

@@ -17,6 +17,7 @@ import * as idp from "./idp";
import * as blueprints from "./blueprints"; import * as blueprints from "./blueprints";
import * as apiKeys from "./apiKeys"; import * as apiKeys from "./apiKeys";
import * as logs from "./auditLogs"; import * as logs from "./auditLogs";
import * as launcher from "./launcher";
import * as newt from "./newt"; import * as newt from "./newt";
import * as olm from "./olm"; import * as olm from "./olm";
import * as serverInfo from "./serverInfo"; import * as serverInfo from "./serverInfo";
@@ -254,6 +255,14 @@ authenticated.delete(
site.deleteSite site.deleteSite
); );
authenticated.post(
"/site/:siteId/restart",
verifySiteAccess,
verifyUserHasAction(ActionsEnum.restartSite),
logActionAudit(ActionsEnum.restartSite),
site.restartSite
);
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
authenticated.get( authenticated.get(
"/site/:siteId/docker/status", "/site/:siteId/docker/status",
@@ -455,6 +464,54 @@ authenticated.get(
resource.getUserResources resource.getUserResources
); );
authenticated.get(
"/org/:orgId/launcher/groups",
verifyOrgAccess,
launcher.listLauncherGroups
);
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
launcher.listLauncherResources
);
authenticated.get(
"/org/:orgId/launcher/sites",
verifyOrgAccess,
launcher.listLauncherSites
);
authenticated.get(
"/org/:orgId/launcher/labels",
verifyOrgAccess,
launcher.listLauncherLabels
);
authenticated.get(
"/org/:orgId/launcher/views",
verifyOrgAccess,
launcher.listLauncherViews
);
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
launcher.createLauncherView
);
authenticated.put(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
launcher.updateLauncherView
);
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
launcher.deleteLauncherView
);
authenticated.get( authenticated.get(
"/org/:orgId/user-resource-aliases", "/org/:orgId/user-resource-aliases",
verifyOrgAccess, verifyOrgAccess,
@@ -910,19 +967,6 @@ unauthenticated.post(
); );
unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
authenticated.post(
"/user/:userId/generate-password-reset-code",
verifyUserIsServerAdmin,
user.adminGeneratePasswordResetCode
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminRemoveUser
);
authenticated.put( authenticated.put(
"/org/:orgId/user", "/org/:orgId/user",
verifyOrgAccess, verifyOrgAccess,
@@ -945,12 +989,6 @@ authenticated.post(
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
authenticated.post(
"/user/:userId/2fa",
verifyUserIsServerAdmin,
user.updateUser2FA
);
authenticated.get( authenticated.get(
"/org/:orgId/users", "/org/:orgId/users",
verifyOrgAccess, verifyOrgAccess,
@@ -1033,85 +1071,112 @@ authenticated.post(
olm.recoverOlmWithFingerprint olm.recoverOlmWithFingerprint
); );
authenticated.put( if (build !== "saas") {
"/idp/oidc", authenticated.put(
verifyUserIsServerAdmin, "/idp/oidc",
// verifyUserHasAction(ActionsEnum.createIdp), verifyUserIsServerAdmin,
idp.createOidcIdp // verifyUserHasAction(ActionsEnum.createIdp),
); idp.createOidcIdp
);
authenticated.post( authenticated.post(
"/idp/:idpId/oidc", "/idp/:idpId/oidc",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
idp.updateOidcIdp idp.updateOidcIdp
); );
authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.put( authenticated.put(
"/idp/:idpId/org/:orgId", "/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
idp.createIdpOrgPolicy idp.createIdpOrgPolicy
); );
authenticated.post( authenticated.post(
"/idp/:idpId/org/:orgId", "/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy idp.updateIdpOrgPolicy
); );
authenticated.delete( authenticated.delete(
"/idp/:idpId/org/:orgId", "/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy idp.deleteIdpOrgPolicy
); );
authenticated.get( authenticated.get(
"/idp/:idpId/org", "/idp/:idpId/org",
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
idp.listIdpOrgPolicies idp.listIdpOrgPolicies
); );
authenticated.get(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.getApiKey
);
authenticated.put(
`/api-key`,
verifyUserIsServerAdmin,
apiKeys.createRootApiKey
);
authenticated.delete(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.deleteApiKey
);
authenticated.get(
`/api-keys`,
verifyUserIsServerAdmin,
apiKeys.listRootApiKeys
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.listApiKeyActions
);
authenticated.post(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.setApiKeyActions
);
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
authenticated.get(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminGetUser
);
authenticated.post(
"/user/:userId/generate-password-reset-code",
verifyUserIsServerAdmin,
user.adminGeneratePasswordResetCode
);
authenticated.delete(
"/user/:userId",
verifyUserIsServerAdmin,
user.adminRemoveUser
);
authenticated.post(
"/user/:userId/2fa",
verifyUserIsServerAdmin,
user.updateUser2FA
);
}
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
authenticated.get(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.getApiKey
);
authenticated.put(
`/api-key`,
verifyUserIsServerAdmin,
apiKeys.createRootApiKey
);
authenticated.delete(
`/api-key/:apiKeyId`,
verifyUserIsServerAdmin,
apiKeys.deleteApiKey
);
authenticated.get(
`/api-keys`,
verifyUserIsServerAdmin,
apiKeys.listRootApiKeys
);
authenticated.get(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.listApiKeyActions
);
authenticated.post(
`/api-key/:apiKeyId/actions`,
verifyUserIsServerAdmin,
apiKeys.setApiKeyActions
);
authenticated.get( authenticated.get(
`/org/:orgId/api-keys`, `/org/:orgId/api-keys`,

View File

@@ -13,37 +13,41 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes try {
// const listenPort = await getNextAvailablePort(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
const listenPort = config.getRawConfig().gerbil.start_port; // const listenPort = await getNextAvailablePort();
let subEndpoint = ""; const listenPort = config.getRawConfig().gerbil.start_port;
if (config.getRawConfig().gerbil.use_subdomain) { let subEndpoint = "";
subEndpoint = await getUniqueExitNodeEndpointName(); if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
}
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} finally {
await release();
} }
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name ||
`Exit Node ${publicKey.slice(0, 8)}`;
// create a new exit node
[exitNode] = await db
.insert(exitNodes)
.values({
publicKey,
endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`,
address,
online: true,
listenPort,
reachableAt,
name: exitNodeName
})
.returning()
.execute();
logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
);
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db

View File

@@ -6,7 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing/features"; import { LimitId } from "@server/lib/billing/features";
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -171,8 +171,9 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
} }
// PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk. // PostgreSQL: batch UPDATE … FROM (VALUES …) - single round-trip per chunk.
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => const valuesList = chunk.map(
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
); );
const valuesClause = sql.join(valuesList, sql`, `); const valuesClause = sql.join(valuesList, sql`, `);
return dbQueryRows<{ orgId: string; pubKey: string }>(sql` return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
@@ -228,7 +229,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
const totalBandwidth = orgUsageMap.get(orgId)!; const totalBandwidth = orgUsageMap.get(orgId)!;
const bandwidthUsage = await usageService.add( const bandwidthUsage = await usageService.add(
orgId, orgId,
FeatureId.EGRESS_DATA_MB, LimitId.EGRESS_DATA_MB,
totalBandwidth totalBandwidth
); );
if (bandwidthUsage) { if (bandwidthUsage) {
@@ -236,7 +237,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
usageService usageService
.checkLimitSet( .checkLimitSet(
orgId, orgId,
FeatureId.EGRESS_DATA_MB, LimitId.EGRESS_DATA_MB,
bandwidthUsage bandwidthUsage
) )
.catch((error: any) => { .catch((error: any) => {
@@ -247,10 +248,7 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
}); });
} }
} catch (error) { } catch (error) {
logger.error( logger.error(`Error processing usage for org ${orgId}:`, error);
`Error processing usage for org ${orgId}:`,
error
);
// Continue with other orgs. // Continue with other orgs.
} }
} }

View File

@@ -31,7 +31,7 @@ import {
} from "@server/auth/sessions/app"; } from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto"; import { decrypt } from "@server/lib/crypto";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
@@ -645,7 +645,7 @@ export async function validateOidcCallback(
for (const orgCount of orgUserCounts) { for (const orgCount of orgUserCounts) {
await usageService.updateCount( await usageService.updateCount(
orgCount.orgId, orgCount.orgId,
FeatureId.USERS, LimitId.USERS,
orgCount.userCount orgCount.userCount
); );
} }

View File

@@ -159,6 +159,7 @@ authenticated.get(
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
resource.getUserResources resource.getUserResources
); );
// Site Resource endpoints // Site Resource endpoints
authenticated.put( authenticated.put(
"/org/:orgId/site-resource", "/org/:orgId/site-resource",

View File

@@ -0,0 +1,101 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { launcherViewConfigSchema } from "./types";
const createLauncherViewBodySchema = z.strictObject({
name: z.string().min(1).max(128),
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function createLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = createLauncherViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canCreateOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canCreateOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const now = moment().toISOString();
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: parsed.data.orgWide ? null : userId,
name: parsed.data.name,
config: JSON.stringify(parsed.data.config),
createdAt: now,
updatedAt: now
})
.returning();
return response(res, {
data: {
viewId: created.viewId,
orgId: created.orgId,
userId: created.userId,
name: created.name,
config: launcherViewConfigSchema.parse(
JSON.parse(created.config)
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
isOrgWide: created.userId == null
},
success: true,
error: false,
message: "Launcher view created successfully",
status: HttpCode.CREATED
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error creating launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,86 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
export async function deleteLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
const viewId = Number.parseInt(
getFirstString(req.params.viewId) ?? "",
10
);
if (!orgId || !Number.isFinite(viewId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid request parameters"
)
);
}
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.viewId, viewId),
eq(launcherViews.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to delete this view"
)
);
}
await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId));
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher view deleted successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error deleting launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,172 @@
import { formatEndpoint, parseEndpoint } from "@server/lib/ip";
export type SiteResourceDestinationInput = {
mode: "host" | "cidr" | "http" | "ssh";
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
};
export function resolveHttpHttpsDisplayPort(
mode: "http",
destinationPort: number | null
): number {
if (destinationPort != null) {
return destinationPort;
}
return 80;
}
export function formatSiteResourceDestinationDisplay(
row: SiteResourceDestinationInput
): string {
if (!row.destination) {
return "";
}
const { mode, destination, destinationPort, scheme } = row;
if (mode !== "http") {
return destination;
}
const port = resolveHttpHttpsDisplayPort(mode, destinationPort);
const downstreamScheme = scheme ?? "http";
const hostPart =
destination.includes(":") && !destination.startsWith("[")
? `[${destination}]`
: destination;
return `${downstreamScheme}://${hostPart}:${port}`;
}
export type PublicResourceAccessInput = {
mode: string;
fullDomain: string | null;
ssl: boolean;
proxyPort: number | null;
wildcard: boolean;
exitNodeEndpoint?: string | null;
};
export type SiteResourceAccessInput = {
mode: string;
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
ssl: boolean;
fullDomain: string | null;
alias: string | null;
aliasAddress: string | null;
};
export type LauncherAccessFields = {
accessDisplay: string;
accessCopyValue: string;
accessUrl: string | null;
};
function formatTcpUdpResourceAccess(
exitNodeEndpoint: string | null | undefined,
proxyPort: number | null
): LauncherAccessFields {
if (proxyPort == null) {
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}
if (!exitNodeEndpoint?.trim()) {
const port = proxyPort.toString();
return {
accessDisplay: port,
accessCopyValue: port,
accessUrl: null
};
}
const parsed = parseEndpoint(exitNodeEndpoint);
const host = parsed?.ip ?? exitNodeEndpoint.trim();
const access = formatEndpoint(host, proxyPort);
return {
accessDisplay: access,
accessCopyValue: access,
accessUrl: null
};
}
export function formatPublicResourceAccess(
resource: PublicResourceAccessInput
): LauncherAccessFields {
const browserModes = ["http", "ssh", "rdp", "vnc"];
if (!browserModes.includes(resource.mode)) {
return formatTcpUdpResourceAccess(
resource.exitNodeEndpoint,
resource.proxyPort
);
}
if (!resource.fullDomain) {
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
accessCopyValue: url,
accessUrl: resource.wildcard ? null : url
};
}
export function formatSiteResourceAccess(
resource: SiteResourceAccessInput
): LauncherAccessFields {
if (resource.alias) {
return {
accessDisplay: resource.alias,
accessCopyValue: resource.alias,
accessUrl: null
};
}
if (resource.mode === "http" && resource.fullDomain) {
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
return {
accessDisplay: url,
accessCopyValue: url,
accessUrl: url
};
}
const destination = formatSiteResourceDestinationDisplay({
mode: resource.mode as SiteResourceDestinationInput["mode"],
destination: resource.destination,
destinationPort: resource.destinationPort,
scheme: resource.scheme
});
if (destination) {
return {
accessDisplay: destination,
accessCopyValue: destination,
accessUrl: resource.mode === "http" ? destination : null
};
}
if (resource.aliasAddress) {
return {
accessDisplay: resource.aliasAddress,
accessCopyValue: resource.aliasAddress,
accessUrl: null
};
}
return {
accessDisplay: "",
accessCopyValue: "",
accessUrl: null
};
}

View File

@@ -0,0 +1,9 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listLauncherGroupsForUser } from "./launcherResourceAccess";
import { launcherListQuerySchema } from "./types";
export async function listLauncherGroups(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { groups, total } = await listLauncherGroupsForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
groups,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher groups retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher groups:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess";
import { launcherFilterListQuerySchema } from "./types";
export async function listLauncherLabels(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherFilterListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { labels, total } = await listAccessibleLauncherLabelsForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
labels,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher labels retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher labels:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,72 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { listLauncherResourcesForUser } from "./launcherResourceAccess";
import { launcherListQuerySchema } from "./types";
const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({
groupKey: z.string().min(1)
});
export async function listLauncherResources(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = listLauncherResourcesQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { resources, total } = await listLauncherResourcesForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
resources,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher resources retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher resources:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,67 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess";
import { launcherFilterListQuerySchema } from "./types";
export async function listLauncherSites(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherFilterListQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const { sites, total } = await listAccessibleLauncherSitesForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: {
sites,
pagination: {
total,
page: parsed.data.page,
pageSize: parsed.data.pageSize
}
},
success: true,
error: false,
message: "Launcher sites retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher sites:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,73 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
export async function listLauncherViews(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const rows = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
or(
eq(launcherViews.userId, userId),
isNull(launcherViews.userId)
)
)
);
return response(res, {
data: {
views: rows.map(mapViewRow)
},
success: true,
error: false,
message: "Launcher views retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher views:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -0,0 +1,165 @@
import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
showLabels: z.boolean().default(true),
showSiteTags: z.boolean().default(true),
showRecents: z.boolean().default(false).optional(),
siteIds: z.array(z.number()).default([]),
labelIds: z.array(z.number()).default([]),
query: z.string().default("")
});
export type LauncherViewConfig = z.infer<typeof launcherViewConfigSchema>;
export const defaultLauncherViewConfig: LauncherViewConfig =
launcherViewConfigSchema.parse({});
export type LauncherLabel = {
labelId: number;
name: string;
color: string;
};
export type LauncherSiteInfo = {
siteId: number;
name: string;
type: string;
online?: boolean;
};
export type LauncherResource = {
launcherResourceKey: string;
resourceType: "public" | "site";
resourceId: number;
siteResourceId?: number;
niceId: string;
name: string;
accessDisplay: string;
accessCopyValue: string;
accessUrl: string | null;
iconUrl: string | null;
enabled: boolean;
mode: string;
labels: LauncherLabel[];
site?: LauncherSiteInfo;
};
export type LauncherGroup = {
groupKey: string;
name: string;
groupType: "site" | "label";
itemCount: number;
siteType?: string;
siteOnline?: boolean;
labelColor?: string;
};
export type ListLauncherGroupsResponse = {
groups: LauncherGroup[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type ListLauncherResourcesResponse = {
resources: LauncherResource[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type LauncherViewRecord = {
viewId: number;
orgId: string;
userId: string | null;
name: string;
config: LauncherViewConfig;
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
};
export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
};
export const launcherFilterListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()
.int()
.positive()
.optional()
.catch(500)
.default(500),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
export type LauncherFilterListQuery = z.infer<
typeof launcherFilterListQuerySchema
>;
export type ListLauncherSitesResponse = {
sites: LauncherSiteInfo[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type ListLauncherLabelsResponse = {
labels: LauncherLabel[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export const launcherListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()
.int()
.positive()
.optional()
.catch(20)
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherListQuery = z.infer<typeof launcherListQuerySchema>;
export function parseIdListParam(value: string | undefined): number[] {
if (!value?.trim()) {
return [];
}
return value
.split(",")
.map((part) => Number.parseInt(part.trim(), 10))
.filter((id) => Number.isFinite(id));
}
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };

View File

@@ -0,0 +1,157 @@
import { db, launcherViews } from "@server/db";
import { response } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import moment from "moment";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { launcherViewConfigSchema } from "./types";
const updateLauncherViewBodySchema = z.strictObject({
name: z.string().min(1).max(128).optional(),
config: launcherViewConfigSchema.optional(),
orgWide: z.boolean().optional()
});
export async function updateLauncherView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
const viewId = Number.parseInt(
getFirstString(req.params.viewId) ?? "",
10
);
if (!orgId || !Number.isFinite(viewId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid request parameters"
)
);
}
const parsed = updateLauncherViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.viewId, viewId),
eq(launcherViews.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Launcher view not found")
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"You do not have permission to update this view"
)
);
}
if (parsed.data.orgWide === true && !canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
if (
parsed.data.orgWide === false &&
isOrgWideView &&
!canManageOrgWide
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
const nextUserId =
parsed.data.orgWide === true
? null
: parsed.data.orgWide === false
? userId
: existing.userId;
const [updated] = await db
.update(launcherViews)
.set({
name: parsed.data.name ?? existing.name,
config: parsed.data.config
? JSON.stringify(parsed.data.config)
: existing.config,
userId: nextUserId,
updatedAt: moment().toISOString()
})
.where(eq(launcherViews.viewId, viewId))
.returning();
return response(res, {
data: {
viewId: updated.viewId,
orgId: updated.orgId,
userId: updated.userId,
name: updated.name,
config: launcherViewConfigSchema.parse(
JSON.parse(updated.config)
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null
},
success: true,
error: false,
message: "Launcher view updated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error updating launcher view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}

View File

@@ -5,6 +5,7 @@ import {
db, db,
ExitNode, ExitNode,
networks, networks,
remoteExitNodeResources,
resources, resources,
Site, Site,
siteNetworks, siteNetworks,
@@ -51,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
clientsRes clientsRes
.filter((client) => { .filter((client) => {
if (!client.clients.pubKey) { if (!client.clients.pubKey) {
logger.warn( logger.debug(
`Client ${client.clients.clientId} has no public key, skipping` `Client ${client.clients.clientId} has no public key, skipping`
); );
return false; return false;
} }
if (!client.clients.subnet) { if (!client.clients.subnet) {
logger.warn( logger.debug(
`Client ${client.clients.clientId} has no subnet, skipping` `Client ${client.clients.clientId} has no subnet, skipping`
); );
return false; return false;
@@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient(
export async function buildTargetConfigurationForNewtClient( export async function buildTargetConfigurationForNewtClient(
siteId: number, siteId: number,
version?: string | null version?: string | null,
remoteExitNodeId?: string
) { ) {
// Get all enabled targets with their resource mode information // Get all enabled targets with their resource mode information
const allTargets = await db const allTargets = await db
@@ -379,10 +381,24 @@ export async function buildTargetConfigurationForNewtClient(
}; };
}); });
let remoteExitNodeSubnets: string[] = [];
if (remoteExitNodeId) {
const remoteNodeResources = await db
.select()
.from(remoteExitNodeResources)
.where(
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
);
// filter through these and provide the subnets
remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination);
}
return { return {
validHealthCheckTargets, validHealthCheckTargets,
tcpTargets, tcpTargets,
udpTargets, udpTargets,
browserGatewayTargets browserGatewayTargets,
remoteExitNodeSubnets
}; };
} }

View File

@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
} }
if (!newt.siteId) { if (!newt.siteId) {
logger.warn("Newt has no client ID!"); logger.warn("Newt has no site ID!");
return; return;
} }
@@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!)) .where(eq(sites.siteId, newt.siteId!))
.returning(); .returning();
if (!site) {
throw new Error(
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
);
}
await fireSiteOfflineAlert( await fireSiteOfflineAlert(
site.orgId, site.orgId,
site.siteId, site.siteId,
@@ -43,6 +49,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
); );
}); });
} catch (error) { } catch (error) {
logger.error("Error handling disconnecting message", { error }); logger.error("Error handling site disconnecting message", error);
} }
}; };

View File

@@ -5,7 +5,20 @@ import { Newt } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { sendNewtSyncMessage } from "./sync"; import { sendNewtSyncMessage } from "./sync";
import { recordPing } from "./pingAccumulator"; import semver from "semver";
import { recordSitePing } from "./pingAccumulator";
const NEWT_SUPPORTS_SYNC_VERSION = ">=1.14.0";
const PONG = {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
/** /**
* Handles ping messages from newt clients. * Handles ping messages from newt clients.
@@ -35,7 +48,15 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
// batched UPDATE instead of one query per ping. This prevents // batched UPDATE instead of one query per ping. This prevents
// connection pool exhaustion under load, especially with // connection pool exhaustion under load, especially with
// cross-region latency to the database. // cross-region latency to the database.
recordPing(newt.siteId); recordSitePing(newt.siteId);
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_SUPPORTS_SYNC_VERSION)
) {
// Newt does not support the sync message so not checking - stop here -
return PONG;
}
// Check config version and sync if stale. // Check config version and sync if stale.
const configVersion = await getClientConfigVersion(newt.newtId); const configVersion = await getClientConfigVersion(newt.newtId);
@@ -49,32 +70,21 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
); );
// TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, newt.siteId))
.limit(1);
// const [site] = await db if (!site) {
// .select() logger.warn(
// .from(sites) `Newt ping message: site with ID ${newt.siteId} not found`
// .where(eq(sites.siteId, newt.siteId)) );
// .limit(1); return;
}
// if (!site) { await sendNewtSyncMessage(newt, site);
// logger.warn(
// `Newt ping message: site with ID ${newt.siteId} not found`
// );
// return;
// }
// await sendNewtSyncMessage(newt, site);
} }
return { return PONG;
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
}; };

View File

@@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesList = await listExitNodes( const exitNodesList = await listExitNodes(
site.orgId, site.orgId,
true, true,
noCloud || false noCloud || false,
newt.siteId
); // filter for only the online ones ); // filter for only the online ones
let lastExitNodeId = null; let lastExitNodeId = null;

View File

@@ -1,4 +1,4 @@
import { db, ExitNode, newts, Transaction } from "@server/db"; import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { exitNodes, Newt, sites } from "@server/db"; import { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.where(eq(newts.newtId, newt.newtId)); .where(eq(newts.newtId, newt.newtId));
} }
let remoteExitNodeId: string | undefined;
if (exitNode.type == "remoteExitNode") {
// get the remote exit node ID associated with this exit node
const [remoteExitNode] = await db
.select()
.from(remoteExitNodes)
.where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId))
.limit(1);
remoteExitNodeId = remoteExitNode?.remoteExitNodeId;
}
const { const {
tcpTargets, tcpTargets,
udpTargets, udpTargets,
validHealthCheckTargets, validHealthCheckTargets,
browserGatewayTargets browserGatewayTargets,
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion); remoteExitNodeSubnets
} = await buildTargetConfigurationForNewtClient(
siteId,
newtVersion,
remoteExitNodeId // this is for the remote node resources
);
logger.debug( logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
}, },
healthCheckTargets: validHealthCheckTargets, healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets, browserGatewayTargets: browserGatewayTargets,
remoteExitNodeSubnets: remoteExitNodeSubnets,
chainId: chainId chainId: chainId
} }
}, },

View File

@@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts"; import { fireSiteOnlineAlert } from "@server/lib/alerts";
import { withRetry } from "@server/lib/dbRetry";
/** /**
* Ping Accumulator * Ping Accumulator
@@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
// Map of siteId -> latest ping timestamp (unix seconds) // Map of siteId -> latest ping timestamp (unix seconds)
@@ -57,9 +56,6 @@ export function recordSitePing(siteId: number): void {
pendingSitePings.set(siteId, now); pendingSitePings.set(siteId, now);
} }
/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */
export const recordPing = recordSitePing;
/** /**
* Record a ping for an OLM client. Batches the `clients` table update * Record a ping for an OLM client. Batches the `clients` table update
* (`online`, `lastPing`, `archived`) and, when `olmArchived` is true, * (`online`, `lastPing`, `archived`) and, when `olmArchived` is true,
@@ -269,85 +265,7 @@ export async function flushPingsToDb(): Promise<void> {
} }
// ── Retry / Error Helpers ────────────────────────────────────────────── // ── Retry / Error Helpers ──────────────────────────────────────────────
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/
async function withRetry<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < MAX_RETRIES) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
/**
* Detect transient errors that are safe to retry.
*/
function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
// ── Lifecycle ────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────

View File

@@ -25,7 +25,7 @@ import { getUniqueSiteName } from "@server/db/names";
import moment from "moment"; import moment from "moment";
import { build } from "@server/build"; import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { LimitId } from "@server/lib/billing";
import { INSPECT_MAX_BYTES } from "buffer"; import { INSPECT_MAX_BYTES } from "buffer";
import { getNextAvailableClientSubnet } from "@server/lib/ip"; import { getNextAvailableClientSubnet } from "@server/lib/ip";
@@ -169,7 +169,7 @@ export async function registerNewt(
// SaaS billing check // SaaS billing check
if (build == "saas") { if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES); const usage = await usageService.getUsage(orgId, LimitId.SITES);
if (!usage) { if (!usage) {
return next( return next(
createHttpError( createHttpError(
@@ -180,7 +180,7 @@ export async function registerNewt(
} }
const rejectSites = await usageService.checkLimitSet( const rejectSites = await usageService.checkLimitSet(
orgId, orgId,
FeatureId.SITES, LimitId.SITES,
{ {
...usage, ...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1 instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -274,7 +274,7 @@ export async function registerNewt(
) )
); );
await usageService.add(orgId, FeatureId.SITES, 1, trx); await usageService.add(orgId, LimitId.SITES, 1, trx);
}); });
} finally { } finally {
await releaseSubnetLock(); await releaseSubnetLock();

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