Compare commits

...

66 Commits

Author SHA1 Message Date
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
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
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
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
Owen
807613f28c Move rate limit up 2026-07-01 17:30:07 -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
Owen
61fc2e5ea7 Fix restartSite import 2026-07-01 14:48:36 -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
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
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
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
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
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 Schwartz
7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
127 changed files with 4791 additions and 794 deletions

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Сайтът е обновен",
"siteUpdatedDescription": "Сайтът е актуализиран.",
"siteGeneralDescription": "Конфигурирайте общи настройки за този сайт",
"siteRestartTitle": "Рестартирайте Сайта",
"siteRestartDescription": "Рестартирайте WireGuard тунела за този сайт. Това ще прекъсне връзката за кратко.",
"siteRestartBody": "Използвайте това, ако тунелът на сайта не функционира правилно и искате да принудите повторно свързване без да рестартирате хоста.",
"siteRestartButton": "Рестартирайте Сайта",
"siteRestartDialogMessage": "Сигурни ли сте, че искате да рестартирате WireGuard тунела за <b>{name}</b>? Сайтът ще изгуби връзка за кратко.",
"siteRestartWarning": "Сайтът ще се изключи за кратко, докато тунелът се рестартира.",
"siteRestarted": "Сайтът е рестартиран",
"siteRestartedDescription": "WireGuard тунелът е рестартиран.",
"siteErrorRestart": "Неуспешно рестартиране на сайта",
"siteErrorRestartDescription": "Възникна грешка при рестартирането на сайта.",
"siteSettingDescription": "Конфигурирайте настройките на сайта",
"siteResourcesTab": "Ресурси",
"siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Приложи Чернова",
"actionListBlueprints": "Списък с планове.",
"actionGetBlueprint": "Вземи план.",
"actionCreateOrgWideLauncherView": "Създайте Изглед на Стартирача за Цялата Организация",
"setupToken": "Конфигурация на токен",
"setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.",
"setupTokenRequired": "Необходим е конфигурационен токен",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Мрежа",
"addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.",
"selectSites": "Избор на сайтове",
"selectLabels": "Изберете етикети",
"sitesDescription": "Клиентът ще има връзка с избраните сайтове",
"clientInstallOlm": "Инсталиране на Olm",
"clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Сайт",
"selectSite": "Изберете сайт...",
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} other {# сайтове}}",
"labelsSelectorLabelsCount": "{count, plural, one {# етикет} other {# етикета}}",
"noSitesFound": "Не са намерени сайтове.",
"createInternalResourceDialogProtocol": "Протокол",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Отдалечени възли",
"remoteExitNodeId": "ID.",
"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": {
"title": "Създаване на отдалечен възел.",
"description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC доставчик",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик",
"subnet": "Подмрежа",
"utilitySubnet": "Мрежа на Помощни Подмрежи",
"subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.",
"customDomain": "Персонализиран домейн.",
"authPage": "Страници за автентификация.",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Бял списък на имейли",
"memberPortalResourceDisabled": "Ресурсът е деактивиран",
"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": "Предишен",
"memberPortalNext": "Следващ",
"httpSettings": "HTTP настройки",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----НАЧАЛО НА OPENSSH ЧАСТЕН КЛЮЧ-----",
"sshPrivateKeyRequired": "Изисква се частен ключ",
"vncTitle": "VNC",
"vncSignInDescription": "Въведете вашата VNC парола за свързване",
"vncSignInDescription": "Въведете VNC данните си за връзка",
"vncUsernameOptional": "Потребителско име (по избор)",
"vncPasswordOptional": "Парола (по избор)",
"vncNoResourceTarget": "Не е налична цел за ресурса",
"vncFailedToLoadNovnc": "Неуспешно зареждане на noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Lokalita upravena",
"siteUpdatedDescription": "Lokalita byla upravena.",
"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",
"siteResourcesTab": "Zdroje",
"siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Použít plán",
"actionListBlueprints": "Seznam šablon",
"actionGetBlueprint": "Získat šablonu",
"actionCreateOrgWideLauncherView": "Vytvořit organizační pohled",
"setupToken": "Nastavit token",
"setupTokenDescription": "Zadejte nastavovací token z konzole serveru.",
"setupTokenRequired": "Je vyžadován token nastavení",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Podsíť",
"addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.",
"selectSites": "Vyberte stránky",
"selectLabels": "Vyberte názvy",
"sitesDescription": "Klient bude mít připojení k vybraným webům",
"clientInstallOlm": "Nainstalovat Olm",
"clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Lokalita",
"selectSite": "Vybrat lokalitu...",
"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.",
"createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Vzdálené uzly",
"remoteExitNodeId": "ID",
"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": {
"title": "Vytvořit vzdálený uzel",
"description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsíť",
"utilitySubnet": "Nástrojová podsíť",
"subnetDescription": "Podsíť pro konfiguraci sítě této organizace.",
"customDomain": "Vlastní doména",
"authPage": "Autentizační stránky",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Seznam povolených emailů",
"memberPortalResourceDisabled": "Zdroj je zakázán",
"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í",
"memberPortalNext": "Následující",
"httpSettings": "Nastavení HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----ZAČÁTEK SOUKROMÉHO KLÍČE OPENSSH-----",
"sshPrivateKeyRequired": "Je vyžadován soukromý klíč",
"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é)",
"vncNoResourceTarget": "Není k dispozici žádný cíl zdroje",
"vncFailedToLoadNovnc": "Nepodařilo se načíst noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site opdateret",
"siteUpdatedDescription": "Sitet er blevet opdateret.",
"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",
"siteResourcesTab": "Ressourcer",
"siteResourcesNoneOnSite": "Dette site har endnu ingen offentlige eller private ressourcer.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Brug blueprint",
"actionListBlueprints": "Vis blueprints",
"actionGetBlueprint": "Hent blueprint",
"actionCreateOrgWideLauncherView": "Opret org-dækkende launcher-visning",
"setupToken": "Opsætningstoken",
"setupTokenDescription": "Indtast opsætningstoken fra serverkonsollen.",
"setupTokenRequired": "Opsætningstoken er nødvendig",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnet",
"addressDescription": "Den interne adressen til klienten. Skal falle inden for organisationens subnet.",
"selectSites": "Vælg sites",
"selectLabels": "Vælg etiketter",
"sitesDescription": "Klienten vil have forbindelse til de valgte områdene",
"clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Få Olm til at køre på dit system",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Websted",
"selectSite": "Vælg site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketter}}",
"noSitesFound": "Ingen sites fundet.",
"createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Eksterne noder",
"remoteExitNodeId": "ID",
"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": {
"title": "Opret ekstern node",
"description": "Opret en ny selvhostet ekstern relay- og proxyservernode",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC udbyder",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør",
"subnet": "Subnet",
"utilitySubnet": "Forsynings subnet",
"subnetDescription": "Subnettet for denne organisations netværkskonfiguration.",
"customDomain": "Brugerdefineret domæne",
"authPage": "Autentiseringssider",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-mailwhitelist",
"memberPortalResourceDisabled": "Ressource deaktiveret",
"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",
"memberPortalNext": "Næste",
"httpSettings": "HTTP Indstillinger",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØGLE-----",
"sshPrivateKeyRequired": "Privat nøgle er påkrævet",
"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)",
"vncNoResourceTarget": "Intet ressourcemål tilgængeligt",
"vncFailedToLoadNovnc": "Kunne ikke indlæse noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Standort aktualisiert",
"siteUpdatedDescription": "Der Standort wurde aktualisiert.",
"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",
"siteResourcesTab": "Ressourcen",
"siteResourcesNoneOnSite": "Dieser Standort hat noch keine öffentlichen oder privaten Ressourcen",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Blueprint anwenden",
"actionListBlueprints": "Blaupausen anzeigen",
"actionGetBlueprint": "Erhalte Blaupause",
"actionCreateOrgWideLauncherView": "Organisationen-Weiter-Startansicht erstellen",
"setupToken": "Setup-Token",
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
"setupTokenRequired": "Setup-Token ist erforderlich",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnetz",
"addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.",
"selectSites": "Standorte auswählen",
"selectLabels": "Etiketten auswählen",
"sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.",
"clientInstallOlm": "Olm installieren",
"clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Standort",
"selectSite": "Standort auswählen...",
"multiSitesSelectorSitesCount": "{count, plural, one {# Standort} other {# Standorte}}",
"labelsSelectorLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}",
"noSitesFound": "Keine Standorte gefunden.",
"createInternalResourceDialogProtocol": "Protokoll",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Entfernte Knoten",
"remoteExitNodeId": "ID",
"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": {
"title": "Erstelle Remote Node",
"description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnetz",
"utilitySubnet": "Nutzsubnetz",
"subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.",
"customDomain": "Eigene Domain",
"authPage": "Authentifizierungs-Seiten",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-Mail-Whitelist",
"memberPortalResourceDisabled": "Ressource deaktiviert",
"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",
"memberPortalNext": "Nächste",
"httpSettings": "HTTP-Einstellungen",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Privater Schlüssel ist erforderlich",
"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)",
"vncNoResourceTarget": "Kein Ressourcen-Ziel verfügbar",
"vncFailedToLoadNovnc": "Fehler beim Laden von noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site updated",
"siteUpdatedDescription": "The site has been updated.",
"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",
"siteResourcesTab": "Resources",
"siteResourcesNoneOnSite": "This site has no public or private resources yet.",
@@ -1905,6 +1915,9 @@
"billingDomains": "Domains",
"billingOrganizations": "Orgs",
"billingRemoteExitNodes": "Remote Nodes",
"billingPublicResources": "Public Resources",
"billingPrivateResources": "Private Resources",
"billingMachineClients": "Machine Clients",
"billingNoLimitConfigured": "No limit configured",
"billingEstimatedPeriod": "Estimated Billing Period",
"billingIncludedUsage": "Included Usage",
@@ -1933,6 +1946,9 @@
"billingUsersInfo": "How many users you can use",
"billingDomainInfo": "How many domains 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",
"billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription",
@@ -2381,6 +2397,21 @@
"sidebarRemoteExitNodes": "Remote Nodes",
"remoteExitNodeId": "ID",
"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": {
"title": "Create Remote Node",
"description": "Create a new self-hosted remote relay and proxy server node",
@@ -3629,7 +3660,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Private key is required",
"vncTitle": "VNC",
"vncSignInDescription": "Enter your VNC password to connect",
"vncSignInDescription": "Enter your VNC credentials to connect",
"vncUsernameOptional": "Username (optional)",
"vncPasswordOptional": "Password (optional)",
"vncNoResourceTarget": "No resource target is available",
"vncFailedToLoadNovnc": "Failed to load noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Sitio actualizado",
"siteUpdatedDescription": "El sitio ha sido actualizado.",
"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",
"siteResourcesTab": "Recursos",
"siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Aplicar plano",
"actionListBlueprints": "Listar blueprints",
"actionGetBlueprint": "Obtener blueprint",
"actionCreateOrgWideLauncherView": "Crear Vista de Lanzador para toda la Organización",
"setupToken": "Configuración de token",
"setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.",
"setupTokenRequired": "Se requiere el token de configuración",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subred",
"addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.",
"selectSites": "Seleccionar sitios",
"selectLabels": "Seleccionar etiquetas",
"sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados",
"clientInstallOlm": "Instalar Olm",
"clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Sitio",
"selectSite": "Seleccionar sitio...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sitio} other {# sitios}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}",
"noSitesFound": "Sitios no encontrados.",
"createInternalResourceDialogProtocol": "Protocolo",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nodos remotos",
"remoteExitNodeId": "ID",
"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": {
"title": "Crear nodo remoto",
"description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subred",
"utilitySubnet": "Subred de Utilidad",
"subnetDescription": "La subred para la configuración de red de esta organización.",
"customDomain": "Dominio personalizado",
"authPage": "Páginas de autenticación",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista Blanca de Correo",
"memberPortalResourceDisabled": "Recurso Deshabilitado",
"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",
"memberPortalNext": "Siguiente",
"httpSettings": "Configuración HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----COMIENZO DE LA CLAVE PRIVADA OPENSSH-----",
"sshPrivateKeyRequired": "Se requiere clave privada",
"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)",
"vncNoResourceTarget": "No hay objetivo de recurso disponible",
"vncFailedToLoadNovnc": "Error al cargar noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Nœud mis à jour",
"siteUpdatedDescription": "Le nœud a été mis à jour.",
"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",
"siteResourcesTab": "Ressources",
"siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Appliquer la Config",
"actionListBlueprints": "Lister les plans",
"actionGetBlueprint": "Obtenez un plan",
"actionCreateOrgWideLauncherView": "Créer une vue de lancement au niveau de l'organisation",
"setupToken": "Jeton de configuration",
"setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.",
"setupTokenRequired": "Le jeton de configuration est requis.",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sous-réseau",
"addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.",
"selectSites": "Sélectionner des sites",
"selectLabels": "Sélectionner des étiquettes",
"sitesDescription": "Le client aura une connectivité vers les sites sélectionnés",
"clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Faites fonctionner Olm sur votre système",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site",
"selectSite": "Sélectionner un site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}",
"noSitesFound": "Aucun site trouvé.",
"createInternalResourceDialogProtocol": "Protocole",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nœuds distants",
"remoteExitNodeId": "ID",
"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": {
"title": "Créer un nœud distant",
"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",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sous-réseau",
"utilitySubnet": "Routeur utilitaire",
"subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.",
"customDomain": "Domaine personnalisé",
"authPage": "Pages d'authentification",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Liste blanche des e-mails",
"memberPortalResourceDisabled": "Ressource désactivée",
"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",
"memberPortalNext": "Suivant",
"httpSettings": "Paramètres HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Une clé privée est requise",
"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)",
"vncNoResourceTarget": "Aucune cible de ressource disponible",
"vncFailedToLoadNovnc": "Échec du chargement de noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Sito aggiornato",
"siteUpdatedDescription": "Il sito è stato aggiornato.",
"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",
"siteResourcesTab": "Risorse",
"siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Applica Progetto",
"actionListBlueprints": "Elenco Blueprints",
"actionGetBlueprint": "Ottieni Blueprint",
"actionCreateOrgWideLauncherView": "Crea Visualizzazione Lanscia Org-Wide",
"setupToken": "Configura Token",
"setupTokenDescription": "Inserisci il token di configurazione dalla console del server.",
"setupTokenRequired": "Il token di configurazione è richiesto",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sottorete",
"addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.",
"selectSites": "Seleziona siti",
"selectLabels": "Seleziona etichette",
"sitesDescription": "Il cliente avrà connettività ai siti selezionati",
"clientInstallOlm": "Installa Olm",
"clientInstallOlmDescription": "Avvia Olm sul tuo sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Sito",
"selectSite": "Seleziona sito...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sito} other {# siti}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}",
"noSitesFound": "Nessun sito trovato.",
"createInternalResourceDialogProtocol": "Protocollo",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nodi Remoti",
"remoteExitNodeId": "ID",
"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": {
"title": "Crea Nodo Remoto",
"description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sottorete",
"utilitySubnet": "Sottorete di utilità",
"subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.",
"customDomain": "Dominio Personalizzato",
"authPage": "Pagine di Autenticazione",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista Autorizzazioni Email",
"memberPortalResourceDisabled": "Risorsa Disabilitata",
"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",
"memberPortalNext": "Successivo",
"httpSettings": "Impostazioni HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "È richiesta una chiave privata",
"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)",
"vncNoResourceTarget": "Nessun bersaglio di risorsa disponibile",
"vncFailedToLoadNovnc": "Impossibile caricare noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "사이트가 업데이트되었습니다",
"siteUpdatedDescription": "사이트가 업데이트되었습니다.",
"siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.",
"siteRestartTitle": "사이트 다시 시작",
"siteRestartDescription": "이 사이트의 WireGuard 터널을 다시 시작합니다. 일시적으로 연결이 중단될 수 있습니다.",
"siteRestartBody": "사이트 터널이 제대로 작동하지 않을 경우, 호스트를 재시작하지 않고 다시 연결을 강제하려면 이 옵션을 사용하세요.",
"siteRestartButton": "사이트 다시 시작",
"siteRestartDialogMessage": "<b>{name}</b>의 WireGuard 터널을 재시작하시겠습니까? 이 작업으로 인해 사이트의 연결이 일시적으로 중단될 수 있습니다.",
"siteRestartWarning": "터널을 재시작하는 동안 사이트가 일시적으로 연결이 끊깁니다.",
"siteRestarted": "사이트가 재시작되었습니다",
"siteRestartedDescription": "WireGuard 터널이 재시작되었습니다.",
"siteErrorRestart": "사이트 재시작 실패",
"siteErrorRestartDescription": "사이트를 재시작하는 중 오류가 발생했습니다.",
"siteSettingDescription": "사이트에서 설정을 구성하세요.",
"siteResourcesTab": "리소스",
"siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "청사진 적용",
"actionListBlueprints": "청사진 목록",
"actionGetBlueprint": "청사진 가져오기",
"actionCreateOrgWideLauncherView": "조직 전체 런처 보기 생성",
"setupToken": "설정 토큰",
"setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.",
"setupTokenRequired": "설정 토큰이 필요합니다",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "서브넷",
"addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.",
"selectSites": "사이트 선택",
"selectLabels": "레이블 선택",
"sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.",
"clientInstallOlm": "Olm 설치",
"clientInstallOlmDescription": "시스템에서 Olm을 실행하기",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "사이트",
"selectSite": "사이트 선택...",
"multiSitesSelectorSitesCount": "{count, plural, other {# 사이트}}",
"labelsSelectorLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}",
"noSitesFound": "사이트를 찾을 수 없습니다.",
"createInternalResourceDialogProtocol": "프로토콜",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "원격 노드",
"remoteExitNodeId": "ID",
"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": {
"title": "원격 노드 생성",
"description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 공급자",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
"subnet": "서브넷",
"utilitySubnet": "유틸리티 서브넷",
"subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.",
"customDomain": "사용자 정의 도메인",
"authPage": "인증 페이지",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "이메일 화이트리스트",
"memberPortalResourceDisabled": "리소스 비활성화됨",
"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": "이전",
"memberPortalNext": "다음",
"httpSettings": "HTTP 설정",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "프라이빗 키가 필요합니다",
"vncTitle": "VNC",
"vncSignInDescription": "연결하려면 VNC 비밀번호를 입력하세요",
"vncSignInDescription": "연결하기 위해 VNC 자격 증명을 입력하세요",
"vncUsernameOptional": "사용자 이름 (선택 사항)",
"vncPasswordOptional": "비밀번호 (선택 사항)",
"vncNoResourceTarget": "사용할 수 있는 리소스 대상이 없습니다",
"vncFailedToLoadNovnc": "noVNC 로드를 실패했습니다",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Område oppdatert",
"siteUpdatedDescription": "Området har blitt oppdatert.",
"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",
"siteResourcesTab": "Ressurser",
"siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Bruk blåkopi",
"actionListBlueprints": "List opp blåkopier",
"actionGetBlueprint": "Hent blåkopi",
"actionCreateOrgWideLauncherView": "Opprett lanseringsvisning for hele organisasjonen",
"setupToken": "Oppsetttoken",
"setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.",
"setupTokenRequired": "Oppsetttoken er nødvendig",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnett",
"addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.",
"selectSites": "Velg områder",
"selectLabels": "Velg etiketter",
"sitesDescription": "Klienten vil ha tilkobling til de valgte områdene",
"clientInstallOlm": "Installer Olm",
"clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Område",
"selectSite": "Velg område...",
"multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}",
"labelsSelectorLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}",
"noSitesFound": "Ingen områder funnet.",
"createInternalResourceDialogProtocol": "Protokoll",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Eksterne Noder",
"remoteExitNodeId": "ID",
"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": {
"title": "Opprett ekstern node",
"description": "Opprett en ny egendrift ekstern relé- og proxyservernode",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnett",
"utilitySubnet": "Nyttesubnett",
"subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.",
"customDomain": "Egendefinert domene",
"authPage": "Autentiseringssider",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-post-hviteliste",
"memberPortalResourceDisabled": "Ressurs deaktivert",
"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",
"memberPortalNext": "Neste",
"httpSettings": "HTTP Innstillinger",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGYNN OPENSSH PRIVAT NØKKEL-----",
"sshPrivateKeyRequired": "Privat nøkkel er påkrevd",
"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)",
"vncNoResourceTarget": "Ingen ressursemål tilgjengelig",
"vncFailedToLoadNovnc": "Klarte ikke å laste noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site bijgewerkt",
"siteUpdatedDescription": "De site is bijgewerkt.",
"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",
"siteResourcesTab": "Bronnen",
"siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Blauwdruk toepassen",
"actionListBlueprints": "Lijst blauwdrukken",
"actionGetBlueprint": "Krijg Blauwdruk",
"actionCreateOrgWideLauncherView": "Maak Organisatiebrede Launcher Weergave",
"setupToken": "Instel Token",
"setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.",
"setupTokenRequired": "Setup-token is vereist",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Subnet",
"addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.",
"selectSites": "Selecteer sites",
"selectLabels": "Selecteer labels",
"sitesDescription": "De client heeft connectiviteit met de geselecteerde sites",
"clientInstallOlm": "Installeer Olm",
"clientInstallOlmDescription": "Laat Olm draaien op uw systeem",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site",
"selectSite": "Selecteer site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
"noSitesFound": "Geen sites gevonden.",
"createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Externe knooppunten",
"remoteExitNodeId": "ID",
"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": {
"title": "Externe knoop aanmaken",
"description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet",
"utilitySubnet": "Hulpmiddel Subnet",
"subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.",
"customDomain": "Aangepast domein",
"authPage": "Authenticatiepagina's",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-mail whitelist",
"memberPortalResourceDisabled": "Bron Uitgeschakeld",
"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",
"memberPortalNext": "Volgende",
"httpSettings": "HTTP-instellingen",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Privésleutel is vereist",
"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)",
"vncNoResourceTarget": "Geen bron doelwit beschikbaar",
"vncFailedToLoadNovnc": "Laden van noVNC mislukt",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Strona zaktualizowana",
"siteUpdatedDescription": "Strona została zaktualizowana.",
"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",
"siteResourcesTab": "Zasoby",
"siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Zastosuj schemat",
"actionListBlueprints": "Lista planów",
"actionGetBlueprint": "Pobierz plan",
"actionCreateOrgWideLauncherView": "Utwórz Widok Uruchamiacza dla Całej Organizacji",
"setupToken": "Skonfiguruj token",
"setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.",
"setupTokenRequired": "Wymagany jest token konfiguracji",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Podsieć",
"addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.",
"selectSites": "Wybierz witryny",
"selectLabels": "Wybierz etykiety",
"sitesDescription": "Klient będzie miał łączność z wybranymi witrynami",
"clientInstallOlm": "Zainstaluj Olm",
"clientInstallOlmDescription": "Uruchom Olm na swoim systemie",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Witryna",
"selectSite": "Wybierz stronę...",
"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.",
"createInternalResourceDialogProtocol": "Protokół",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Zdalne węzły",
"remoteExitNodeId": "ID",
"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": {
"title": "Utwórz zdalny węzeł",
"description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsieć",
"utilitySubnet": "Użyteczna podsieć",
"subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.",
"customDomain": "Niestandardowa domena",
"authPage": "Strony uwierzytelniania",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Biała lista e-mail",
"memberPortalResourceDisabled": "Zasób wyłączony",
"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",
"memberPortalNext": "Następny",
"httpSettings": "Ustawienia HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Wymagany jest klucz prywatny",
"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)",
"vncNoResourceTarget": "Brak dostępnego celu zasobu",
"vncFailedToLoadNovnc": "Błąd ładowania noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site atualizado",
"siteUpdatedDescription": "O site foi atualizado.",
"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",
"siteResourcesTab": "Recursos",
"siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Aplicar Diagrama",
"actionListBlueprints": "Listar Modelos",
"actionGetBlueprint": "Obter Modelo",
"actionCreateOrgWideLauncherView": "Criar Visualização do Lançador para Toda a Organização",
"setupToken": "Configuração do Token",
"setupTokenDescription": "Digite o token de configuração do console do servidor.",
"setupTokenRequired": "Token de configuração é necessário",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Sub-rede",
"addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.",
"selectSites": "Selecionar sites",
"selectLabels": "Selecionar etiquetas",
"sitesDescription": "O cliente terá conectividade com os sites selecionados",
"clientInstallOlm": "Instalar Olm",
"clientInstallOlmDescription": "Execute o Olm em seu sistema",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site",
"selectSite": "Selecionar site...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
"labelsSelectorLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}",
"noSitesFound": "Nenhum site encontrado.",
"createInternalResourceDialogProtocol": "Protocolo",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Nós remotos",
"remoteExitNodeId": "ID",
"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": {
"title": "Criar Nó Remoto",
"description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Provedor Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sub-rede",
"utilitySubnet": "Sub-rede de utilidade",
"subnetDescription": "A sub-rede para a configuração de rede dessa organização.",
"customDomain": "Domínio Personalizado",
"authPage": "Páginas de Autenticação",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Lista de E-mails Permitidos",
"memberPortalResourceDisabled": "Recurso Desativado",
"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",
"memberPortalNext": "Próximo",
"httpSettings": "Configurações HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "Chave privada é necessária",
"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)",
"vncNoResourceTarget": "Nenhum alvo de recurso disponível",
"vncFailedToLoadNovnc": "Falha ao carregar noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Сайт обновлён",
"siteUpdatedDescription": "Сайт был успешно обновлён.",
"siteGeneralDescription": "Настройте общие параметры для этого сайта",
"siteRestartTitle": "Перезагрузить сайт",
"siteRestartDescription": "Перезапустите туннель WireGuard для этого сайта. Это кратковременно прервет соединение.",
"siteRestartBody": "Используйте это, если туннель сайта не работает должным образом и вам нужно принудительно переподключиться без перезапуска хоста.",
"siteRestartButton": "Перезагрузить сайт",
"siteRestartDialogMessage": "Вы уверены, что хотите перезапустить туннель WireGuard для <b>{name}</b>? Сайт кратковременно потеряет соединение.",
"siteRestartWarning": "Сайт кратковременно отключится во время перезапуска туннеля.",
"siteRestarted": "Сайт перезапущен",
"siteRestartedDescription": "Туннель WireGuard был перезапущен.",
"siteErrorRestart": "Не удалось перезапустить сайт",
"siteErrorRestartDescription": "Произошла ошибка во время перезапуска сайта.",
"siteSettingDescription": "Настройка параметров на сайте",
"siteResourcesTab": "Ресурсы",
"siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Применить чертёж",
"actionListBlueprints": "Список чертежей",
"actionGetBlueprint": "Получить чертёж",
"actionCreateOrgWideLauncherView": "Создать вид запуска на уровне организации",
"setupToken": "Код настройки",
"setupTokenDescription": "Введите токен настройки из консоли сервера.",
"setupTokenRequired": "Токен настройки обязателен",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Подсеть",
"addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.",
"selectSites": "Выберите сайты",
"selectLabels": "Выберите метки",
"sitesDescription": "Клиент будет иметь подключение к выбранным сайтам",
"clientInstallOlm": "Установить Olm",
"clientInstallOlmDescription": "Запустите Olm на вашей системе",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Сайт",
"selectSite": "Выберите сайт...",
"multiSitesSelectorSitesCount": "{count, plural, one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}}",
"labelsSelectorLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}",
"noSitesFound": "Сайты не найдены.",
"createInternalResourceDialogProtocol": "Протокол",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Удаленные узлы",
"remoteExitNodeId": "ID",
"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": {
"title": "Создать удалённый узел",
"description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Подсеть",
"utilitySubnet": "Утилита подсети",
"subnetDescription": "Подсеть для конфигурации сети этой организации.",
"customDomain": "Пользовательский домен",
"authPage": "Страницы аутентификации",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "Белый список email",
"memberPortalResourceDisabled": "Ресурс отключён",
"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": "Предыдущий",
"memberPortalNext": "Следующий",
"httpSettings": "Настройки HTTP",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----НАЧАЛО ЛИЧНОГО КЛЮЧА OPENSSH-----",
"sshPrivateKeyRequired": "Требуется личный ключ",
"vncTitle": "VNC",
"vncSignInDescription": "Введите пароль VNC для подключения",
"vncSignInDescription": "Введите ваши учетные данные VNC для подключения",
"vncUsernameOptional": "Имя пользователя (необязательно)",
"vncPasswordOptional": "Пароль (необязательно)",
"vncNoResourceTarget": "Отсутствует целевой ресурс",
"vncFailedToLoadNovnc": "Не удалось загрузить noVNC",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "Site güncellendi",
"siteUpdatedDescription": "Site güncellendi.",
"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",
"siteResourcesTab": "Kaynaklar",
"siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "Planı Uygula",
"actionListBlueprints": "Plan Listesini Görüntüle",
"actionGetBlueprint": "Planı Elde Et",
"actionCreateOrgWideLauncherView": "Kuruluş Genelinde Başlatıcı Görünümü Oluşturma",
"setupToken": "Kurulum Simgesi",
"setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.",
"setupTokenRequired": "Kurulum simgesi gerekli",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "Alt ağ",
"addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.",
"selectSites": "Siteleri seçin",
"selectLabels": "Etiketleri seçin",
"sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır",
"clientInstallOlm": "Olm Yükle",
"clientInstallOlmDescription": "Sisteminizde Olm çalıştırın",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "Site",
"selectSite": "Site seç...",
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# siteler}}",
"labelsSelectorLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}",
"noSitesFound": "Site bulunamadı.",
"createInternalResourceDialogProtocol": "Protokol",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "Uzak Düğümler",
"remoteExitNodeId": "Kimlik",
"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": {
"title": "Uzak Düğüm Oluştur",
"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ı",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
"subnet": "Alt ağ",
"utilitySubnet": "Yardımcı Alt Ağ",
"subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.",
"customDomain": "Özel Alan",
"authPage": "Kimlik Sayfaları",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "E-posta Beyaz Listesi",
"memberPortalResourceDisabled": "Kaynak Devre Dışı",
"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",
"memberPortalNext": "Sonraki",
"httpSettings": "HTTP Ayarları",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BAŞLANGIÇ OPENSSH ÖZEL ANAHTARI-----",
"sshPrivateKeyRequired": "Özel anahtar gereklidir",
"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ı)",
"vncNoResourceTarget": "Kaynak hedefi mevcut değil",
"vncFailedToLoadNovnc": "NoVNC yüklenemedi",

View File

@@ -123,6 +123,16 @@
"siteUpdated": "站点已更新",
"siteUpdatedDescription": "网站已更新。",
"siteGeneralDescription": "配置此站点的常规设置",
"siteRestartTitle": "重启站点",
"siteRestartDescription": "重启此站点的WireGuard隧道。此操作将暂时中断连接。",
"siteRestartBody": "如果站点隧道无法正常工作,并且您希望在不重启主机的情况下强制重新连接,请使用此选项。",
"siteRestartButton": "重启站点",
"siteRestartDialogMessage": "确定要重启<b>{name}</b>的WireGuard隧道吗站点将暂时断开连接。",
"siteRestartWarning": "隧道重启时,站点将暂时断开连接。",
"siteRestarted": "站点已重启",
"siteRestartedDescription": "WireGuard隧道已重启。",
"siteErrorRestart": "重启站点失败",
"siteErrorRestartDescription": "重启站点时发生错误。",
"siteSettingDescription": "配置站点设置",
"siteResourcesTab": "资源",
"siteResourcesNoneOnSite": "此站点尚无公开或私人资源。",
@@ -1401,6 +1411,7 @@
"actionApplyBlueprint": "应用蓝图",
"actionListBlueprints": "列表蓝图",
"actionGetBlueprint": "获取蓝图",
"actionCreateOrgWideLauncherView": "创建组织范围的启动器视图",
"setupToken": "设置令牌",
"setupTokenDescription": "从服务器控制台输入设置令牌。",
"setupTokenRequired": "需要设置令牌",
@@ -2077,6 +2088,7 @@
"subnetPlaceholder": "子网",
"addressDescription": "客户的内部地址。必须属于组织的子网。",
"selectSites": "选择站点",
"selectLabels": "选择标签",
"sitesDescription": "客户端将与所选站点进行连接",
"clientInstallOlm": "安装 Olm",
"clientInstallOlmDescription": "在您的系统上运行 Olm",
@@ -2304,6 +2316,7 @@
"createInternalResourceDialogSite": "站点",
"selectSite": "选择站点...",
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
"labelsSelectorLabelsCount": "{count, plural, other {# 标签}}",
"noSitesFound": "未找到站点。",
"createInternalResourceDialogProtocol": "协议",
"createInternalResourceDialogTcp": "TCP",
@@ -2378,6 +2391,21 @@
"sidebarRemoteExitNodes": "远程节点",
"remoteExitNodeId": "ID",
"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": {
"title": "创建远程节点",
"description": "创建一个新的自托管远程中继和代理服务器节点",
@@ -2556,6 +2584,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "子网",
"utilitySubnet": "实用程序子网",
"subnetDescription": "此组织网络配置的子网。",
"customDomain": "自定义域",
"authPage": "身份验证页面",
@@ -3541,6 +3570,55 @@
"memberPortalEmailWhitelist": "电子邮件白名单",
"memberPortalResourceDisabled": "资源已禁用",
"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": "上一页",
"memberPortalNext": "下一页",
"httpSettings": "HTTP 设置",
@@ -3576,7 +3654,8 @@
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
"sshPrivateKeyRequired": "需要私钥",
"vncTitle": "VNC",
"vncSignInDescription": "输入您的 VNC 密码以连接",
"vncSignInDescription": "输入您的VNC凭据以连接",
"vncUsernameOptional": "用户名(可选)",
"vncPasswordOptional": "密码 (可选)",
"vncNoResourceTarget": "没有可用的资源目标",
"vncFailedToLoadNovnc": "加载 noVNC 失败",

View File

@@ -21,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
createResource = "createResource",

View File

@@ -2,6 +2,7 @@ import {
pgTable,
serial,
varchar,
unique,
boolean,
integer,
bigint,
@@ -19,12 +20,13 @@ import {
roles,
users,
exitNodes,
sessions,
clients,
resources,
siteResources,
targetHealthCheck,
sites
sites,
clients,
sessions,
labels
} from "./schema";
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", {
sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")

View File

@@ -25,7 +25,8 @@ export const domains = pgTable("domains", {
certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert"),
errorMessage: text("errorMessage")
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
});
export const dnsRecords = pgTable("dnsRecords", {
@@ -128,7 +129,8 @@ export const sites = pgTable(
t.exitNodeId,
t.type,
t.siteId
)
),
index("idx_sites_orgid_niceid").on(t.orgId, t.niceId)
]
);
@@ -203,7 +205,9 @@ export const resources = pgTable(
(t) => [
index("idx_resources_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)
]
);
@@ -398,63 +402,77 @@ export const exitNodes = pgTable("exitNodes", {
region: varchar("region")
});
export const siteResources = pgTable("siteResources", {
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.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")
});
export const siteResources = pgTable(
"siteResources",
{
// this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh">()
.notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString")
.notNull()
.default("*"),
udpPortRangeString: varchar("udpPortRangeString")
.notNull()
.default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.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", {
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const networks = pgTable(
"networks",
{
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [index("idx_networks_orgid").on(t.orgId)]
);
export const siteNetworks = pgTable(
"siteNetworks",
@@ -1000,28 +1018,32 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
value: varchar("value").notNull()
});
export const resourcePolicies = pgTable("resourcePolicies", {
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const resourcePolicies = pgTable(
"resourcePolicies",
{
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [index("idx_resourcepolicies_orgid_niceid").on(t.orgId, t.niceId)]
);
export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(),
@@ -1145,7 +1167,10 @@ export const clients = pgTable(
"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(

View File

@@ -12,6 +12,7 @@ import {
clients,
domains,
exitNodes,
labels,
orgs,
resources,
roles,
@@ -21,9 +22,6 @@ import {
targetHealthCheck,
users
} from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -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", {
sessionId: text("id").primaryKey(),
remoteExitNodeId: text("remoteExitNodeId")

View File

@@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", {
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage")
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
});
export const dnsRecords = sqliteTable("dnsRecords", {

View File

@@ -1,28 +1,39 @@
export enum FeatureId {
export enum LimitId {
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations",
ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
TIER1 = "tier1"
}
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
switch (featureId) {
case FeatureId.USERS:
case LimitId.USERS:
return "Users";
case FeatureId.SITES:
case LimitId.SITES:
return "Sites";
case FeatureId.EGRESS_DATA_MB:
case LimitId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
case LimitId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
case LimitId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS:
case LimitId.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";
default:
return featureId;
@@ -30,15 +41,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
}
// 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"
};
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
export function getFeatureMeterId(featureId: LimitId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -49,22 +61,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
}
}
export function getFeatureIdByMetricId(
metricId: string
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
([_, v]) => v === metricId
)?.[0];
}
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
};
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
};
export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -79,11 +89,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
}
export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
};
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
};
export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -98,11 +108,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
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
const allPriceSets = [
getTier1FeaturePriceSet(),
@@ -125,7 +135,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
];
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
);
if (entry) {

View File

@@ -1,19 +1,19 @@
import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features";
import { LimitId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
orgId: string
): 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]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
if (featureId === LimitId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
} else if (featureId === LimitId.TIER1) {
quantity = 1;
}

View File

@@ -1,70 +1,82 @@
import { FeatureId } from "./features";
import { LimitId } from "./features";
export type LimitSet = Partial<{
[key in FeatureId]: {
[key in LimitId]: {
value: number | null; // null indicates no limit
description?: string;
};
}>;
export const freeLimitSet: LimitSet = {
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.SITES]: { value: 5, description: "Basic limit" },
[LimitId.USERS]: { value: 5, description: "Basic limit" },
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[LimitId.REMOTE_EXIT_NODES]: { 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 = {
[FeatureId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.USERS]: { value: 7, description: "Home limit" },
[LimitId.SITES]: { value: 10, description: "Home limit" },
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[LimitId.REMOTE_EXIT_NODES]: { 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 = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 50,
description: "Team limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 50,
description: "Team limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 3,
description: "Team limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 1,
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 = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 250,
description: "Business limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 5,
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 { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet";
import { FeatureId } from "./features";
import { LimitId } from "./features";
import logger from "@server/logger";
class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<number | null> {
const limitId = `${orgId}-${featureId}`;
const [limit] = await db

View File

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

View File

@@ -8,7 +8,7 @@ import {
userSiteResources,
clientSiteResources
} from "@server/db";
import { Config, ConfigSchema } from "./types";
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import {
PublicResourcesResults,
updatePublicResources
@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} 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 = {
orgId: string;
@@ -64,6 +70,7 @@ export async function applyBlueprint({
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);

View File

@@ -25,6 +25,12 @@ import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
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(
siteResourceId: number | undefined,
@@ -413,6 +419,34 @@ export async function updatePrivateResources(
oldSites: existingSiteIds
});
} 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 releaseAliasLock: (() => Promise<void>) | null = null;
if (
@@ -609,6 +643,8 @@ export async function updatePrivateResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,

View File

@@ -51,6 +51,11 @@ import { build } from "@server/build";
import { encrypt } from "@server/lib/crypto";
import { generateId } from "@server/auth/sessions/app";
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 = {
proxyResource: Resource;
@@ -1005,6 +1010,33 @@ export async function updatePublicResources(
logger.debug(`Updated resource ${existingResource.resourceId}`);
} else {
// 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;
if (
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
@@ -1294,6 +1326,8 @@ export async function updatePublicResources(
await createTarget(newResource.resourceId, targetData);
}
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
logger.debug(`Created resource ${newResource.resourceId}`);
}

View File

@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { usageService } from "./billing/usageService";
import { FeatureId } from "./billing";
import { LimitId } from "./billing";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
@@ -140,7 +140,9 @@ export async function deleteOrgById(
.select({ count: count() })
.from(orgDomains)
.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) {
domainIdsToDelete.push(domainId);
}
@@ -152,7 +154,7 @@ export async function deleteOrgById(
.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));
@@ -199,22 +201,22 @@ export async function deleteOrgById(
if (org.billingOrgId) {
usageService.updateCount(
org.billingOrgId,
FeatureId.DOMAINS,
LimitId.DOMAINS,
domainCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.SITES,
LimitId.SITES,
siteCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.USERS,
LimitId.USERS,
userCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0
);
}

View File

@@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes(
orgId: string,
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
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 { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getNextAvailableSubnet(): Promise<string> {
// Get all existing subnets from routes table
const existingAddresses = await db
.select({
address: exitNodes.address
})
.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");
/**
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* 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
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return subnet;
try {
// Get all existing subnets from routes table
const existingAddresses = await trx
.select({
address: exitNodes.address
})
.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) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
const [org] = await transaction
@@ -395,7 +395,7 @@ export async function getNextAvailableAliasAddress(
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
const [org] = await trx
@@ -463,7 +463,7 @@ export async function getNextAvailableOrgSubnet(): Promise<{
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
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()}`;
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 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(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<boolean> {
): Promise<string | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
const token = `${instanceId}:${randomUUID()}`;
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
owner: token,
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
return token;
}
// The lock is currently held -- possibly by a different, unrelated
// caller in this same process. We intentionally do NOT treat
// same-process holders as automatically reentrant here: two
// independent logical operations (e.g. two different API requests)
// running concurrently in the same process must not both believe
// they hold the lock, or their writes under it can interleave
// unguarded. Just retry with backoff like any other contended lock.
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return 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 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);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
if (existing && existing.owner === token) {
localLocks.delete(lockKey);
}
}
@@ -100,23 +104,29 @@ export class LockManager {
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl,
owner: existing.owner.split(":")[0]
};
}
/**
* Extend the TTL of an existing lock owned by this worker
* Extend the TTL of an existing lock, provided the token matches the
* acquisition currently holding it.
* @param lockKey - Unique identifier for the lock
* @param ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @returns Promise<boolean> - true if extended successfully
*/
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
async extendLock(
lockKey: string,
ttlMs: number,
token: string
): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
if (!existing || existing.owner !== token) {
return false;
}
@@ -131,14 +141,14 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts
* @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(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
@@ -148,7 +158,7 @@ export class LockManager {
);
if (acquired) {
return true;
return acquired;
}
if (attempt < maxRetries) {
@@ -158,7 +168,7 @@ export class LockManager {
}
}
return false;
return null;
}
/**
@@ -173,16 +183,16 @@ export class LockManager {
fn: () => Promise<T>,
ttlMs: number = 30000
): 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}`);
}
try {
return await fn();
} finally {
await this.releaseLock(lockKey);
await this.releaseLock(lockKey, token);
}
}
@@ -204,7 +214,7 @@ export class LockManager {
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
if (value.owner.startsWith(`${instanceId}:`)) {
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,23 @@ import {
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
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
// peer/proxy updates, so give them a generous window.
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_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL
const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000;
@@ -271,6 +283,7 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource
) {
await incrementOrgRebuildCount(siteResource.orgId);
try {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
@@ -292,6 +305,8 @@ export async function rebuildClientAssociationsFromSiteResource(
return { mergedAllClients: [] };
}
throw err;
} finally {
await decrementOrgRebuildCount(siteResource.orgId);
}
}
@@ -1638,8 +1653,9 @@ export async function handleMessagingForUpdatedSiteResource(
export async function rebuildClientAssociationsFromClient(
client: Client
): Promise<void> {
const trx = primaryDb;
await incrementOrgRebuildCount(client.orgId);
try {
const trx = primaryDb;
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
@@ -1660,6 +1676,8 @@ export async function rebuildClientAssociationsFromClient(
return;
}
throw err;
} finally {
await decrementOrgRebuildCount(client.orgId);
}
}

View File

@@ -1,3 +1,5 @@
import logger from "@server/logger";
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
@@ -16,12 +18,104 @@ export interface RebuildQueueManager {
isQueued(job: RebuildJob): Promise<boolean>;
}
class NoopRebuildQueue implements RebuildQueueManager {
async enqueue(_job: RebuildJob): Promise<void> {}
startProcessing(_handlers: RebuildJobHandlers): void {}
async isQueued(_job: RebuildJob): Promise<boolean> {
return false;
// In-process FIFO used when there is no Redis to back a distributed queue
// (OSS build, or Redis unavailable). A job that loses the per-resource
// rebuild lock race lands here instead of being silently dropped, and gets
// retried shortly after against fresh DB state.
const POLL_INTERVAL_MS = 500;
const BATCH_SIZE = 5;
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) {
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";
import { eq, and, inArray, ne, exists } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
export async function assignUserToOrg(
org: Org,
@@ -61,7 +61,7 @@ export async function assignUserToOrg(
);
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) {
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,
targets,
sites,
siteLabels,
remoteExitNodes,
remoteExitNodePreferenceLabels,
targetHealthCheck,
Transaction
} from "@server/db";
import logger from "@server/logger";
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 config from "../config";
@@ -150,7 +153,8 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes(
orgId: string,
filterOnline = false,
noCloud = false
noCloud = false,
siteId?: number
) {
const allExitNodes = await db
.select({
@@ -237,7 +241,7 @@ export async function listExitNodes(
// })
// );
const remoteExitNodes = allExitNodes.filter(
let remoteExitNodesList = allExitNodes.filter(
(node) =>
node.type === "remoteExitNode" && (!filterOnline || node.online)
);
@@ -246,9 +250,82 @@ export async function listExitNodes(
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
const exitNodesList =
remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes;
remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes;
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
* @param lockKey - Unique identifier for the lock
* @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(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<boolean> {
): Promise<string | null> {
if (!redis || !redis.status || redis.status !== "ready") {
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
const token = `${instanceId}:${uuidv4()}`;
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
owner: token,
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
return token;
}
// Do not treat a same-process holder as automatically
// reentrant -- see the note in the Redis branch below.
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
return null;
}
const lockValue = `${instanceId}:${Date.now()}`;
const redisKey = `lock:${lockKey}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
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)
// This is atomic and handles both setting and expiration
const result = await redis.set(
@@ -93,19 +97,7 @@ export class LockManager {
if (result === "OK") {
logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`);
return true;
}
// 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;
return lockValue;
}
// If this isn't our last attempt, wait before retrying with exponential backoff
@@ -132,18 +124,23 @@ export class LockManager {
logger.debug(
`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 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") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
if (existing && existing.owner === token) {
localLocks.delete(lockKey);
}
return;
@@ -151,13 +148,12 @@ export class LockManager {
const redisKey = `lock:${lockKey}`;
// Lua script to ensure we only delete the lock if it belongs to this worker
const luaScript = `
local key = KEYS[1]
local worker_prefix = ARGV[1]
local expected_value = ARGV[1]
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)
else
return 0
@@ -169,16 +165,14 @@ export class LockManager {
luaScript,
1,
redisKey,
`${instanceId}:`
token
)) as number;
if (result === 1) {
logger.debug(`Lock released: ${lockKey} by ${instanceId}`);
} else {
logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${
instanceId
}`
`Lock not released - token did not match current holder: ${lockKey} (attempted by ${instanceId})`
);
}
} catch (error) {
@@ -230,7 +224,7 @@ export class LockManager {
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl,
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 ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @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") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
if (!existing || existing.owner !== token) {
return false;
}
@@ -282,14 +282,13 @@ export class LockManager {
const redisKey = `lock:${lockKey}`;
// Lua script to extend TTL only if lock is owned by this worker
const luaScript = `
local key = KEYS[1]
local worker_prefix = ARGV[1]
local expected_value = ARGV[1]
local ttl = tonumber(ARGV[2])
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)
else
return 0
@@ -301,7 +300,7 @@ export class LockManager {
luaScript,
1,
redisKey,
`${instanceId}:`,
token,
ttlMs.toString()
)) as number;
@@ -324,14 +323,14 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts
* @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(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
@@ -341,7 +340,7 @@ export class LockManager {
);
if (acquired) {
return true;
return acquired;
}
if (attempt < maxRetries) {
@@ -355,7 +354,7 @@ export class LockManager {
logger.warn(
`Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts`
);
return false;
return null;
}
/**
@@ -370,16 +369,16 @@ export class LockManager {
fn: () => Promise<T>,
ttlMs: number = 30000
): 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}`);
}
try {
return await fn();
} finally {
await this.releaseLock(lockKey);
await this.releaseLock(lockKey, token);
}
}
@@ -402,7 +401,7 @@ export class LockManager {
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
if (value.owner.startsWith(`${instanceId}:`)) {
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 = 5;
// 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 redisManager from "#private/lib/redis";
import { regionalRedisManager as redisManager } from "#private/lib/redis";
import { build } from "@server/build";
// Rate limiting configuration
@@ -152,10 +152,9 @@ export class RateLimitService {
);
// Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) {
await redisManager
.getClient()
.expire(globalKey, RATE_LIMIT_WINDOW + 10);
const writeClient = redisManager.getClient();
if (writeClient) {
await writeClient.expire(globalKey, RATE_LIMIT_WINDOW + 10);
}
// Update tracking
@@ -204,10 +203,12 @@ export class RateLimitService {
);
// Set TTL using the client directly - this prevents the key from persisting forever
if (redisManager.getClient()) {
await redisManager
.getClient()
.expire(messageTypeKey, RATE_LIMIT_WINDOW + 10);
const writeClient = redisManager.getClient();
if (writeClient) {
await writeClient.expire(
messageTypeKey,
RATE_LIMIT_WINDOW + 10
);
}
// Update tracking
@@ -487,16 +488,13 @@ export class RateLimitService {
await redisManager.del(globalKey);
// Get all message type keys for this client and delete them
const client = redisManager.getClient();
if (client) {
const messageTypeKeys = await client.keys(
`ratelimit:${clientId}:*`
const messageTypeKeys = await redisManager.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

@@ -894,6 +894,19 @@ class RegionalRedisManager {
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 {
const cfg = this.getConfig();
const baseOpts = {
@@ -907,35 +920,42 @@ class RegionalRedisManager {
try {
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
// The real replica routing is handled by the StatefulSet headless service
this.readClient = this.writeClient;
const replicaHost = this.getReplicaHost(cfg.host!);
this.readClient = replicaHost
? new Redis({ ...baseOpts, host: replicaHost })
: this.writeClient;
this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready");
logger.info("Regional Redis write client ready");
this.isHealthy = true;
});
this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err);
logger.error("Regional Redis write client error:", err);
this.isHealthy = false;
});
this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting...");
logger.info("Regional Redis write client reconnecting...");
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) {
logger.error("Failed to initialize regional Redis client:", error);
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> {
try {
if (this.readClient && this.readClient !== this.writeClient) {
await this.readClient.quit();
}
this.readClient = null;
if (this.writeClient) {
await this.writeClient.quit();
this.writeClient = null;
}
this.readClient = null;
logger.info("Regional Redis client disconnected");
} catch (error) {
logger.error("Error disconnecting regional Redis client:", error);

View File

@@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog";
export * from "./exportAccessAuditLog";
export * from "./queryConnectionAuditLog";
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 { NextFunction } 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 { z } from "zod";
import createHttpError from "http-errors";
@@ -120,7 +120,10 @@ function getWhere(data: Q) {
lt(accessAuditLog.timestamp, data.timeEnd),
eq(accessAuditLog.orgId, data.orgId),
data.resourceId
? eq(accessAuditLog.resourceId, data.resourceId)
? or(
eq(accessAuditLog.resourceId, data.resourceId),
eq(accessAuditLog.siteResourceId, data.resourceId)
)
: undefined,
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
data.actorType
@@ -233,7 +236,6 @@ async function enrichWithResourceDetails(
const details = siteResourceMap.get(log.siteResourceId);
return {
...log,
resourceId: log.siteResourceId,
resourceName: details?.name ?? null,
resourceNiceId: details?.niceId ?? null
};

View File

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

View File

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

View File

@@ -329,6 +329,44 @@ authenticated.delete(
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(
"/org/:orgId/login-page",
verifyValidLicense,
@@ -495,29 +533,31 @@ authRouter.post(
auth.transferSession
);
authenticated.post(
"/license/activate",
verifyUserIsServerAdmin,
license.activateLicense
);
if (build !== "saas") {
authenticated.post(
"/license/activate",
verifyUserIsServerAdmin,
license.activateLicense
);
authenticated.get(
"/license/keys",
verifyUserIsServerAdmin,
license.listLicenseKeys
);
authenticated.get(
"/license/keys",
verifyUserIsServerAdmin,
license.listLicenseKeys
);
authenticated.delete(
"/license/:licenseKey",
verifyUserIsServerAdmin,
license.deleteLicenseKey
);
authenticated.delete(
"/license/:licenseKey",
verifyUserIsServerAdmin,
license.deleteLicenseKey
);
authenticated.post(
"/license/recheck",
verifyUserIsServerAdmin,
license.recheckStatus
);
authenticated.post(
"/license/recheck",
verifyUserIsServerAdmin,
license.recheckStatus
);
}
authenticated.get(
"/org/:orgId/logs/action",
@@ -878,3 +918,9 @@ authenticated.post(
verifyClientAccess,
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));
let exitNode: ExitNode;
if (!exitNodeQuery) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
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 {
// update the reachable at
[exitNode] = await db

View File

@@ -215,7 +215,7 @@ export async function sendTrialNotification(
if (resetLimits) {
// 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(
`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 { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { OpenAPITags, registry } from "@server/openApi";
import { isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z
.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
.select({
userId: userOrgs.userId,

View File

@@ -35,7 +35,7 @@ import logger from "@server/logger";
import { and, eq, inArray, ne } from "drizzle-orm";
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
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";
export const paramsSchema = z.object({
@@ -79,7 +79,10 @@ export async function createRemoteExitNode(
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(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -87,13 +90,13 @@ export async function createRemoteExitNode(
const usage = await usageService.getUsage(
orgId,
FeatureId.REMOTE_EXIT_NODES
LimitId.REMOTE_EXIT_NODES
);
if (usage) {
const rejectRemoteExitNodes = await usageService.checkLimitSet(
orgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -111,8 +114,6 @@ export async function createRemoteExitNode(
}
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
.select()
@@ -188,89 +189,106 @@ export async function createRemoteExitNode(
);
}
await db.transaction(async (trx) => {
if (!existingExitNode) {
const [res] = await trx
.insert(exitNodes)
.values({
name: remoteExitNodeId,
address,
endpoint: "",
publicKey: "",
listenPort: 0,
online: false,
type: "remoteExitNode"
})
.returning();
existingExitNode = res;
}
// If this remote exit node isn't already backing an exit node in
// another org, we're about to create a brand new one. Reserve a
// subnet for it up front so the allocation lock is held across the
// whole insert - this guarantees exit node subnets never overlap,
// even under concurrent creation, which matters for HA setups.
let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null;
if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet();
newExitNodeAddress = value;
releaseSubnetLock = release;
}
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,
FeatureId.REMOTE_EXIT_NODES,
1,
trx
);
try {
await db.transaction(async (trx) => {
if (!existingExitNode) {
const [res] = await trx
.insert(exitNodes)
.values({
name: remoteExitNodeId,
address: newExitNodeAddress!,
endpoint: "",
publicKey: "",
listenPort: 0,
online: false,
type: "remoteExitNode"
})
.returning();
existingExitNode = res;
}
}
});
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();
await createRemoteExitNodeSession(token, remoteExitNodeId);

View File

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

View File

@@ -23,3 +23,7 @@ export * from "./pickRemoteExitNodeDefaults";
export * from "./quickStartRemoteExitNode";
export * from "./offlineChecker";
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 { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addUserRoleParamsSchema = z.strictObject({
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: {
userId: string;
orgId: string;

View File

@@ -21,7 +21,10 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const setUserOrgRolesParamsSchema = z.strictObject({
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
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles)

View File

@@ -26,7 +26,7 @@ import {
} from "@server/db";
import { eq } from "drizzle-orm";
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 { validateOlmSessionToken } from "@server/auth/sessions/olm";
import logger from "@server/logger";
@@ -1063,7 +1063,7 @@ const setupConnection = async (
// pending pings in a single batched UPDATE every ~10s, which
// prevents connection pool exhaustion under load (especially
// 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;
actorId: string | null;
resourceId: number | null;
siteResourceId: number | null;
resourceName: string | null;
resourceNiceId: 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 { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
@@ -220,7 +220,7 @@ export async function deleteMyAccount(
await trx.delete(users).where(eq(users.userId, userId));
// loop through the other orgs and decrement the count
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 { generateId } from "@server/auth/sessions/app";
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 { build } from "@server/build";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const createClientParamsSchema = z.strictObject({
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));
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
// make sure the subnet is unique
@@ -277,6 +323,8 @@ export async function createClient(
clientId: newClient.clientId,
dateCreated: moment().toISOString()
});
await usageService.add(orgId, LimitId.MACHINE_CLIENTS, 1, trx);
});
if (newClient) {
@@ -291,7 +339,7 @@ export async function createClient(
data: newClient,
success: true,
error: false,
message: "Site created successfully",
message: "Client created successfully",
status: HttpCode.CREATED
});
} catch (error) {

View File

@@ -21,7 +21,10 @@ import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { listExitNodes } from "#dynamic/lib/exitNodes";
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";
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
// make sure the subnet is unique

View File

@@ -9,9 +9,14 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
import { LimitId } from "@server/lib/billing/features";
import { usageService } from "@server/lib/billing/usageService";
const deleteClientSchema = z.strictObject({
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)
if (client.userId) {
return next(
@@ -106,6 +120,13 @@ export async function deleteClient(
if (!client.userId && client.olmId) {
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
}
await usageService.add(
deletedClient.orgId,
LimitId.MACHINE_CLIENTS,
-1,
trx
);
});
if (deletedClient) {

View File

@@ -9,7 +9,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { rebuildClientAssociationsFromClient, isOrgRebuildRateLimited } from "@server/lib/rebuildClientAssociations";
const paramsSchema = z.strictObject({
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) => {
logger.error(
`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 { eq, and } from "drizzle-orm";
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 { build } from "@server/build";
import config from "@server/lib/config";
@@ -120,7 +120,7 @@ export async function createOrgDomain(
}
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS);
const usage = await usageService.getUsage(orgId, LimitId.DOMAINS);
if (!usage) {
return next(
createHttpError(
@@ -132,7 +132,7 @@ export async function createOrgDomain(
const rejectDomains = await usageService.checkLimitSet(
orgId,
FeatureId.DOMAINS,
LimitId.DOMAINS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -346,7 +346,7 @@ export async function createOrgDomain(
await trx.insert(dnsRecords).values(recordsToInsert);
}
await usageService.add(orgId, FeatureId.DOMAINS, 1, trx);
await usageService.add(orgId, LimitId.DOMAINS, 1, trx);
});
if (!returned) {

View File

@@ -8,7 +8,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { and, eq } from "drizzle-orm";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
const paramsSchema = z.strictObject({
domainId: z.string(),
@@ -77,7 +77,7 @@ export async function deleteAccountDomain(
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, {

View File

@@ -255,6 +255,14 @@ authenticated.delete(
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"
authenticated.get(
"/site/:siteId/docker/status",
@@ -959,19 +967,6 @@ unauthenticated.post(
);
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(
"/org/:orgId/user",
verifyOrgAccess,
@@ -994,12 +989,6 @@ authenticated.post(
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess);
authenticated.post(
"/user/:userId/2fa",
verifyUserIsServerAdmin,
user.updateUser2FA
);
authenticated.get(
"/org/:orgId/users",
verifyOrgAccess,
@@ -1082,85 +1071,112 @@ authenticated.post(
olm.recoverOlmWithFingerprint
);
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
if (build !== "saas") {
authenticated.put(
"/idp/oidc",
verifyUserIsServerAdmin,
// verifyUserHasAction(ActionsEnum.createIdp),
idp.createOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
idp.updateOidcIdp
);
authenticated.post(
"/idp/:idpId/oidc",
verifyUserIsServerAdmin,
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(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.put(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.createIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.post(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.updateIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.delete(
"/idp/:idpId/org/:orgId",
verifyUserIsServerAdmin,
idp.deleteIdpOrgPolicy
);
authenticated.get(
"/idp/:idpId/org",
verifyUserIsServerAdmin,
idp.listIdpOrgPolicies
);
authenticated.get(
"/idp/:idpId/org",
verifyUserIsServerAdmin,
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/: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(
`/org/:orgId/api-keys`,

View File

@@ -13,37 +13,41 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode;
if (!exitNodeQuery) {
const address = await getNextAvailableSubnet();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
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 {
// update the existing exit node
[exitNode] = await db

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
db,
ExitNode,
networks,
remoteExitNodeResources,
resources,
Site,
siteNetworks,
@@ -223,7 +224,8 @@ export async function buildClientConfigurationForNewtClient(
export async function buildTargetConfigurationForNewtClient(
siteId: number,
version?: string | null
version?: string | null,
remoteExitNodeId?: string
) {
// Get all enabled targets with their resource mode information
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 {
validHealthCheckTargets,
tcpTargets,
udpTargets,
browserGatewayTargets
browserGatewayTargets,
remoteExitNodeSubnets
};
}

View File

@@ -5,7 +5,20 @@ import { Newt } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
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.
@@ -35,7 +48,15 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
// batched UPDATE instead of one query per ping. This prevents
// connection pool exhaustion under load, especially with
// 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.
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})`
);
// 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
// .select()
// .from(sites)
// .where(eq(sites.siteId, newt.siteId))
// .limit(1);
if (!site) {
logger.warn(
`Newt ping message: site with ID ${newt.siteId} not found`
);
return;
}
// if (!site) {
// logger.warn(
// `Newt ping message: site with ID ${newt.siteId} not found`
// );
// return;
// }
// await sendNewtSyncMessage(newt, site);
await sendNewtSyncMessage(newt, site);
}
return {
message: {
type: "pong",
data: {
timestamp: new Date().toISOString()
}
},
broadcast: false,
excludeSender: false
};
return PONG;
};

View File

@@ -38,7 +38,8 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
const exitNodesList = await listExitNodes(
site.orgId,
true,
noCloud || false
noCloud || false,
newt.siteId
); // filter for only the online ones
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 { exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
@@ -196,12 +196,29 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
.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 {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
browserGatewayTargets,
remoteExitNodeSubnets
} = await buildTargetConfigurationForNewtClient(
siteId,
newtVersion,
remoteExitNodeId // this is for the remote node resources
);
logger.debug(
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
@@ -222,6 +239,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
},
healthCheckTargets: validHealthCheckTargets,
browserGatewayTargets: browserGatewayTargets,
remoteExitNodeSubnets: remoteExitNodeSubnets,
chainId: chainId
}
},

View File

@@ -57,9 +57,6 @@ export function recordSitePing(siteId: number): void {
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
* (`online`, `lastPing`, `archived`) and, when `olmArchived` is true,

View File

@@ -25,7 +25,7 @@ import { getUniqueSiteName } from "@server/db/names";
import moment from "moment";
import { build } from "@server/build";
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 { getNextAvailableClientSubnet } from "@server/lib/ip";
@@ -169,7 +169,7 @@ export async function registerNewt(
// SaaS billing check
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
const usage = await usageService.getUsage(orgId, LimitId.SITES);
if (!usage) {
return next(
createHttpError(
@@ -180,7 +180,7 @@ export async function registerNewt(
}
const rejectSites = await usageService.checkLimitSet(
orgId,
FeatureId.SITES,
LimitId.SITES,
{
...usage,
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 {
await releaseSubnetLock();

View File

@@ -13,9 +13,9 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
tcpTargets,
udpTargets,
validHealthCheckTargets,
browserGatewayTargets
browserGatewayTargets,
remoteExitNodeSubnets
} = await buildTargetConfigurationForNewtClient(site.siteId);
let exitNode: ExitNode | undefined;
if (site.exitNodeId) {
[exitNode] = await db
@@ -28,7 +28,6 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
site,
exitNode
);
await sendToClient(
newt.newtId,
{
@@ -41,7 +40,8 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
healthCheckTargets: validHealthCheckTargets,
peers: peers,
clientTargets: targets,
browserGatewayTargets: browserGatewayTargets
browserGatewayTargets: browserGatewayTargets,
remoteExitNodeSubnets: remoteExitNodeSubnets
}
},
{

View File

@@ -9,7 +9,10 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
@@ -64,6 +67,30 @@ export async function deleteUserOlm(
const { olmId } = parsedParams.data;
// get the client first
const [client] = await db
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
if (!client) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`No client found for olmId ${olmId}`
)
);
}
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."
)
);
}
let deletedClient: Client | undefined;
// Delete associated clients and the OLM in a transaction
await db.transaction(async (trx) => {

View File

@@ -27,7 +27,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { isValidCIDR } from "@server/lib/validators";
import { createCustomer } from "#dynamic/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing";
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { doCidrsOverlap } from "@server/lib/ip";
@@ -202,7 +202,7 @@ export async function createOrg(
if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS
LimitId.ORGANIZATIONS
);
if (!usage) {
return next(
@@ -214,7 +214,7 @@ export async function createOrg(
}
const rejectOrgs = await usageService.checkLimitSet(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS,
LimitId.ORGANIZATIONS,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -421,7 +421,7 @@ export async function createOrg(
if (customerId) {
await usageService.updateCount(
orgId,
FeatureId.USERS,
LimitId.USERS,
1,
customerId
); // Only 1 because we are creating the org
@@ -431,7 +431,7 @@ export async function createOrg(
if (numOrgs) {
usageService.updateCount(
billingOrgIdForNewOrg || orgId,
FeatureId.ORGINIZATIONS,
LimitId.ORGANIZATIONS,
numOrgs
);
}

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -43,3 +43,37 @@ export type GetRemoteExitNodeResponse = {
online: boolean;
type: string | null;
};
export type ListRemoteExitNodeResourcesResponse = {
resources: {
remoteExitNodeResourceId: number;
remoteExitNodeId: string;
destination: string;
}[];
};
export type SetRemoteExitNodeResourcesResponse = {
resources: {
remoteExitNodeResourceId: number;
remoteExitNodeId: string;
destination: string;
}[];
};
export type ListRemoteExitNodePreferenceLabelsResponse = {
labels: {
remoteExitNodePreferenceLabelId: number;
labelId: number;
name: string;
color: string;
}[];
};
export type SetRemoteExitNodePreferenceLabelsResponse = {
labels: {
remoteExitNodePreferenceLabelId: number;
labelId: number;
name: string;
color: string;
}[];
};

View File

@@ -36,6 +36,8 @@ import {
getUniqueResourceName,
getUniqueResourcePolicyName
} from "@server/db/names";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
const createResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -235,6 +237,38 @@ export async function createResource(
req.body.mode = resolvedMode.mode;
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PUBLIC_RESOURCES
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
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) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Public resource limit exceeded. Please upgrade your plan."
)
);
}
}
if (typeof req.body.proxyPort === "number") {
if (
!config.getRawConfig().flags?.allow_raw_resources &&
@@ -503,6 +537,8 @@ async function createHttpResource(
}
resource = newResource[0];
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
});
if (!resource) {
@@ -631,6 +667,8 @@ async function createRawResource(
}
resource = newResource[0];
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
});
if (!resource) {

View File

@@ -11,6 +11,8 @@ import {
performDeleteResource,
runResourceDeleteSideEffects
} from "@server/lib/deleteResource";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const deleteResourceSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
@@ -64,6 +66,14 @@ export async function deleteResource(
await db.transaction(async (trx) => {
deleteResult = await performDeleteResource(resourceId, trx);
if (deleteResult?.deletedResource?.orgId) {
await usageService.add(
deleteResult?.deletedResource?.orgId,
LimitId.PUBLIC_RESOURCES,
-1,
trx
);
}
});
if (!deleteResult) {

View File

@@ -19,7 +19,7 @@ import { getNextAvailableClientSubnet, isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { generateId } from "@server/auth/sessions/app";
const createSiteParamsSchema = z.strictObject({
@@ -160,7 +160,7 @@ export async function createSite(
}
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.SITES);
const usage = await usageService.getUsage(orgId, LimitId.SITES);
if (!usage) {
return next(
createHttpError(
@@ -172,7 +172,7 @@ export async function createSite(
const rejectSites = await usageService.checkLimitSet(
orgId,
FeatureId.SITES,
LimitId.SITES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
@@ -519,7 +519,7 @@ export async function createSite(
});
}
await usageService.add(orgId, FeatureId.SITES, 1, trx);
await usageService.add(orgId, LimitId.SITES, 1, trx);
});
} finally {
await releaseSubnetLock?.();

View File

@@ -13,7 +13,7 @@ import { sendToClient } from "#dynamic/routers/ws";
import { OpenAPITags, registry } from "@server/openApi";
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { LimitId } from "@server/lib/billing";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAssociatedResourcesForSite,
@@ -177,7 +177,7 @@ export async function deleteSite(
}
await trx.delete(sites).where(eq(sites.siteId, siteId));
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
});
if (deleteResources) {

View File

@@ -7,3 +7,4 @@ export * from "./listSites";
export * from "./listSiteRoles";
export * from "./pickSiteDefaults";
export * from "./socketIntegration";
export * from "./restartSite";

View File

@@ -0,0 +1,114 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, newts } from "@server/db";
import { 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 { OpenAPITags, registry } from "@server/openApi";
import { sendToClient } from "#dynamic/routers/ws";
const updateSiteParamsSchema = z.strictObject({
siteId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "post",
path: "/site/{siteId}/restart",
description: "Restart a site.",
tags: [OpenAPITags.Site],
request: {
params: updateSiteParamsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function restartSite(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { siteId } = parsedParams.data;
const [existingSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!existingSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
// get the newt
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (!newt) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Newt for site with ID ${siteId} not found`
)
);
}
logger.info(`Restarting site ${siteId}...`);
await sendToClient(newt.newtId, {
type: "newt/wg/restart",
data: {}
});
return response(res, {
data: null,
success: true,
error: false,
message: "Site restarted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -8,7 +8,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addClientToSiteResourceBodySchema = z
.object({
@@ -128,6 +131,15 @@ export async function addClientToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if client already exists in site resource
const existingEntry = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addRoleToSiteResourceBodySchema = z
.object({
@@ -104,6 +107,15 @@ export async function addRoleToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// verify the role exists and belongs to the same org
const [role] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const addUserToSiteResourceBodySchema = z
.object({
@@ -104,6 +107,15 @@ export async function addUserToSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if user already exists in site resource
const existingEntry = await db
.select()

View File

@@ -15,7 +15,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromClient,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const batchAddClientToSiteResourcesParamsSchema = z
.object({
@@ -186,6 +189,15 @@ export async function batchAddClientToSiteResources(
);
}
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."
)
);
}
if (client.userId !== null) {
return next(
createHttpError(

View File

@@ -21,7 +21,10 @@ import {
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -34,6 +37,8 @@ import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService";
import { LimitId } from "@server/lib/billing";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -291,6 +296,38 @@ export async function createSiteResource(
siteIds.push(siteId);
}
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No usage data found for this organization"
)
);
}
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) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Private resource limit exceeded. Please upgrade your plan."
)
);
}
}
if (mode == "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
@@ -339,6 +376,15 @@ export async function createSiteResource(
);
}
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."
)
);
}
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
@@ -593,6 +639,13 @@ export async function createSiteResource(
);
}
}
await usageService.add(
orgId,
LimitId.PRIVATE_RESOURCES,
1,
trx
);
});
} finally {
await releaseAliasLock?.();

View File

@@ -12,6 +12,8 @@ import {
performDeleteSiteResource,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import { LimitId } from "@server/lib/billing";
import { usageService } from "@server/lib/billing/usageService";
const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
@@ -86,6 +88,14 @@ export async function deleteSiteResource(
siteResourceId,
trx
);
if (removedSiteResource?.orgId) {
await usageService.add(
removedSiteResource?.orgId,
LimitId.PRIVATE_RESOURCES,
-1,
trx
);
}
});
if (!removedSiteResource) {

View File

@@ -8,7 +8,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeClientFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,14 @@ export async function removeClientFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if client exists and has a userId
const [client] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeRoleFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,15 @@ export async function removeRoleFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if the role is an admin role
const [roleToCheck] = await db
.select()

View File

@@ -9,7 +9,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
rebuildClientAssociationsFromSiteResource,
isOrgRebuildRateLimited
} from "@server/lib/rebuildClientAssociations";
const removeUserFromSiteResourceBodySchema = z
.object({
@@ -106,6 +109,15 @@ export async function removeUserFromSiteResource(
);
}
if (await isOrgRebuildRateLimited(siteResource.orgId)) {
return next(
createHttpError(
HttpCode.TOO_MANY_REQUESTS,
"Too many concurrent rebuild operations for this organization. Please retry after a moment."
)
);
}
// Check if user exists in site resource
const existingEntry = await db
.select()

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