mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-03 12:29:53 +00:00
Compare commits
66 Commits
resource-l
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93d26f09f | ||
|
|
fc54ad49b5 | ||
|
|
f87e136f6b | ||
|
|
1bf3d2cdd6 | ||
|
|
5fc5a3ebca | ||
|
|
e40f325703 | ||
|
|
8f377a4fb2 | ||
|
|
3699f8f9cb | ||
|
|
5a2388a1e6 | ||
|
|
86e6ebc8af | ||
|
|
c82678852e | ||
|
|
2e3ab10f5e | ||
|
|
4985ed02d3 | ||
|
|
a2fea7f714 | ||
|
|
ddba2aff21 | ||
|
|
da9e668fb8 | ||
|
|
9c2d14d8c6 | ||
|
|
c383e74df4 | ||
|
|
40101f8bb0 | ||
|
|
b20ed9efa9 | ||
|
|
56266e3b62 | ||
|
|
47eff8c948 | ||
|
|
15f36096ef | ||
|
|
064252586d | ||
|
|
6181d46a1e | ||
|
|
9747731668 | ||
|
|
2a478eef6f | ||
|
|
4ab101f8a9 | ||
|
|
807613f28c | ||
|
|
bcc128aeb6 | ||
|
|
ba33cb9895 | ||
|
|
69e7fedcfc | ||
|
|
61fc2e5ea7 | ||
|
|
108cb6216c | ||
|
|
e3ef592778 | ||
|
|
e98bcb83ac | ||
|
|
80284863bb | ||
|
|
296439fd67 | ||
|
|
31f675f38c | ||
|
|
cfbbdedaf5 | ||
|
|
686789ee4c | ||
|
|
3fda190ff6 | ||
|
|
0033f40f4d | ||
|
|
af95052706 | ||
|
|
9bb2d6cdc8 | ||
|
|
29563a13a4 | ||
|
|
b41c1f5b27 | ||
|
|
e5652cdb8a | ||
|
|
7c2ea153c5 | ||
|
|
ccabddc225 | ||
|
|
42d98fa83b | ||
|
|
2f2b7f43c1 | ||
|
|
528bbeca26 | ||
|
|
d60c15b0ae | ||
|
|
ff89a64453 | ||
|
|
4718c489d3 | ||
|
|
d5d99a4804 | ||
|
|
9c18936be7 | ||
|
|
cf07cceb5d | ||
|
|
faee9e6330 | ||
|
|
c9cc9581b1 | ||
|
|
eac7c67dcc | ||
|
|
633d9031af | ||
|
|
05dc558c4a | ||
|
|
784588cebc | ||
|
|
7590e8d8a1 |
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 로드를 실패했습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 失败",
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum ActionsEnum {
|
||||
getSite = "getSite",
|
||||
listSites = "listSites",
|
||||
updateSite = "updateSite",
|
||||
restartSite = "restartSite",
|
||||
resetSiteBandwidth = "resetSiteBandwidth",
|
||||
reGenerateSecret = "reGenerateSecret",
|
||||
createResource = "createResource",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
24
server/lib/orgRebuildCounter.ts
Normal file
24
server/lib/orgRebuildCounter.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
105
server/private/lib/orgRebuildCounter.ts
Normal file
105
server/private/lib/orgRebuildCounter.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,3 +17,4 @@ export * from "./queryAccessAuditLog";
|
||||
export * from "./exportAccessAuditLog";
|
||||
export * from "./queryConnectionAuditLog";
|
||||
export * from "./exportConnectionAuditLog";
|
||||
export * from "./logAccessAuditAttempt";
|
||||
|
||||
95
server/private/routers/auditLogs/logAccessAuditAttempt.ts
Normal file
95
server/private/routers/auditLogs/logAccessAuditAttempt.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
1
server/routers/remoteExitNode/index.ts
Normal file
1
server/routers/remoteExitNode/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
@@ -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;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from "./listSites";
|
||||
export * from "./listSiteRoles";
|
||||
export * from "./pickSiteDefaults";
|
||||
export * from "./socketIntegration";
|
||||
export * from "./restartSite";
|
||||
|
||||
114
server/routers/site/restartSite.ts
Normal file
114
server/routers/site/restartSite.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user