Compare commits

..

41 Commits

Author SHA1 Message Date
Owen Schwartz 7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
Owen 053ff1e799 Add utility subnet 2026-06-25 15:39:04 -04:00
Owen Schwartz c5ffca499e Merge pull request #3330 from fosrl/dev
1.19.3
2026-06-25 11:54:25 -07:00
Owen Schwartz 9ae54f445d Merge pull request #3343 from fosrl/crowdin_dev
New Crowdin updates
2026-06-25 11:45:14 -07:00
Owen Schwartz 8183d19400 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-25 11:44:44 -07:00
Owen Schwartz 7425daad3f New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-25 11:44:43 -07:00
Owen Schwartz 39d61a35eb New translations en-us.json (Turkish)
[ci skip]
2026-06-25 11:44:41 -07:00
Owen Schwartz f3fe11c136 New translations en-us.json (Russian)
[ci skip]
2026-06-25 11:44:39 -07:00
Owen Schwartz 66b1b385a3 New translations en-us.json (Portuguese)
[ci skip]
2026-06-25 11:44:37 -07:00
Owen Schwartz 822a07d48e New translations en-us.json (Polish)
[ci skip]
2026-06-25 11:44:35 -07:00
Owen Schwartz 3c13b1ea15 New translations en-us.json (Dutch)
[ci skip]
2026-06-25 11:44:33 -07:00
Owen Schwartz fee635b861 New translations en-us.json (Korean)
[ci skip]
2026-06-25 11:44:31 -07:00
Owen Schwartz 7e4dea918a New translations en-us.json (Italian)
[ci skip]
2026-06-25 11:44:29 -07:00
Owen Schwartz 56187d61d5 New translations en-us.json (German)
[ci skip]
2026-06-25 11:44:27 -07:00
Owen Schwartz 36460d4cc0 New translations en-us.json (Danish)
[ci skip]
2026-06-25 11:44:25 -07:00
Owen Schwartz 88a9b92dc3 New translations en-us.json (Czech)
[ci skip]
2026-06-25 11:44:23 -07:00
Owen Schwartz dd26518d6f New translations en-us.json (Bulgarian)
[ci skip]
2026-06-25 11:44:21 -07:00
Owen Schwartz 60339706bb New translations en-us.json (Spanish)
[ci skip]
2026-06-25 11:44:19 -07:00
Owen Schwartz 4b1b3d3d5b New translations en-us.json (French)
[ci skip]
2026-06-25 11:44:17 -07:00
Owen cf21bacd9c Merge branch 'main' into dev 2026-06-25 14:30:05 -04:00
Owen Schwartz 80d257b94b Merge pull request #3336 from mr1beast/main
New translations en-us.json (Danish)
2026-06-25 11:24:56 -07:00
Owen Schwartz e0d0c5dcbf Merge pull request #3331 from Fredkiss3/feat/geoip-tag-in-tables
feat: Show GeoIp country flags in site & rules page
2026-06-25 11:23:42 -07:00
Fred KISSIE 0f02d1bc02 ♻️ remove deprecated ISO CS country code 2026-06-25 18:01:25 +02:00
Owen f8591f27c5 Fix no data when last data was over 90 days ago 2026-06-25 10:08:37 -04:00
Owen 877985deb3 Spellcheck 2026-06-24 18:36:01 -04:00
Owen be3877a3ce Rename for clarity 2026-06-24 18:36:01 -04:00
Owen 79de64dc07 Fix removing site not removing peer 2026-06-24 18:36:01 -04:00
miloschwartz d0defa380a remove split by command and space in role form 2026-06-24 17:52:21 -04:00
miloschwartz 4eba51de72 support delete resources associated with site 2026-06-24 17:45:44 -04:00
mr1beast a48032adb3 New translations en-us.json (Danish)
New translations en-us.json (Danish)
2026-06-24 21:25:57 +00:00
miloschwartz 6fe4eee336 improve org policy error message responses 2026-06-24 16:32:58 -04:00
Owen 242123b875 Implement non-redis lock 2026-06-24 16:01:05 -04:00
miloschwartz 2b38658ea6 make sidebar notification failures more resilient 2026-06-24 15:55:29 -04:00
miloschwartz b18a41e4aa adjust translation 2026-06-24 15:55:29 -04:00
Owen d303fa05cb Comment out the sync 2026-06-24 15:50:54 -04:00
Owen 75b87ffba7 Quiet log message 2026-06-24 15:49:51 -04:00
Owen 62fc2edae9 Add logging and fix removing alias 2026-06-24 15:28:46 -04:00
Owen 80b66cf9b9 Add locks to rebuilds 2026-06-24 14:13:11 -04:00
Owen 034bcbd271 Reorg 2026-06-24 11:54:56 -04:00
Fred KISSIE bb7729df00 💄 show geoip flag in policy access rule tab 2026-06-23 23:45:59 +02:00
Fred KISSIE e104489257 💄 Show GeoIp flag in site details page 2026-06-23 23:28:46 +02:00
65 changed files with 5627 additions and 841 deletions
+4 -1
View File
@@ -18,5 +18,8 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true
"editor.formatOnSave": true,
"cSpell.words": [
"nessicary"
]
}
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Локална",
"edit": "Редактиране",
"siteConfirmDelete": "Потвърждение на изтриване на сайта",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите",
"siteDelete": "Изтриване на сайта",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Изтриване на сайта и ресурсите",
"siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.",
"siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.",
"siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?",
"sitesTableDeleteSite": "Изтриване на сайта",
"sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите",
"siteManageSites": "Управление на сайтове",
"siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи",
"sitesBannerTitle": "Свържете се с мрежа.",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Управление на обществени ресурси",
"proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.",
"publicResourcesBannerTitle": "Публичен достъп през Интернет",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Публичните ресурси са HTTPS проксита, достъпни за всеки в Интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват клиентски софтуер и могат да включват издентити и контексто-осъзнати политики за достъп.",
"clientResourceTitle": "Управление на частни ресурси",
"clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.",
"privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Тип на действието",
"alertingNotifyUsers": "Потребители",
"alertingNotifyRoles": "Роли",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Имейл адреси",
"alertingEmailPlaceholder": "Добавете имейл и натиснете Enter",
"alertingWebhookMethod": "HTTP метод",
"alertingWebhookSecret": "Секрет за подписване (по избор)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.",
"sshSudo": "Разреши sudo",
"sshSudoCommands": "Sudo команди",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Списък с команди, които потребителят има право да изпълнява със sudo, разделени със запетаи, интервали или нови редове. Необходимо е да се използват абсолютни пътища.",
"sshCreateHomeDir": "Създай начална директория",
"sshUnixGroups": "Unix групи",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Unix групи, за добавяне на потребителя на целевия хост, разделени със запетаи, интервали или нови редове.",
"roleTextFieldPlaceholder": "Въведете стойности или пуснете .txt или .csv файл",
"roleTextImportTitle": "Импортиране от файл",
"roleTextImportDescription": "Импортиране на {fileName} в {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.",
"createInternalResourceDialogAlias": "Псевдоним",
"createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.",
"internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси",
"internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси",
"siteConfiguration": "Конфигурация",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
"loadingDNSRecords": "Зареждане на DNS записи...",
"olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.",
"client": "Клиент",
"proxyProtocol": "Настройки на прокси протокол",
"proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Místní",
"edit": "Upravit",
"siteConfirmDelete": "Potvrdit odstranění lokality",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Potvrdit odstranění lokality a zdrojů",
"siteDelete": "Odstranění lokality",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Odstranit lokalitu a zdroje",
"siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.",
"siteMessageRemoveAndResources": "Toto trvale odstraní všechny veřejné a soukromé zdroje spojené s touto lokalitou, i když je zdroj také přiřazen k jiným lokalitám.",
"siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Opravdu chcete odstranit tuto lokalitu a všechny přidružené zdroje?",
"sitesTableDeleteSite": "Odstranění lokality",
"sitesTableDeleteSiteAndResources": "Odstranit lokalitu a zdroje",
"siteManageSites": "Správa lokalit",
"siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím",
"sitesBannerTitle": "Připojit jakoukoli síť",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Spravovat veřejné zdroje",
"proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče",
"publicResourcesBannerTitle": "Webové Veřejné Přístupy",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS proxy přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
"clientResourceTitle": "Spravovat soukromé zdroje",
"clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta",
"privateResourcesBannerTitle": "Zero-Trust soukromý přístup",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Typ akce",
"alertingNotifyUsers": "Uživatelé",
"alertingNotifyRoles": "Role",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Emailové adresy",
"alertingEmailPlaceholder": "Přidejte e-mail a stiskněte Enter",
"alertingWebhookMethod": "HTTP metoda",
"alertingWebhookSecret": "Přihlašovací tajemství (volitelné)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.",
"sshSudo": "Povolit sudo",
"sshSudoCommands": "Sudo příkazy",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Seznam příkazů, které je uživateli povoleno spouštět se sudo, oddělený čárkami, mezerami, nebo novými řádky. Je třeba používat absolutní cesty.",
"sshCreateHomeDir": "Vytvořit domovský adresář",
"sshUnixGroups": "Unixové skupiny",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Unixové skupiny, do kterých má být uživatel přidán na cílovém hostu, oddělené čárkami, mezerami, nebo novými řádky.",
"roleTextFieldPlaceholder": "Zadejte hodnoty nebo přetáhněte soubor .txt nebo .csv",
"roleTextImportTitle": "Importovat ze souboru",
"roleTextImportDescription": "Importuje se {fileName} do {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Aliasy končící na .local mohou způsobit problémy s vyřešením díky mDNS v některých sítích.",
"internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje",
"internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj",
"siteConfiguration": "Konfigurace",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Chybí ID organizace nebo domény",
"loadingDNSRecords": "Načítání DNS záznamů...",
"olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Je k dispozici aktualizovaná verze. Aktualizujte prosím na nejnovější verzi pro nejlepší zážitek.",
"client": "Zákazník",
"proxyProtocol": "Nastavení proxy protokolu",
"proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.",
+3608
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Lokal",
"edit": "Bearbeiten",
"siteConfirmDelete": "Löschen des Standorts bestätigen",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Löschen von Standort und Ressourcen bestätigen",
"siteDelete": "Standort löschen",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Standort und Ressourcen löschen",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteMessageRemoveAndResources": "Dies wird dauerhaft alle öffentlichen und privaten Ressourcen, die mit diesem Standort verknüpft sind, löschen, selbst wenn eine Ressource auch mit anderen Standorten verbunden ist.",
"siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Sind Sie sicher, dass Sie diesen Standort und alle zugehörigen Ressourcen löschen möchten?",
"sitesTableDeleteSite": "Standort löschen",
"sitesTableDeleteSiteAndResources": "Standort und Ressourcen löschen",
"siteManageSites": "Standorte verwalten",
"siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen",
"sitesBannerTitle": "Verbinde ein beliebiges Netzwerk",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Öffentliche Ressourcen verwalten",
"proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind",
"publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS-Proxys, die über einen Webbrowser für jeden im Internet zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextuelle Zugriffsrichtlinien enthalten.",
"clientResourceTitle": "Private Ressourcen verwalten",
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Aktionstyp",
"alertingNotifyUsers": "Benutzer",
"alertingNotifyRoles": "Rollen",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "E-Mail-Adressen",
"alertingEmailPlaceholder": "E-Mail hinzufügen und Enter drücken",
"alertingWebhookMethod": "HTTP-Methode",
"alertingWebhookSecret": "Signatur geheim (optional)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.",
"sshSudo": "sudo erlauben",
"sshSudoCommands": "Sudo-Befehle",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Liste der Befehle, die der Benutzer mit sudo ausführen darf, durch Kommas, Leerzeichen oder neue Zeilen getrennt. Absolute Pfade müssen verwendet werden.",
"sshCreateHomeDir": "Home-Verzeichnis erstellen",
"sshUnixGroups": "Unix-Gruppen",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Unix-Gruppen, in die der Benutzer auf dem Ziel-Host aufgenommen werden soll, getrennt durch Kommas, Leerzeichen oder neue Zeilen.",
"roleTextFieldPlaceholder": "Werte eingeben oder eine .txt- oder .csv-Datei ablegen",
"roleTextImportTitle": "Von Datei importieren",
"roleTextImportDescription": "Importiere {fileName} in {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Aliasse, die auf .local enden, können aufgrund von mDNS in einigen Netzwerken zu Auflösungsproblemen führen.",
"internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich",
"internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich",
"siteConfiguration": "Konfiguration",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt",
"loadingDNSRecords": "Lade DNS-Einträge...",
"olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Eine aktualisierte Version ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"client": "Client",
"proxyProtocol": "Proxy-Protokoll-Einstellungen",
"proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.",
+12 -5
View File
@@ -66,9 +66,15 @@
"local": "Local",
"edit": "Edit",
"siteConfirmDelete": "Confirm Delete Site",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteDelete": "Delete Site",
"siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network",
@@ -204,7 +210,7 @@
"proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"publicResourcesBannerTitle": "Web-based Public Access",
"publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -1638,7 +1644,7 @@
"alertingActionType": "Action type",
"alertingNotifyUsers": "Users",
"alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email addresses",
"alertingNotifyEmails": "Email Addresses",
"alertingEmailPlaceholder": "Add email and press Enter",
"alertingWebhookMethod": "HTTP method",
"alertingWebhookSecret": "Signing secret (optional)",
@@ -2171,10 +2177,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
"roleTextImportTitle": "Import from File",
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
@@ -2550,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "The subnet for this organization's network configuration.",
"customDomain": "Custom Domain",
"authPage": "Authentication Pages",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Local",
"edit": "Editar",
"siteConfirmDelete": "Confirmar Borrar Sitio",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Confirmar eliminación del sitio y recursos",
"siteDelete": "Eliminar sitio",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Eliminar sitio y recursos",
"siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.",
"siteMessageRemoveAndResources": "Esto eliminará permanentemente todos los recursos públicos y privados vinculados a este sitio, incluso si un recurso también está asociado con otros sitios.",
"siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "¿Está seguro de que desea eliminar este sitio y todos los recursos asociados?",
"sitesTableDeleteSite": "Eliminar sitio",
"sitesTableDeleteSiteAndResources": "Eliminar sitio y recursos",
"siteManageSites": "Administrar Sitios",
"siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas",
"sitesBannerTitle": "Conectar cualquier red",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Administrar recursos públicos",
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
"publicResourcesBannerTitle": "Acceso Público basado en Web",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS accesibles para cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluyen políticas de acceso basadas en identidad y contexto.",
"clientResourceTitle": "Administrar recursos privados",
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
"privateResourcesBannerTitle": "Acceso privado de confianza cero",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Tipo de acción",
"alertingNotifyUsers": "Usuarios",
"alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Direcciones de correo electrónico",
"alertingEmailPlaceholder": "Añadir email y presionar Enter",
"alertingWebhookMethod": "Método HTTP",
"alertingWebhookSecret": "Firma secreta (opcional)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
"sshSudo": "Permitir sudo",
"sshSudoCommands": "Comandos Sudo",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Lista de comandos que el usuario tiene permitido ejecutar con sudo, separados por comas, espacios o nuevas líneas. Se deben usar rutas absolutas.",
"sshCreateHomeDir": "Crear directorio principal",
"sshUnixGroups": "Grupos Unix",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Grupos Unix a los que añadir el usuario en el host de destino, separados por comas, espacios o nuevas líneas.",
"roleTextFieldPlaceholder": "Introduce valores, o suelta un archivo .txt o .csv",
"roleTextImportTitle": "Importar desde Archivo",
"roleTextImportDescription": "Importando {fileName} en {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Los alias que terminan en .local pueden causar problemas de resolución debido a mDNS en algunas redes.",
"internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP",
"internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP",
"siteConfiguration": "Configuración",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Falta el ID de organización o dominio",
"loadingDNSRecords": "Cargando registros DNS...",
"olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Hay una versión actualizada disponible. Actualice a la última versión para obtener la mejor experiencia.",
"client": "Cliente",
"proxyProtocol": "Configuración del Protocolo Proxy",
"proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Locale",
"edit": "Modifier",
"siteConfirmDelete": "Confirmer la suppression du nœud",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Confirmer la suppression du site et des ressources",
"siteDelete": "Supprimer le nœud",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Supprimer le site et les ressources",
"siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.",
"siteMessageRemoveAndResources": "Cela supprimera définitivement toutes les ressources publiques et privées liées à ce site, même si une ressource est également associée à d'autres sites.",
"siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Êtes-vous sûr de vouloir supprimer ce site et toutes les ressources associées?",
"sitesTableDeleteSite": "Supprimer le site",
"sitesTableDeleteSiteAndResources": "Supprimer le site et les ressources",
"siteManageSites": "Gérer les nœuds",
"siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés",
"sitesBannerTitle": "Se connecter à n'importe quel réseau",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Gérer les ressources publiques",
"proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web",
"publicResourcesBannerTitle": "Accès public basé sur le Web",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS accessibles à quiconque sur Internet via un navigateur Web. Contrairement aux ressources privées, elles ne nécessitent pas de logiciel côté client et peuvent inclure des politiques d'accès fondées sur l'identité et le contexte.",
"clientResourceTitle": "Gérer les ressources privées",
"clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté",
"privateResourcesBannerTitle": "Accès privé sans confiance",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Type d'action",
"alertingNotifyUsers": "Utilisateurs",
"alertingNotifyRoles": "Rôles",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Adresses e-mail",
"alertingEmailPlaceholder": "Ajoutez un e-mail et appuyez sur Entrée",
"alertingWebhookMethod": "Méthode HTTP",
"alertingWebhookSecret": "Secret de signature (facultatif)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.",
"sshSudo": "Autoriser sudo",
"sshSudoCommands": "Commandes Sudo",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Liste des commandes que l'utilisateur est autorisé à exécuter avec sudo, séparées par des virgules, des espaces ou des nouvelles lignes. Les chemins absolus doivent être utilisés.",
"sshCreateHomeDir": "Créer un répertoire personnel",
"sshUnixGroups": "Groupes Unix",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Groupes Unix auxquels ajouter l'utilisateur sur l'hôte cible, séparés par des virgules, des espaces, ou des nouvelles lignes.",
"roleTextFieldPlaceholder": "Entrez des valeurs, ou déposez un fichier .txt ou .csv",
"roleTextImportTitle": "Importer depuis un fichier",
"roleTextImportDescription": "Importation de {fileName} dans {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Les alias se terminant par .local peuvent causer des problèmes de résolution dus au mDNS sur certains réseaux.",
"internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP",
"internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP",
"siteConfiguration": "Configuration",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant",
"loadingDNSRecords": "Chargement des enregistrements DNS...",
"olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Une version mise à jour est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"client": "Client",
"proxyProtocol": "Paramètres du protocole proxy",
"proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Locale",
"edit": "Modifica",
"siteConfirmDelete": "Conferma Eliminazione Sito",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Conferma Eliminazione Sito e Risorse",
"siteDelete": "Elimina Sito",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Elimina Sito e Risorse",
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
"siteMessageRemoveAndResources": "Questo eliminerà permanentemente tutte le risorse pubbliche e private collegate a questo sito, anche se una risorsa è anche associata ad altri siti.",
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Sei sicuro di voler eliminare questo sito e tutte le risorse associate?",
"sitesTableDeleteSite": "Elimina Sito",
"sitesTableDeleteSiteAndResources": "Elimina Sito e Risorse",
"siteManageSites": "Gestisci Siti",
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
"sitesBannerTitle": "Connetti Qualsiasi Rete",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
"publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
"clientResourceTitle": "Gestisci Risorse Private",
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Tipo di azione",
"alertingNotifyUsers": "Utenti",
"alertingNotifyRoles": "Ruoli",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Indirizzi email",
"alertingEmailPlaceholder": "Aggiungi email e premi Invio",
"alertingWebhookMethod": "Metodo HTTP",
"alertingWebhookSecret": "Segreto di firma (opzionale)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.",
"sshSudo": "Consenti sudo",
"sshSudoCommands": "Comandi Sudo",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Elenco di comandi che l'utente è autorizzato ad eseguire con sudo, separati da virgole, spazi o nuove righe. Devono essere utilizzati percorsi assoluti.",
"sshCreateHomeDir": "Crea Cartella Home",
"sshUnixGroups": "Gruppi Unix",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Gruppi Unix a cui aggiungere l'utente sull'host di destinazione, separati da virgole, spazi o nuove righe.",
"roleTextFieldPlaceholder": "Inserisci i valori o rilascia un file .txt o .csv",
"roleTextImportTitle": "Importa da File",
"roleTextImportDescription": "Importazione di {fileName} in {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Gli alias che terminano in .local possono causare problemi di risoluzione a causa di mDNS su alcune reti.",
"internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP",
"internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP",
"siteConfiguration": "Configurazione",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio",
"loadingDNSRecords": "Caricamento record DNS...",
"olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "È disponibile una versione aggiornata. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"client": "Client",
"proxyProtocol": "Impostazioni Protocollo Proxy",
"proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "로컬",
"edit": "편집",
"siteConfirmDelete": "사이트 삭제 확인",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인",
"siteDelete": "사이트 삭제",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "사이트 및 리소스 삭제",
"siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.",
"siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.",
"siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?",
"sitesTableDeleteSite": "사이트 삭제",
"sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제",
"siteManageSites": "사이트 관리",
"siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.",
"sitesBannerTitle": "모든 네트워크 연결",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "공개 리소스 관리",
"proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.",
"publicResourcesBannerTitle": "웹 기반의 공개 액세스",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
"clientResourceTitle": "개인 리소스 관리",
"clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.",
"privateResourcesBannerTitle": "제로 트러스트 개인 접근",
@@ -1644,7 +1644,7 @@
"alertingActionType": "작업 유형",
"alertingNotifyUsers": "사용자들",
"alertingNotifyRoles": "역할들",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "이메일 주소들",
"alertingEmailPlaceholder": "이메일 추가 후 Enter 키를 누르세요",
"alertingWebhookMethod": "HTTP 메소드",
"alertingWebhookSecret": "서명 비밀 (선택 사항)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.",
"sshSudo": "Sudo 허용",
"sshSudoCommands": "Sudo 명령",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "사용자가 쉘에서 sudo로 실행할 수 있는 명령 목록, 쉼표, 공백 또는 새 줄로 구분됩니다. 절대 경로를 사용해야 합니다.",
"sshCreateHomeDir": "홈 디렉터리 생성",
"sshUnixGroups": "유닉스 그룹",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "사용자를 대상 호스트에 추가할 유닉스 그룹들, 쉼표, 공백 또는 새 줄로 구분됩니다.",
"roleTextFieldPlaceholder": "값을 입력하거나 .txt나 .csv 파일을 드롭하세요",
"roleTextImportTitle": "파일에서 가져오기",
"roleTextImportDescription": "{fileName}을(를) {fieldLabel}에 가져오는 중",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.",
"createInternalResourceDialogAlias": "별칭",
"createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.",
"internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다",
"internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다",
"siteConfiguration": "설정",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
"loadingDNSRecords": "DNS 레코드를 로드하는 중...",
"olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"client": "클라이언트",
"proxyProtocol": "프록시 프로토콜 설정",
"proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Lokal",
"edit": "Rediger",
"siteConfirmDelete": "Bekreft Sletting av Område",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Bekreft sletting av nettsted og ressurser",
"siteDelete": "Slett Område",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Slett nettsted og ressurser",
"siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.",
"siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressurser tilknyttet dette nettstedet, selv om en ressurs også er tilknyttet andre nettsteder.",
"siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Er du sikker på at du vil slette dette nettstedet og alle tilknyttede ressurser?",
"sitesTableDeleteSite": "Slett nettsted",
"sitesTableDeleteSiteAndResources": "Slett nettsted og ressurser",
"siteManageSites": "Administrer Områder",
"siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk",
"sitesBannerTitle": "Koble til alle nettverk",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Administrere offentlige ressurser",
"proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser",
"publicResourcesBannerTitle": "Web-basert offentlig tilgang",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Offentlige ressurser er HTTPS-proxyer som er tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klientprogramvare og kan inkludere identitets- og kontekstsensitive tilgangspolicyer.",
"clientResourceTitle": "Administrer private ressurser",
"clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient",
"privateResourcesBannerTitle": "Zero-Trust privat tilgang",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Handlings type",
"alertingNotifyUsers": "Brukere",
"alertingNotifyRoles": "Roller",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "E-postadresser",
"alertingEmailPlaceholder": "Legg til e-post og trykk Enter",
"alertingWebhookMethod": "HTTP-metode",
"alertingWebhookSecret": "Signeringshemmelig (valgfritt)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.",
"sshSudo": "Tillat sudo",
"sshSudoCommands": "Sudo kommandoer",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Liste over kommandoer brukeren har lov til å kjøre med sudo, separert med komma, mellomrom eller nye linjer. Absolutte stier må brukes.",
"sshCreateHomeDir": "Opprett hjemmappe",
"sshUnixGroups": "Unix grupper",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Unix-grupper å legge til brukeren i på målverten, separert med komma, mellomrom eller nye linjer.",
"roleTextFieldPlaceholder": "Skriv inn verdier, eller slipp en .txt eller .csv fil",
"roleTextImportTitle": "Importer fra fil",
"roleTextImportDescription": "Importerer {fileName} til {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Alias som slutter på .local kan forårsake oppløsningsproblemer på grunn av mDNS på enkelte nettverk.",
"internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser",
"internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser",
"siteConfiguration": "Konfigurasjon",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "ID for organisasjon eller domene mangler",
"loadingDNSRecords": "Laster DNS-poster...",
"olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "En oppdatert versjon er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"client": "Klient",
"proxyProtocol": "Protokoll innstillinger for Protokoll",
"proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Lokaal",
"edit": "Bewerken",
"siteConfirmDelete": "Verwijderen van site bevestigen",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Bevestig Verwijderen van Site en Bronnen",
"siteDelete": "Site verwijderen",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Site en Bronnen verwijderen",
"siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.",
"siteMessageRemoveAndResources": "Dit zal permanent alle publieke en private resources gekoppeld aan deze site verwijderen, zelfs als een resource ook aan andere sites is gekoppeld.",
"siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Weet u zeker dat u deze site en alle gekoppelde resources wilt verwijderen?",
"sitesTableDeleteSite": "Site verwijderen",
"sitesTableDeleteSiteAndResources": "Site en Bronnen verwijderen",
"siteManageSites": "Sites beheren",
"siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen",
"sitesBannerTitle": "Verbind elk netwerk",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Openbare bronnen beheren",
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
"publicResourcesBannerTitle": "Web-gebaseerde Openbare Toegang",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot privébronnen hoeven ze geen client-software te hebben en kunnen ze identiteit- en context bewuste toegangsmiddelen bevatten.",
"clientResourceTitle": "Privébronnen beheren",
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Actietype",
"alertingNotifyUsers": "Gebruikers",
"alertingNotifyRoles": "Rollen",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "E-mailadressen",
"alertingEmailPlaceholder": "Voeg e-mail toe en druk op Enter",
"alertingWebhookMethod": "HTTP-methode",
"alertingWebhookSecret": "Ondertekengeheim (optioneel)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.",
"sshSudo": "sudo toestaan",
"sshSudoCommands": "Sudo Commando's",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Lijst met commando's die de gebruiker mag uitvoeren met sudo, gescheiden door komma's, spaties of nieuwe regels. Absolute paden moeten worden gebruikt.",
"sshCreateHomeDir": "Maak Home Directory",
"sshUnixGroups": "Unix groepen",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Unix-groepen om de gebruiker aan toe te voegen op de doelhost, gescheiden door komma's, spaties of nieuwe regels.",
"roleTextFieldPlaceholder": "Voer waarden in, of sleep een .txt of .csv-bestand",
"roleTextImportTitle": "Importeer vanuit Bestand",
"roleTextImportDescription": "{fileName} importeren naar {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Aliassen die eindigen op .local kunnen resolutieproblemen veroorzaken vanwege mDNS op sommige netwerken.",
"internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen",
"internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen",
"siteConfiguration": "Configuratie",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt",
"loadingDNSRecords": "DNS-records laden...",
"olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Er is een bijgewerkte versie beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"client": "Klant",
"proxyProtocol": "Proxy Protocol Instellingen",
"proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Lokalny",
"edit": "Edytuj",
"siteConfirmDelete": "Potwierdź usunięcie witryny",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Potwierdź usunięcie witryny i zasobów",
"siteDelete": "Usuń witrynę",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Usuń witrynę i zasoby",
"siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.",
"siteMessageRemoveAndResources": "To spowoduje trwałe usunięcie wszystkich zasobów publicznych i prywatnych powiązanych z tą witryną, nawet jeśli zasób jest także powiązany z innymi witrynami.",
"siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Czy na pewno chcesz usunąć tę witrynę i wszystkie powiązane zasoby?",
"sitesTableDeleteSite": "Usuń witrynę",
"sitesTableDeleteSiteAndResources": "Usuń witrynę i zasoby",
"siteManageSites": "Zarządzaj stronami",
"siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami",
"sitesBannerTitle": "Połącz dowolną sieć",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Zarządzaj zasobami publicznymi",
"proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej",
"publicResourcesBannerTitle": "Publiczny dostęp przez przeglądarkę internetową",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Zasoby publiczne to serwery proxy HTTPS, dostępne dla każdego w Internecie za pośrednictwem przeglądarki. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
"clientResourceTitle": "Zarządzaj zasobami prywatnymi",
"clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta",
"privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Typ akcji",
"alertingNotifyUsers": "Użytkownicy",
"alertingNotifyRoles": "Role",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Adres e-mail",
"alertingEmailPlaceholder": "Dodaj e-mail i naciśnij Enter",
"alertingWebhookMethod": "Metoda HTTP",
"alertingWebhookSecret": "Sekret podpisu (opcjonalny)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.",
"sshSudo": "Zezwól na sudo",
"sshSudoCommands": "Komendy Sudo",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Lista poleceń, które użytkownik może uruchomić z sudo, oddzielone przecinkami, spacjami lub nowymi liniami. Absolutne ścieżki muszą być używane.",
"sshCreateHomeDir": "Utwórz katalog domowy",
"sshUnixGroups": "Grupy Unix",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Grupy Uniksowe, do których dodać użytkownika na docelowym hoście, oddzielone przecinkami, spacjami, lub nowymi liniami.",
"roleTextFieldPlaceholder": "Wprowadź wartości lub upuść plik .txt lub .csv",
"roleTextImportTitle": "Importuj z pliku",
"roleTextImportDescription": "Importowanie {fileName} do {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Alias kończący się na .local może powodować problemy z rozpoznawaniem z powodu mDNS w niektórych sieciach.",
"internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP",
"internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP",
"siteConfiguration": "Konfiguracja",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny",
"loadingDNSRecords": "Ładowanie rekordów DNS...",
"olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Dostępna jest zaktualizowana wersja. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze wrażenia z użytkowania.",
"client": "Klient",
"proxyProtocol": "Ustawienia protokołu proxy",
"proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Localização",
"edit": "Alterar",
"siteConfirmDelete": "Confirmar que pretende apagar o site",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Confirmar Exclusão do Site e Recursos",
"siteDelete": "Excluir site",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Excluir Site e Recursos",
"siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.",
"siteMessageRemoveAndResources": "Isso excluirá permanentemente todos os recursos públicos e privados vinculados a este site, mesmo que um recurso também esteja associado a outros sites.",
"siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Tem certeza de que deseja excluir este site e todos os recursos associados?",
"sitesTableDeleteSite": "Excluir Site",
"sitesTableDeleteSiteAndResources": "Excluir Site e Recursos",
"siteManageSites": "Gerir sites",
"siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas",
"sitesBannerTitle": "Conectar a Qualquer Rede",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Gerenciar Recursos Públicos",
"proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web",
"publicResourcesBannerTitle": "Acesso Público Baseado em Web",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS acessíveis a qualquer pessoa na internet através de um navegador web. Ao contrário dos recursos privados, eles não exigem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
"clientResourceTitle": "Gerenciar recursos privados",
"clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado",
"privateResourcesBannerTitle": "Acesso Privado com Confiança Zero",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Tipo de Ação",
"alertingNotifyUsers": "Utilizadores",
"alertingNotifyRoles": "Papéis",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Endereços de e-mail",
"alertingEmailPlaceholder": "Adicione o e-mail e pressione Enter",
"alertingWebhookMethod": "Método HTTP",
"alertingWebhookSecret": "Segredo de assinatura (opcional)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.",
"sshSudo": "Permitir sudo",
"sshSudoCommands": "Comandos Sudo",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Lista de comandos que o usuário está autorizado a executar com sudo, separados por vírgulas, espaços ou novas linhas. Devem ser usados caminhos absolutos.",
"sshCreateHomeDir": "Criar Diretório Inicial",
"sshUnixGroups": "Grupos Unix",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Grupos Unix para adicionar o usuário no host de destino, separados por vírgulas, espaços ou novas linhas.",
"roleTextFieldPlaceholder": "Insira valores, ou solte um arquivo .txt ou .csv",
"roleTextImportTitle": "Importar de Arquivo",
"roleTextImportDescription": "Importando {fileName} para {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Os aliases terminando em .local podem causar problemas de resolução devido ao mDNS em algumas redes.",
"internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP",
"internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP",
"siteConfiguration": "Configuração",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "ID da organização ou domínio está faltando",
"loadingDNSRecords": "Carregando registros DNS...",
"olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Uma versão atualizada está disponível. Por favor, atualize para a versão mais recente para uma melhor experiência.",
"client": "Cliente",
"proxyProtocol": "Configurações de Protocolo Proxy",
"proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Локальный",
"edit": "Редактировать",
"siteConfirmDelete": "Подтвердить удаление сайта",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов",
"siteDelete": "Удалить сайт",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Удалить сайт и ресурсы",
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
"siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.",
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?",
"sitesTableDeleteSite": "Удалить сайт",
"sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы",
"siteManageSites": "Управление сайтами",
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
"sitesBannerTitle": "Подключить любую сеть",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Управление публичными ресурсами",
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
"publicResourcesBannerTitle": "Веб-доступ к публичным ресурсам",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Публичные ресурсы — это HTTPS-прокси, доступные для любого пользователя Интернета через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать в себя политики доступа, учитывающие идентичность и контекст.",
"clientResourceTitle": "Управление приватными ресурсами",
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Тип действия",
"alertingNotifyUsers": "Пользователи",
"alertingNotifyRoles": "Роли",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "Email адреса",
"alertingEmailPlaceholder": "Добавьте email и нажмите Enter",
"alertingWebhookMethod": "HTTP метод",
"alertingWebhookSecret": "Секрет подписания (необязательно)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
"sshSudo": "Разрешить sudo",
"sshSudoCommands": "Sudo Команды",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Список команд, которые пользователь может запускать с sudo, разделенный запятыми, пробелами или новыми строками. Должны использоваться абсолютные пути.",
"sshCreateHomeDir": "Создать домашний каталог",
"sshUnixGroups": "Unix группы",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Группы Unix, к которым пользователь добавляется на целевом хосте, разделяются запятыми, пробелами или новыми строками.",
"roleTextFieldPlaceholder": "Введите значения или перетащите файл .txt или .csv",
"roleTextImportTitle": "Импорт из файла",
"roleTextImportDescription": "Импортирую {fileName} в {fieldLabel}.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.",
"internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов",
"internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов",
"siteConfiguration": "Конфигурация",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
"loadingDNSRecords": "Загрузка записей DNS...",
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.",
"client": "Клиент",
"proxyProtocol": "Настройки протокола прокси",
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "Yerel",
"edit": "Düzenle",
"siteConfirmDelete": "Site Silmeyi Onayla",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "Site ve Kaynakları Silmeyi Onayla",
"siteDelete": "Siteyi Sil",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "Site ve Kaynakları Sil",
"siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.",
"siteMessageRemoveAndResources": "Bu işlem, diğer sitelerle de ilişkilendirilmiş olsa bile, bu siteye bağlı tüm genel ve özel kaynakları kalıcı olarak silecektir.",
"siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "Bu siteyi ve tüm ilişkili kaynakları silmek istediğinizden emin misiniz?",
"sitesTableDeleteSite": "Siteyi Sil",
"sitesTableDeleteSiteAndResources": "Site ve Kaynakları Sil",
"siteManageSites": "Siteleri Yönet",
"siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin",
"sitesBannerTitle": "Herhangi Bir Ağa Bağlan",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "Herkese Açık Kaynakları Yönet",
"proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin",
"publicResourcesBannerTitle": "Web tabanlı Açık Erişim",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla internette herkesin erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine istemci tarafı yazılım gerektirmezler ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
"clientResourceTitle": "Özel Kaynakları Yönet",
"clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin",
"privateResourcesBannerTitle": "Sıfır Güven Özel Erişim",
@@ -1644,7 +1644,7 @@
"alertingActionType": "Eylem türü",
"alertingNotifyUsers": "Kullanıcılar",
"alertingNotifyRoles": "Roller",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "E-posta adresleri",
"alertingEmailPlaceholder": "E-posta ekleyin ve Enter tuşuna basın",
"alertingWebhookMethod": "HTTP yöntemi",
"alertingWebhookSecret": "İmza sırrı (isteğe bağlı)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.",
"sshSudo": "Sudo'ya izin ver",
"sshSudoCommands": "Sudo Komutları",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "Kullanıcının 'sudo' ile çalıştırmasına izin verilen komutlar listesi noktalı virgülle, boşluk veya yeni satırla ayrılmalıdır. Mutlak yollar kullanılmalıdır.",
"sshCreateHomeDir": "Ev Dizini Oluştur",
"sshUnixGroups": "Unix Grupları",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "Hedef ana bilgisayardaki kullanıcıya eklemek için Unix grupları, noktalı virgülle, boşluk veya yeni satırla ayrılmalıdır.",
"roleTextFieldPlaceholder": "Değerleri girin veya bir .txt veya .csv dosyası bırakın",
"roleTextImportTitle": "Dosyadan İçe Aktar",
"roleTextImportDescription": "{fileName} dosyası {fieldLabel} alanına içe aktarılıyor.",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.",
"createInternalResourceDialogAlias": "Takma Ad",
"createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "Bazı ağlarda mDNS nedeniyle .local ile biten takma adlar çözümleme sorunlarına neden olabilir.",
"internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir",
"internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir",
"siteConfiguration": "Yapılandırma",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik",
"loadingDNSRecords": "DNS kayıtları yükleniyor...",
"olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "Güncellenmiş bir sürüm mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"client": "İstemci",
"proxyProtocol": "Proxy Protokol Ayarları",
"proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.",
+13 -13
View File
@@ -66,15 +66,15 @@
"local": "本地的",
"edit": "编辑",
"siteConfirmDelete": "确认删除节点",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteConfirmDeleteAndResources": "确认删除站点及资源",
"siteDelete": "删除节点",
"siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteDeleteAndResources": "删除站点及资源",
"siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。",
"siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。",
"siteQuestionRemove": "您确定要从组织中删除该节点吗?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?",
"sitesTableDeleteSite": "删除站点",
"sitesTableDeleteSiteAndResources": "删除站点及资源",
"siteManageSites": "管理站点",
"siteDescription": "创建和管理站点,启用与私人网络的连接",
"sitesBannerTitle": "连接任何网络",
@@ -210,7 +210,7 @@
"proxyResourceTitle": "管理公共资源",
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
"publicResourcesBannerTitle": "基于 Web 的公共访问",
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。",
"clientResourceTitle": "管理私有资源",
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
"privateResourcesBannerTitle": "零信任私有访问",
@@ -1644,7 +1644,7 @@
"alertingActionType": "操作类型",
"alertingNotifyUsers": "用户",
"alertingNotifyRoles": "角色",
"alertingNotifyEmails": "Email Addresses",
"alertingNotifyEmails": "电子邮件地址",
"alertingEmailPlaceholder": "添加电子邮件并按回车键",
"alertingWebhookMethod": "HTTP 方法",
"alertingWebhookSecret": "签名密钥(可选)",
@@ -2177,10 +2177,10 @@
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
"sshSudo": "允许Sudo",
"sshSudoCommands": "可用 Sudo 命令",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。",
"sshCreateHomeDir": "创建主目录",
"sshUnixGroups": "Unix 组",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"sshUnixGroupsDescription": "在目标主机上将用户添加到的 Unix 组,以逗号、空格或新行分隔。",
"roleTextFieldPlaceholder": "输入值,或放入 .txt 或 .csv 文件",
"roleTextImportTitle": "从文件导入",
"roleTextImportDescription": "将 {fileName} 导入到 {fieldLabel}。",
@@ -2344,7 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
"internalResourceAliasLocalWarning": "以 .local 结尾的别名可能会因某些网络上的 mDNS 而导致解析问题。",
"internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案",
"internalResourceHttpPortRequired": "HTTP 资源需要目的端口",
"siteConfiguration": "配置",
@@ -2974,7 +2974,7 @@
"orgOrDomainIdMissing": "缺少机构或域 ID",
"loadingDNSRecords": "正在载入DNS记录...",
"olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
"updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。",
"client": "客户端:",
"proxyProtocol": "代理协议设置",
"proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。",
+40 -1
View File
@@ -12,7 +12,7 @@ import {
users
} from "@server/db";
import { db } from "@server/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray, ne } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
@@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
}
}
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie(
token: string,
isSecure: boolean,
+7 -4
View File
@@ -795,10 +795,13 @@ export const COUNTRIES = [
name: "Serbia",
code: "RS"
},
{
name: "Serbia and Montenegro",
code: "CS"
},
// Removed as this is a deprecated ISO country code, not supported anymore
// Also the individual flags for Serbia & Montenegro are already included in the list
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
// {
// name: "Serbia and Montenegro",
// code: "CS"
// },
{
name: "Seychelles",
code: "SC"
+24 -22
View File
@@ -29,8 +29,11 @@ import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
import {
handleMessagingForUpdatedSiteResource,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
type ApplyBlueprintArgs = {
orgId: string;
@@ -138,26 +141,25 @@ export async function applyBlueprint({
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
).catch((e) => {
logger.error(
`Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((site) => ({
// only need to run this on the old sites because the new sites are added above
siteId: site.siteId,
orgId: result.newSiteResource.orgId
}))
).catch((err) => {
logger.error(
`Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`,
err
);
});
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
logger.debug(
+144
View File
@@ -0,0 +1,144 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets,
type Resource,
type Target,
type TargetHealthCheck,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
targetsToBeRemoved: Target[];
healthChecksToBeRemoved: TargetHealthCheck[];
};
export async function performDeleteResources(
resourceIds: number[],
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult[]> {
if (resourceIds.length === 0) {
return [];
}
const targetsToBeRemoved = await trx
.select()
.from(targets)
.where(inArray(targets.resourceId, resourceIds));
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
const healthChecksToBeRemoved =
targetIds.length > 0
? await trx
.select()
.from(targetHealthCheck)
.where(inArray(targetHealthCheck.targetId, targetIds))
: [];
const deletedResources = await trx
.delete(resources)
.where(inArray(resources.resourceId, resourceIds))
.returning();
const policyIds = deletedResources
.map((resource) => resource.defaultResourcePolicyId)
.filter((id): id is number => id != null);
if (policyIds.length > 0) {
await trx
.delete(resourcePolicies)
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
}
if (deletedResources.length > 0) {
logger.debug(`Deleted ${deletedResources.length} resources`);
}
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
for (const healthCheck of healthChecksToBeRemoved) {
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
if (resourceId == null) {
continue;
}
const existing = healthChecksByResourceId.get(resourceId) ?? [];
existing.push(healthCheck);
healthChecksByResourceId.set(resourceId, existing);
}
return deletedResources.map((deletedResource) => ({
deletedResource,
targetsToBeRemoved:
targetsByResourceId.get(deletedResource.resourceId) ?? [],
healthChecksToBeRemoved:
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
}));
}
export async function performDeleteResource(
resourceId: number,
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult | null> {
const [result] = await performDeleteResources([resourceId], trx);
return result ?? null;
}
export async function runResourceDeleteSideEffects(
result: DeleteResourceResult
): Promise<void> {
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
result;
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
);
}
if (site.pubKey && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await removeTargets(
newt.newtId,
[],
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import { and, eq, sql } from "drizzle-orm";
import {
db,
siteNetworks,
siteResources,
targets,
type SiteResource,
type Transaction
} from "@server/db";
import {
performDeleteResources,
runResourceDeleteSideEffects,
type DeleteResourceResult
} from "@server/lib/deleteResource";
import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
export type DeleteSiteAssociatedResourcesSideEffects = {
resources: DeleteResourceResult[];
siteResources: SiteResource[];
};
export async function getResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
return rows.map((row) => row.resourceId);
}
export async function getSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
);
return rows.map((row) => row.siteResourceId);
}
export async function getAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [publicCountResult, privateCountResult] = await Promise.all([
trx
.select({
count: sql<number>`count(distinct ${targets.resourceId})`
})
.from(targets)
.where(eq(targets.siteId, siteId)),
trx
.select({
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
})
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
]);
return (
Number(publicCountResult[0]?.count ?? 0) +
Number(privateCountResult[0]?.count ?? 0)
);
}
export function exceedsSiteAssociatedResourceDeleteLimit(
resourceCount: number
): boolean {
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
sideEffects: DeleteSiteAssociatedResourcesSideEffects
): Promise<void> {
for (const result of sideEffects.resources) {
await runResourceDeleteSideEffects(result);
}
for (const removed of sideEffects.siteResources) {
runSiteResourceDeleteSideEffects(removed);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { inArray } from "drizzle-orm";
import {
db,
siteResources,
type SiteResource,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
export async function performDeleteSiteResources(
siteResourceIds: number[],
trx: Transaction | typeof db = db
): Promise<SiteResource[]> {
if (siteResourceIds.length === 0) {
return [];
}
const removedSiteResources = await trx
.delete(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds))
.returning();
if (removedSiteResources.length > 0) {
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
}
return removedSiteResources;
}
export async function performDeleteSiteResource(
siteResourceId: number,
trx: Transaction | typeof db = db
): Promise<SiteResource | null> {
const [removedSiteResource] = await performDeleteSiteResources(
[siteResourceId],
trx
);
return removedSiteResource ?? null;
}
export function runSiteResourceDeleteSideEffects(
removedSiteResource: SiteResource
): void {
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
err
);
}
);
}
+117 -7
View File
@@ -1,4 +1,24 @@
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager {
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/**
* Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock
@@ -7,22 +27,57 @@ export class LockManager {
*/
async acquireLock(
lockKey: string,
ttlMs: number = 30000
ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<boolean> {
return true;
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
}
/**
* Release a lock using Lua script to ensure atomicity
* @param lockKey - Unique identifier for the lock
*/
async releaseLock(lockKey: string): Promise<void> {}
async releaseLock(lockKey: string): Promise<void> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
localLocks.delete(lockKey);
}
}
/**
* Force release a lock regardless of owner (use with caution)
* @param lockKey - Unique identifier for the lock
*/
async forceReleaseLock(lockKey: string): Promise<void> {}
async forceReleaseLock(lockKey: string): Promise<void> {
localLocks.delete(lockKey);
}
/**
* Check if a lock exists and get its info
@@ -35,7 +90,20 @@ export class LockManager {
ttl: number;
owner?: string;
}> {
return { exists: true, ownedByMe: true, ttl: 0 };
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ttl,
owner: existing.owner.split(":")[0]
};
}
/**
@@ -45,6 +113,15 @@ export class LockManager {
* @returns Promise<boolean> - true if extended successfully
*/
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
@@ -62,7 +139,26 @@ export class LockManager {
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
return true;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return true;
}
if (attempt < maxRetries) {
const delay =
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
}
/**
@@ -99,7 +195,21 @@ export class LockManager {
activeLocksCount: number;
locksOwnedByMe: number;
}> {
return { activeLocksCount: 0, locksOwnedByMe: 0 };
const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
}
/**
+636 -43
View File
@@ -35,10 +35,13 @@ import {
parseEndpoint
} from "@server/lib/ip";
import {
addPeerData,
addPeerDataBatch,
addTargetsBatch as addSubnetProxyTargetsBatch,
removePeerDataBatch,
removeTargetsBatch as removeSubnetProxyTargetsBatch
removeTargetsBatch as removeSubnetProxyTargetsBatch,
updatePeerDataBatch,
updateTargets
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
@@ -47,6 +50,112 @@ import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
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;
/**
* Returns true if a rebuild for the given site resource is currently active
* (holding the distributed lock) or is pending in the rebuild queue.
*/
export async function hasActiveSiteResourceRebuild(
siteResourceId: number
): Promise<boolean> {
const lockKey = `rebuild-client-associations:site-resource:${siteResourceId}`;
const lockInfo = await lockManager.getLockInfo(lockKey);
if (lockInfo.exists) return true;
return rebuildQueue.isQueued({ type: "site-resource", id: siteResourceId });
}
/**
* Resolves once there is no active or queued rebuild for the given site resource.
* Logs a warning and resolves early if the timeout is reached.
*/
export async function waitForSiteResourceRebuildIdle(
siteResourceId: number,
timeoutMs = REBUILD_IDLE_DEFAULT_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await hasActiveSiteResourceRebuild(siteResourceId))) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForSiteResourceRebuildIdle: timed out after ${timeoutMs}ms waiting for siteResourceId=${siteResourceId}`
);
}
/**
* Resolves once there are no active or queued rebuilds for any site resource
* associated with the given site.
*/
export async function waitForSiteRebuildIdle(
siteId: number,
timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resourceRows = await db
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(eq(siteNetworks.siteId, siteId));
let allIdle = true;
for (const { siteResourceId } of resourceRows) {
if (await hasActiveSiteResourceRebuild(siteResourceId)) {
allIdle = false;
break;
}
}
if (allIdle) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForSiteRebuildIdle: timed out after ${timeoutMs}ms waiting for siteId=${siteId}`
);
}
/**
* Resolves once there are no active or queued rebuilds for any site resource
* associated with the given client.
*/
export async function waitForClientRebuildIdle(
clientId: number,
timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resourceRows = await db
.select({
siteResourceId:
clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(eq(clientSiteResourcesAssociationsCache.clientId, clientId));
let allIdle = true;
for (const { siteResourceId } of resourceRows) {
if (await hasActiveSiteResourceRebuild(siteResourceId)) {
allIdle = false;
break;
}
}
if (allIdle) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForClientRebuildIdle: timed out after ${timeoutMs}ms waiting for clientId=${clientId}`
);
}
export async function getClientSiteResourceAccess(
siteResource: SiteResource,
trx: Transaction | typeof db = db
@@ -162,15 +271,10 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource
) {
const trx = primaryDb;
try {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() =>
rebuildClientAssociationsFromSiteResourceImpl(
siteResource,
trx
),
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
} catch (err: any) {
@@ -192,15 +296,10 @@ export async function rebuildClientAssociationsFromSiteResource(
}
async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> {
siteResource: SiteResource
) {
const trx = primaryDb;
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
);
@@ -214,14 +313,62 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
/////////// process the client-siteResource associations ///////////
const existingClientSiteResources = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResource.siteResourceId
)
);
const existingClientSiteResourceIds = existingClientSiteResources.map(
(row) => row.clientId
);
// get all of the clients associated with other site resources that share
// any of the same sites as this site resource (via siteNetworks). We can't
// simply filter by networkId since each site resource has its own network;
// two site resources serving the same site typically belong to different
// networks that both happen to include the site through siteNetworks.
const sitesListSiteIds = sitesList.map((s) => s.siteId);
// We must also consider sites where these clients are currently cached,
// otherwise removing a site from this resource can leave stale
// client-site cache entries behind for the removed site.
const cachedSiteRowsForResourceClients =
existingClientSiteResourceIds.length > 0
? await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
existingClientSiteResourceIds
)
)
: [];
const allCandidateSiteIds = Array.from(
new Set([
...sitesListSiteIds,
...cachedSiteRowsForResourceClients.map((r) => r.siteId)
])
);
const sitesToProcess =
allCandidateSiteIds.length > 0
? await trx
.select()
.from(sites)
.where(inArray(sites.siteId, allCandidateSiteIds))
: [];
const currentSiteIdSet = new Set(sitesListSiteIds);
const allUpdatedClientsFromOtherResourcesOnThisSite =
sitesListSiteIds.length > 0
allCandidateSiteIds.length > 0
? await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -241,7 +388,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
)
.where(
and(
inArray(siteNetworks.siteId, sitesListSiteIds),
inArray(siteNetworks.siteId, allCandidateSiteIds),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
@@ -260,22 +407,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
}
const existingClientSiteResources = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResource.siteResourceId
)
);
const existingClientSiteResourceIds = existingClientSiteResources.map(
(row) => row.clientId
);
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
);
@@ -358,10 +489,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
/////////// process the client-site associations ///////////
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)`
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesToProcess.length} site(s) (current=${sitesList.length})`
);
for (const site of sitesList) {
for (const site of sitesToProcess) {
const siteId = site.siteId;
logger.debug(
@@ -403,7 +534,13 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
);
const clientSitesToAdd = mergedAllClientIds.filter(
// Expected clients from this resource are site-scoped: if this site is
// no longer attached to the resource, the expected set is empty.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
? mergedAllClientIds
: [];
const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) =>
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
@@ -438,7 +575,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!mergedAllClientIds.includes(clientId) &&
!expectedClientIdsForSite.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
@@ -485,10 +622,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove,
trx
);
return {
mergedAllClients
};
}
async function handleMessagesForSiteClients(
@@ -1042,6 +1175,466 @@ async function handleSubnetProxyTargetUpdates(
await Promise.all([...proxyJobs, ...olmJobs]);
}
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
existingSiteIds: number[],
updatedSiteIds: number[]
) {
const trx = primaryDb;
logger.debug(
`handleMessagingForUpdatedSiteResource: START siteResourceId=${updatedSiteResource.siteResourceId} existingSiteIds=[${existingSiteIds.join(", ")}] updatedSiteIds=[${updatedSiteIds.join(", ")}]`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: existingSiteResource is: ",
existingSiteResource
);
logger.debug(
"handleMessagingForUpdatedSiteResource: updatedSiteResource is: ",
updatedSiteResource
);
const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])];
logger.debug(
`handleMessagingForUpdatedSiteResource: allSiteIds=[${allSiteIds.join(", ")}] count=${allSiteIds.length}`
);
const newtsForSites =
allSiteIds.length > 0
? await trx
.select()
.from(newts)
.where(inArray(newts.siteId, allSiteIds))
: [];
const newtBySiteId = new Map(
newtsForSites.map((newt) => [newt.siteId, newt])
);
logger.debug(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
);
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
const mergedAllClients = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
clients,
eq(clientSiteResourcesAssociationsCache.clientId, clients.clientId)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
updatedSiteResource.siteResourceId
)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: resolved merged clients count=${mergedAllClients.length} clientIds=[${mergedAllClients.map((c) => c.clientId).join(", ")}]`
);
const targets = await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
logger.debug(
`handleMessagingForUpdatedSiteResource: generated updated targets count=${targets ? targets.length : 0}`
);
const oldDestinationStillInUseClientSitePairs = new Set<string>();
if (
existingSiteResource?.destination &&
allSiteIds.length > 0 &&
mergedAllClients.length > 0
) {
logger.debug(
`handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}`
);
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
mergedAllClients.map((c) => c.clientId)
),
inArray(siteNetworks.siteId, allSiteIds),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
for (const row of oldDestinationStillInUseRows) {
oldDestinationStillInUseClientSitePairs.add(
`${row.clientId}:${row.siteId}`
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: old destination still in use rows=${oldDestinationStillInUseRows.length} uniqueClientSitePairs=${oldDestinationStillInUseClientSitePairs.size}`
);
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping old destination reuse check (missing existing destination or no sites/clients)"
);
}
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES
const removedSiteIds = existingSiteIds.filter(
(id) => !updatedSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: removing sites removedSiteIds=[${removedSiteIds.join(", ")}] count=${removedSiteIds.length}`
);
const targetsToRemoveBatch: {
newtId: string;
targets: any[];
version: string | null;
}[] = [];
const peerDataRemoves: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
if (targets) {
for (const siteId of removedSiteIds) {
const newt = newtBySiteId.get(siteId);
if (!newt) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping remove for siteId=${siteId} because no newt found`
);
continue;
}
logger.debug(
`handleMessagingForUpdatedSiteResource: preparing remove batches for siteId=${siteId} newtId=${newt.newtId}`
);
targetsToRemoveBatch.push({
newtId: newt.newtId,
targets: targets,
version: newt.version
});
for (const client of mergedAllClients) {
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseBySite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${siteId}`
);
if (existingSiteResource) {
peerDataRemoves.push({
// this might happen twice after the rebuild function but that is okay
clientId: client.clientId,
siteId,
remoteSubnets: !oldDestinationStillInUseBySite
? generateRemoteSubnets([existingSiteResource])
: [],
aliases: generateAliasConfig([existingSiteResource])
});
}
}
}
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping removal batch generation because targets were empty"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: remove batches prepared targetBatchCount=${targetsToRemoveBatch.length} peerDataCount=${peerDataRemoves.length}`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching removeSubnetProxyTargetsBatch"
);
removeSubnetProxyTargetsBatch(targetsToRemoveBatch);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching removePeerDataBatch"
);
removePeerDataBatch(peerDataRemoves);
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES
const addedSiteIds = updatedSiteIds.filter(
(id) => !existingSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: adding sites addedSiteIds=[${addedSiteIds.join(", ")}] count=${addedSiteIds.length}`
);
const targetsToAddBatch: {
newtId: string;
targets: any[];
version: string | null;
}[] = [];
const peerDataAdds: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
if (targets) {
for (const siteId of addedSiteIds) {
const newt = newtBySiteId.get(siteId);
if (!newt) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping add for siteId=${siteId} because no newt found`
);
continue;
}
logger.debug(
`handleMessagingForUpdatedSiteResource: preparing add batches for siteId=${siteId} newtId=${newt.newtId}`
);
targetsToAddBatch.push({
newtId: newt.newtId,
targets: targets,
version: newt.version
});
for (const client of mergedAllClients) {
peerDataAdds.push({
clientId: client.clientId,
siteId,
remoteSubnets: generateRemoteSubnets([updatedSiteResource]),
aliases: generateAliasConfig([updatedSiteResource])
});
}
}
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping add batch generation because targets were empty"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: add batches prepared targetBatchCount=${targetsToAddBatch.length} peerDataCount=${peerDataAdds.length}`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching addSubnetProxyTargetsBatch"
);
addSubnetProxyTargetsBatch(targetsToAddBatch);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching addPeerDataBatch"
);
addPeerDataBatch(peerDataAdds);
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES
const unchangedSiteIds = existingSiteIds.filter((id) =>
updatedSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: unchangedSiteIds=[${unchangedSiteIds.join(", ")}] count=${unchangedSiteIds.length}`
);
// after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed
const destinationChanged =
existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
logger.debug(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
destinationPortChanged;
logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
);
const oldTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
)
: [];
const newTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
)
: [];
logger.debug(
`handleMessagingForUpdatedSiteResource: target update payload sizes oldTargets=${oldTargets ? oldTargets.length : 0} newTargets=${newTargets ? newTargets.length : 0}`
);
const peerDataUpdateBatch: Parameters<typeof updatePeerDataBatch>[0] =
[];
for (const siteId of unchangedSiteIds) {
const newt = newtBySiteId.get(siteId);
logger.debug(
`handleMessagingForUpdatedSiteResource: processing unchanged siteId=${siteId}`
);
if (!newt) {
logger.error(
`handleMessagingForUpdatedSiteResource: missing newt for unchanged siteId=${siteId}`
);
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if these items change
if (shouldUpdateTargets) {
logger.debug(
`handleMessagingForUpdatedSiteResource: updating targets for siteId=${siteId} newtId=${newt.newtId}`
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
if (!existingSiteResource.destination) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping peerData update for clientId=${client.clientId} siteId=${siteId} because existing destination is empty`
);
continue;
}
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseBySite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${siteId}`
);
// we also need to update the remote subnets on the olms for each client that has access to this site
peerDataUpdateBatch.push({
clientId: client.clientId,
siteId,
remoteSubnets: destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseBySite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliases:
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
});
}
}
logger.debug(
`handleMessagingForUpdatedSiteResource: dispatching updatePeerDataBatch count=${peerDataUpdateBatch.length}`
);
updatePeerDataBatch(peerDataUpdateBatch);
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: no unchanged-site update required because no relevant fields changed"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
);
}
export async function rebuildClientAssociationsFromClient(
client: Client
): Promise<void> {
@@ -1776,7 +2369,7 @@ async function handleMessagesForClientResources(
)
);
// Only remove remote subnet if no other resource uses the same destination
// Only remove remote subnet if no other resource uses the same destination on the same site
const remoteSubnetsToRemove =
destinationStillInUse.length > 0
? []
+4
View File
@@ -13,11 +13,15 @@ export interface RebuildJobHandlers {
export interface RebuildQueueManager {
enqueue(job: RebuildJob): Promise<void>;
startProcessing(handlers: RebuildJobHandlers): void;
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;
}
}
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();
+30 -4
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import { and, eq, gte, lt, asc, desc } from "drizzle-orm";
import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -42,7 +42,29 @@ export async function getCachedStatusHistory(
)
.orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days);
// Fetch the last known state before the window so that entities that
// haven't changed status recently still show the correct status rather
// than appearing as "no_data".
const [lastKnownEvent] = await logsDb
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
lt(statusHistory.timestamp, startSec)
)
)
.orderBy(desc(statusHistory.timestamp))
.limit(1);
const priorStatus = lastKnownEvent?.status ?? null;
const { buckets, totalDowntime } = computeBuckets(
events,
days,
priorStatus
);
const totalWindow = days * 86400;
const overallUptime =
totalWindow > 0
@@ -110,7 +132,8 @@ export function computeBuckets(
timestamp: number;
id: number;
}[],
days: number
days: number,
priorStatus: string | null = null
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000);
@@ -136,7 +159,10 @@ export function computeBuckets(
.filter((e) => e.timestamp < dayStartSec)
.at(-1);
const currentStatus = lastBeforeDay?.status ?? null;
// Fall back to the last known state before the entire query window
// so that entities that haven't generated events recently still show
// as their actual status rather than "no_data".
const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null;
const windows: { start: number; end: number | null; status: string }[] =
[];
@@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -56,8 +56,7 @@ export async function verifyAdmin(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -113,8 +113,7 @@ export async function verifyApiKeyAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+2 -6
View File
@@ -107,8 +107,7 @@ export async function verifyClientAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
@@ -129,10 +128,7 @@ export async function verifyClientAccess(
.where(
and(
eq(roleClients.clientId, client.clientId),
inArray(
roleClients.roleId,
req.userOrgRoleIds!
)
inArray(roleClients.roleId, req.userOrgRoleIds!)
)
)
.limit(1)
+1 -2
View File
@@ -88,8 +88,7 @@ export async function verifyDomainAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+2 -2
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
import logger from "@server/logger";
export async function verifyOrgAccess(
req: Request,
@@ -59,8 +60,7 @@ export async function verifyOrgAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -105,8 +105,7 @@ export async function verifyResourceAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
@@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -132,8 +132,7 @@ export async function verifyRoleAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
@@ -45,8 +45,7 @@ export async function verifySetResourceClients(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -40,8 +40,7 @@ export async function verifySetResourceUsers(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -115,8 +115,7 @@ export async function verifySiteAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
@@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
@@ -103,8 +103,7 @@ export async function verifySiteResourceAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -122,8 +122,7 @@ export async function verifyTargetAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+1 -2
View File
@@ -59,8 +59,7 @@ export async function verifyUserAccess(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
"" + (policyCheck.error || "Unknown error")
)
);
}
+3 -3
View File
@@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
);
continue;
}
logger.debug(
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
);
// logger.debug(
// `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
// );
for (const cert of resolverData.Certificates) {
allCerts.push(cert);
}
+78 -10
View File
@@ -21,6 +21,49 @@ import {
} from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes";
function formatMaxSessionLengthRequirement(
maxSessionLengthHours: number
): string {
if (maxSessionLengthHours < 24) {
return `This organization requires you to log in every ${maxSessionLengthHours} hours.`;
}
const maxDays = Math.round(maxSessionLengthHours / 24);
return `This organization requires you to log in every ${maxDays} days.`;
}
function buildOrgAccessPolicyError(
policies: CheckOrgAccessPolicyResult["policies"]
): string | undefined {
if (!policies) {
return undefined;
}
const errors: string[] = [];
if (policies.requiredTwoFactor === false) {
errors.push(
"This organization requires two-factor authentication. Enable two-factor authentication on your account to continue."
);
}
if (policies.maxSessionLength?.compliant === false) {
errors.push(
`Your session has expired. ${formatMaxSessionLengthRequirement(
policies.maxSessionLength.maxSessionLengthHours
)}`
);
}
if (policies.passwordAge?.compliant === false) {
errors.push(
`Your password has expired. This organization requires you to change your password every ${policies.passwordAge.maxPasswordAgeDays} days.`
);
}
return errors.length > 0 ? errors.join(" ") : undefined;
}
export function enforceResourceSessionLength(
resourceSession: ResourceSession,
org: Org
@@ -36,13 +79,17 @@ export function enforceResourceSessionLength(
if (sessionAgeMs > maxSessionLengthMs) {
return {
valid: false,
error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)`
error: `Your resource session has expired. ${formatMaxSessionLengthRequirement(
maxSessionLengthHours
)}`
};
}
} else {
return {
valid: false,
error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)`
error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement(
maxSessionLengthHours
)}`
};
}
}
@@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy(
if (!orgId) {
return {
allowed: false,
error: "Organization ID is required"
error: "Unable to verify organization access. Organization information is missing."
};
}
if (!userId) {
return { allowed: false, error: "User ID is required" };
return {
allowed: false,
error: "Unable to verify organization access. User information is missing."
};
}
if (!sessionId) {
return { allowed: false, error: "Session ID is required" };
return {
allowed: false,
error: "Your session is invalid. Please log in again."
};
}
if (build === "enterprise") {
@@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy(
.where(eq(orgs.orgId, orgId));
props.org = orgQuery;
if (!props.org) {
return { allowed: false, error: "Organization not found" };
return {
allowed: false,
error: "This organization could not be found."
};
}
}
@@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy(
.where(eq(users.userId, userId));
props.user = userQuery;
if (!props.user) {
return { allowed: false, error: "User not found" };
return {
allowed: false,
error: "Your account could not be found."
};
}
}
@@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy(
.where(eq(sessions.sessionId, sessionId));
props.session = sessionQuery;
if (!props.session) {
return { allowed: false, error: "Session not found" };
return {
allowed: false,
error: "Your session has expired. Please log in again."
};
}
}
if (props.session.userId !== props.user.userId) {
return {
allowed: false,
error: "Session does not belong to the user"
error: "Your session is invalid. Please log in again."
};
}
@@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy(
allowed = false;
}
const policyError = buildOrgAccessPolicyError(policies);
return {
allowed,
policies
policies,
error: allowed
? undefined
: (policyError ??
"You do not meet this organization's security requirements.")
};
}
+105 -48
View File
@@ -11,14 +11,31 @@
* This file is not licensed under the AGPLv3.
*/
import { config } from "@server/lib/config";
import logger from "@server/logger";
import { redis } from "#private/lib/redis";
import { v4 as uuidv4 } from "uuid";
const instanceId = uuidv4();
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager {
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/**
* Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock
@@ -32,12 +49,34 @@ export class LockManager {
retryDelayMs: number = 100
): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") {
return true;
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
}
const lockValue = `${
instanceId
}:${Date.now()}`;
const lockValue = `${instanceId}:${Date.now()}`;
const redisKey = `lock:${lockKey}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -53,11 +92,7 @@ export class LockManager {
);
if (result === "OK") {
logger.debug(
`Lock acquired: ${lockKey} by ${
instanceId
}`
);
logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`);
return true;
}
@@ -65,17 +100,11 @@ export class LockManager {
const existingValue = await redis.get(redisKey);
if (
existingValue &&
existingValue.startsWith(
`${instanceId}:`
)
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
}`
);
logger.debug(`Lock extended: ${lockKey} by ${instanceId}`);
return true;
}
@@ -88,7 +117,10 @@ export class LockManager {
await new Promise((resolve) => setTimeout(resolve, delay));
}
} catch (error) {
logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error);
logger.error(
`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`,
error
);
// On error, still retry if we have attempts left
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
@@ -109,6 +141,11 @@ export class LockManager {
*/
async releaseLock(lockKey: string): Promise<void> {
if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
localLocks.delete(lockKey);
}
return;
}
@@ -136,11 +173,7 @@ export class LockManager {
)) as number;
if (result === 1) {
logger.debug(
`Lock released: ${lockKey} by ${
instanceId
}`
);
logger.debug(`Lock released: ${lockKey} by ${instanceId}`);
} else {
logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${
@@ -159,6 +192,7 @@ export class LockManager {
*/
async forceReleaseLock(lockKey: string): Promise<void> {
if (!redis || !redis.status || redis.status !== "ready") {
localLocks.delete(lockKey);
return;
}
@@ -186,7 +220,20 @@ export class LockManager {
owner?: string;
}> {
if (!redis || !redis.status || redis.status !== "ready") {
return { exists: false, ownedByMe: true, ttl: 0 };
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ttl,
owner: existing.owner.split(":")[0]
};
}
const redisKey = `lock:${lockKey}`;
@@ -198,11 +245,7 @@ export class LockManager {
]);
const exists = value !== null;
const ownedByMe =
exists &&
value!.startsWith(
`${instanceId}:`
);
const ownedByMe = exists && value!.startsWith(`${instanceId}:`);
const owner = exists ? value!.split(":")[0] : undefined;
return {
@@ -225,6 +268,15 @@ export class LockManager {
*/
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
@@ -255,9 +307,7 @@ export class LockManager {
if (result === 1) {
logger.debug(
`Lock extended: ${lockKey} by ${
instanceId
} for ${ttlMs}ms`
`Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms`
);
return true;
}
@@ -282,12 +332,13 @@ export class LockManager {
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") {
return true;
}
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(lockKey, ttlMs);
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return true;
@@ -319,10 +370,6 @@ export class LockManager {
fn: () => Promise<T>,
ttlMs: number = 30000
): Promise<T> {
if (!redis || !redis.status || redis.status !== "ready") {
return await fn();
}
const acquired = await this.acquireLock(lockKey, ttlMs);
if (!acquired) {
@@ -346,7 +393,21 @@ export class LockManager {
locksOwnedByMe: number;
}> {
if (!redis || !redis.status || redis.status !== "ready") {
return { activeLocksCount: 0, locksOwnedByMe: 0 };
const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
}
try {
@@ -356,11 +417,7 @@ export class LockManager {
if (keys.length > 0) {
const values = await redis.mget(...keys);
locksOwnedByMe = values.filter(
(value) =>
value &&
value.startsWith(
`${instanceId}:`
)
(value) => value && value.startsWith(`${instanceId}:`)
).length;
}
+11
View File
@@ -46,6 +46,17 @@ const POLL_INTERVAL_MS = 500;
class RedisRebuildQueue {
private processingStarted = false;
async isQueued(job: RebuildJob): Promise<boolean> {
if (!redis || redis.status !== "ready") return false;
const dedupeKey = `${job.type}:${job.id}`;
try {
const member = await redis.sismember(QUEUED_SET_KEY, dedupeKey);
return member === 1;
} catch {
return false;
}
}
async enqueue(job: RebuildJob): Promise<void> {
if (!redis || redis.status !== "ready") {
logger.warn(
+2 -45
View File
@@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app";
import { sessions, resourceSessions } from "@server/db";
import { and, eq, ne, inArray } from "drizzle-orm";
import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app";
import { eq } from "drizzle-orm";
import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes";
import { sendEmail } from "@server/emails";
@@ -31,48 +30,6 @@ export type ChangePasswordResponse = {
codeRequested?: boolean;
};
async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword(
req: Request,
res: Response,
+13
View File
@@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot
import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes";
import { generateBackupCodes } from "@server/lib/totp";
import {
invalidateAllSessions,
invalidateAllSessionsExceptCurrent
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse";
@@ -168,6 +172,15 @@ export async function verifyTotp(
);
}
if (existingSession) {
await invalidateAllSessionsExceptCurrent(
user.userId,
existingSession.sessionId
);
} else {
await invalidateAllSessions(user.userId);
}
sendEmail(
TwoFactorAuthNotification({
email: user.email!,
+64
View File
@@ -438,6 +438,70 @@ export async function removePeerDataBatch(
await sendToClientsBatch(payloads);
}
export async function updatePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets:
| {
oldRemoteSubnets: string[];
newRemoteSubnets: string[];
}
| undefined;
aliases:
| {
oldAliases: Alias[];
newAliases: Alias[];
}
| undefined;
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/update`,
data: {
siteId: entry.siteId,
...entry.remoteSubnets,
...entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData(
clientId: number,
siteId: number,
@@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNecessary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations";
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
@@ -61,6 +62,8 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
return;
}
await waitForSiteRebuildIdle(siteId);
// update the endpoint and the public key
const [site] = await db
.update(sites)
+14 -12
View File
@@ -49,20 +49,22 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
);
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, newt.siteId))
.limit(1);
// TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN
if (!site) {
logger.warn(
`Newt ping message: site with ID ${newt.siteId} not found`
);
return;
}
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, newt.siteId))
// .limit(1);
await sendNewtSyncMessage(newt, site);
// if (!site) {
// logger.warn(
// `Newt ping message: site with ID ${newt.siteId} not found`
// );
// return;
// }
// await sendNewtSyncMessage(newt, site);
}
return {
@@ -21,6 +21,7 @@ import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -385,6 +386,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
}
// NOTE: its important that the client here is the old client and the public key is the new key
await waitForClientRebuildIdle(olm.clientId);
const siteConfigurations = await buildSiteConfigurationForOlmClient(
client,
publicKey,
+14 -79
View File
@@ -1,13 +1,4 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets
} from "@server/db";
import { db } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
@@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import {
performDeleteResource,
runResourceDeleteSideEffects
} from "@server/lib/deleteResource";
// Define Zod schema for request parameters validation
const deleteResourceSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
@@ -67,27 +60,13 @@ export async function deleteResource(
const { resourceId } = parsedParams.data;
const targetsToBeRemoved = await db
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
let deleteResult = null;
const healthChecksToBeRemoved = await db
.select()
.from(targetHealthCheck)
.where(
inArray(
targetHealthCheck.targetId,
targetsToBeRemoved.map((t) => t.targetId)
)
);
await db.transaction(async (trx) => {
deleteResult = await performDeleteResource(resourceId, trx);
});
const [deletedResource] = await db
.delete(resources)
.where(eq(resources.resourceId, resourceId))
.returning();
if (!deletedResource) {
if (!deleteResult) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -96,54 +75,7 @@ export async function deleteResource(
);
}
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
// [target],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
// Also delete default resource policy
if (deletedResource.defaultResourcePolicyId) {
await db
.delete(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
deletedResource.defaultResourcePolicyId
)
);
}
await runResourceDeleteSideEffects(deleteResult);
return response(res, {
data: null,
@@ -154,6 +86,9 @@ export async function deleteResource(
});
} catch (error) {
logger.error(error);
if (createHttpError.isHttpError(error)) {
return next(error);
}
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
+1 -2
View File
@@ -80,8 +80,7 @@ export async function getExchangeToken(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(hasAccess.error || "Unknown error")
"" + (hasAccess.error || "Unknown error")
)
);
}
+93 -5
View File
@@ -14,18 +14,41 @@ 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 { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAssociatedResourcesForSite,
exceedsSiteAssociatedResourceDeleteLimit,
getAssociatedResourceCountForSite,
runDeleteSiteAssociatedResourcesSideEffects,
MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
type DeleteSiteAssociatedResourcesSideEffects
} from "@server/lib/deleteSiteAssociatedResources";
const deleteSiteSchema = z.strictObject({
siteId: z.coerce.number().int().positive()
});
const deleteSiteQuerySchema = z.strictObject({
deleteResources: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(false)
.openapi({
type: "boolean",
description:
"When true, also deletes all public and private resources associated with this site"
})
});
registry.registerPath({
method: "delete",
path: "/site/{siteId}",
description: "Delete a site and all its associated data.",
tags: [OpenAPITags.Site],
request: {
params: deleteSiteSchema
params: deleteSiteSchema,
query: deleteSiteQuerySchema
},
responses: {
200: {
@@ -61,7 +84,18 @@ export async function deleteSite(
);
}
const parsedQuery = deleteSiteQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { siteId } = parsedParams.data;
const { deleteResources } = parsedQuery.data;
const [site] = await db
.select()
@@ -78,20 +112,67 @@ export async function deleteSite(
);
}
if (deleteResources) {
const canDeletePublic = await checkUserActionPermission(
ActionsEnum.deleteResource,
req
);
const canDeletePrivate = await checkUserActionPermission(
ActionsEnum.deleteSiteResource,
req
);
if (!canDeletePublic || !canDeletePrivate) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to delete associated resources"
)
);
}
const associatedResourceCount =
await getAssociatedResourceCountForSite(siteId, site.orgId);
if (
exceedsSiteAssociatedResourceDeleteLimit(
associatedResourceCount
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} resources`
)
);
}
}
const [deletedNewt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
resources: [],
siteResources: []
};
await db.transaction(async (trx) => {
if (deleteResources) {
resourceSideEffects = await deleteAssociatedResourcesForSite(
siteId,
site.orgId,
trx
);
}
if (site.type == "wireguard") {
if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey);
}
} else if (site.type == "newt") {
// Clean up all client associations and send peer/proxy removal
// messages in a single efficient pass before deleting the row.
await cleanupSiteAssociations(site, trx);
}
@@ -99,13 +180,17 @@ export async function deleteSite(
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
});
// Send termination message outside of transaction to prevent blocking
if (deleteResources) {
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
if (deletedNewt) {
const payload = {
type: `newt/wg/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(deletedNewt.newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
@@ -123,6 +208,9 @@ export async function deleteSite(
});
} catch (error) {
logger.error(error);
if (createHttpError.isHttpError(error)) {
return next(error);
}
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
+6 -1
View File
@@ -10,6 +10,7 @@ import logger from "@server/logger";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { getCountryCodeForIp } from "@server/lib/geoip";
const getSiteSchema = z.strictObject({
siteId: z
@@ -47,6 +48,7 @@ type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
export type GetSiteResponse = SiteQueryRow["sites"] & {
newtId: string | null;
newtVersion: string | null;
countryCode: string | null;
};
registry.registerPath({
@@ -134,7 +136,10 @@ export async function getSite(
const data: GetSiteResponse = {
...site.sites,
newtId: site.newt ? site.newt.newtId : null,
newtVersion: site.newt?.version ?? null
newtVersion: site.newt?.version ?? null,
countryCode: site.sites.endpoint
? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null)
: null
};
return response<GetSiteResponse>(res, {
@@ -1,15 +1,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, newts, primaryDb, sites } from "@server/db";
import { siteResources } from "@server/db";
import { db, siteResources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import {
performDeleteSiteResource,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive()
@@ -65,11 +67,10 @@ export async function deleteSiteResource(
const { siteResourceId } = parsedParams.data;
// Check if site resource exists
const [existingSiteResource] = await db
.select()
.from(siteResources)
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
if (!existingSiteResource) {
@@ -78,26 +79,22 @@ export async function deleteSiteResource(
);
}
// Delete the site resource
const [removedSiteResource] = await db
.delete(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
let removedSiteResource = null;
// Run in the background after the response is sent. Wrapped in its
// own transaction so it always executes on the primary — avoiding any
// replica-lag issues while still allowing the HTTP response to return
// early.
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
}
);
await db.transaction(async (trx) => {
removedSiteResource = await performDeleteSiteResource(
siteResourceId,
trx
);
});
logger.info(`Deleted site resource ${siteResourceId}`);
if (!removedSiteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
runSiteResourceDeleteSideEffects(removedSiteResource);
return response(res, {
data: { message: "Site resource deleted successfully" },
+29 -246
View File
@@ -1,8 +1,6 @@
import {
clientSiteResources,
clientSiteResourcesAssociationsCache,
db,
newts,
orgs,
roles,
roleSiteResources,
@@ -10,10 +8,7 @@ import {
SiteResource,
siteResources,
sites,
networks,
Transaction,
userSiteResources,
primaryDb
userSiteResources
} from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -21,17 +16,11 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip";
import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargetV2,
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
handleMessagingForUpdatedSiteResource,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "@server/lib/rebuildClientAssociations";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -390,7 +379,7 @@ export async function updateSiteResource(
);
}
const existingSiteIds = existingSiteResource.networkId
const existingSiteNetworks = existingSiteResource.networkId
? await db
.select()
.from(siteNetworks)
@@ -398,7 +387,7 @@ export async function updateSiteResource(
eq(siteNetworks.networkId, existingSiteResource.networkId)
)
: [];
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
@@ -464,6 +453,7 @@ export async function updateSiteResource(
}
let updatedSiteResource: SiteResource | undefined;
let updatedSiteIds: number[] = [];
await db.transaction(async (trx) => {
// Update the site resource
const sshPamSet =
@@ -534,6 +524,7 @@ export async function updateSiteResource(
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
updatedSiteIds.push(siteId);
}
await trx
@@ -605,27 +596,27 @@ export async function updateSiteResource(
throw new Error("No updated resource found after update");
}
rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch(
(e) => {
logger.error(
`Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}`
);
}
);
const finalUpdatedSiteResource = updatedSiteResource;
handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
Array.from(existingSiteIdSet).map((siteId: number) => ({
// we already added to the new sites above in the rebuild function so we only need to update the ones that did not change
siteId,
orgId: existingSiteResource.orgId
}))
).catch((e) => {
logger.error(
`Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}`
);
});
rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource)
.then(() =>
waitForSiteResourceRebuildIdle(
finalUpdatedSiteResource.siteResourceId
)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
existingSiteResource,
finalUpdatedSiteResource,
existingSiteIds,
updatedSiteIds
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${siteResourceId}. Error: ${e}`
);
});
return response(res, {
data: updatedSiteResource,
@@ -644,211 +635,3 @@ export async function updateSiteResource(
);
}
}
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
sites: { siteId: number; orgId: string }[]
) {
const trx = primaryDb;
logger.debug(
"handleMessagingForUpdatedSiteResource: existingSiteResource is: ",
existingSiteResource
);
logger.debug(
"handleMessagingForUpdatedSiteResource: updatedSiteResource is: ",
updatedSiteResource
);
const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(
existingSiteResource || updatedSiteResource,
trx
);
const siteIds = sites.map((site) => site.siteId);
// after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed
const destinationChanged =
existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
const newtsForSites =
siteIds.length > 0
? await trx
.select()
.from(newts)
.where(inArray(newts.siteId, siteIds))
: [];
const newtBySiteId = new Map(
newtsForSites.map((newt) => [newt.siteId, newt])
);
const oldDestinationStillInUseClientSitePairs = new Set<string>();
if (
existingSiteResource?.destination &&
siteIds.length > 0 &&
mergedAllClientIds.length > 0
) {
const oldDestinationStillInUseRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
mergedAllClientIds
),
inArray(siteNetworks.siteId, siteIds),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
for (const row of oldDestinationStillInUseRows) {
oldDestinationStillInUseClientSitePairs.add(
`${row.clientId}:${row.siteId}`
);
}
}
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
destinationPortChanged;
const oldTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
)
: [];
const newTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
)
: [];
for (const site of sites) {
const newt = newtBySiteId.get(site.siteId);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if these items change
if (shouldUpdateTargets) {
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
if (!existingSiteResource.destination) {
continue;
}
const oldDestinationStillInUseByASite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${site.siteId}`
);
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
// TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
}
}
}
+9 -1
View File
@@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={3}>
<InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{org.org.name}</InfoSectionContent>
@@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
{org.org.subnet || t("none")}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("utilitySubnet")}
</InfoSectionTitle>
<InfoSectionContent>
{org.org.utilitySubnet || t("none")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
+57 -26
View File
@@ -8,6 +8,7 @@ import {
type ProductUpdate,
productUpdatesQueries
} from "@app/lib/queries";
import { build } from "@server/build";
import { useQueries } from "@tanstack/react-query";
import {
ArrowRight,
@@ -39,22 +40,42 @@ export default function ProductUpdates({
}) {
const { env } = useEnvContext();
const productUpdatesEnabled = env.app.notifications.product_updates;
const versionCheckEnabled =
env.app.notifications.new_releases && build !== "saas";
const data = useQueries({
queries: [
productUpdatesQueries.list(
env.app.notifications.product_updates,
env.app.version
),
productUpdatesQueries.list(productUpdatesEnabled, env.app.version),
productUpdatesQueries.latestVersion(
env.app.notifications.new_releases
)
],
combine(result) {
if (result[0].isLoading || result[1].isLoading) return null;
return {
updates: result[0].data?.data ?? [],
latestVersion: result[1].data
};
const [updatesQuery, versionQuery] = result;
const updatesSettled =
!productUpdatesEnabled ||
updatesQuery.isFetched ||
updatesQuery.isError;
const versionSettled =
!versionCheckEnabled ||
versionQuery.isFetched ||
versionQuery.isError;
if (!updatesSettled || !versionSettled) return null;
const updates = updatesQuery.isError
? []
: Array.isArray(updatesQuery.data?.data)
? updatesQuery.data.data
: [];
const latestVersion = versionQuery.isError
? undefined
: versionQuery.data;
return { updates, latestVersion };
}
});
const t = useTranslations();
@@ -76,19 +97,30 @@ export default function ProductUpdates({
if (!data) return null;
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion;
const versionResponse = data.latestVersion?.data;
const latestVersion = versionResponse?.pangolin?.latestVersion;
const currentVersion = env.app.version;
const showNewVersionPopup = Boolean(
let showNewVersionPopup = false;
if (
latestVersion &&
valid(latestVersion) &&
valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion &&
gt(latestVersion, currentVersion)
);
valid(latestVersion) &&
valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion
) {
try {
showNewVersionPopup = gt(latestVersion, currentVersion);
} catch {
showNewVersionPopup = false;
}
}
const readUpdateIds = Array.isArray(productUpdatesRead)
? productUpdatesRead
: [];
const filteredUpdates = data.updates.filter(
(update) => !productUpdatesRead.includes(update.id)
(update) => !readUpdateIds.includes(update.id)
);
if (filteredUpdates.length === 0 && !showNewVersionPopup) {
@@ -133,17 +165,14 @@ export default function ProductUpdates({
show={filteredUpdates.length > 0}
onDimissAll={() =>
setProductUpdatesRead([
...productUpdatesRead,
...readUpdateIds,
...filteredUpdates.map(
(update) => update.id
)
])
}
onDimiss={(id) =>
setProductUpdatesRead([
...productUpdatesRead,
id
])
setProductUpdatesRead([...readUpdateIds, id])
}
/>
</div>
@@ -151,11 +180,9 @@ export default function ProductUpdates({
</div>
<NewVersionAvailable
version={data.latestVersion?.data}
version={versionResponse}
onDimiss={() => {
setIgnoredVersionUpdate(
data.latestVersion?.data?.pangolin.latestVersion ?? null
);
setIgnoredVersionUpdate(latestVersion ?? null);
}}
show={showNewVersionPopup}
/>
@@ -346,6 +373,10 @@ function NewVersionAvailable({
}
}, [show]);
if (!version?.pangolin?.latestVersion) {
return null;
}
return (
<Transition show={open}>
{version && (
+5 -13
View File
@@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return [];
return value
.split(/[,\s\n]+/)
.split(/\r?\n/)
.map((group) => group.trim())
.filter(Boolean);
}
@@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] {
export function parseSudoCommands(value: string | undefined): string[] {
if (!value?.trim()) return [];
const commands: string[] = [];
for (const segment of value.split(/[,\n]+/)) {
const trimmed = segment.trim();
if (!trimmed) continue;
for (const part of trimmed.split(/ (?=\/)/)) {
const command = part.trim();
if (command) commands.push(command);
}
}
return commands;
return value
.split(/\r?\n/)
.map((command) => command.trim())
.filter(Boolean);
}
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
+6 -1
View File
@@ -9,6 +9,7 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
type SiteInfoCardProps = {};
@@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection>
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicEndpoint(site.endpoint)}
{formatPublicEndpoint(site.endpoint)}&nbsp;
<span className="text-lg">
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
</span>
</InfoSectionContent>
</InfoSection>
) : null;
+48 -8
View File
@@ -19,6 +19,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
@@ -104,6 +105,7 @@ export default function SitesTable({
} = useNavigationContext();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteWithResources, setDeleteWithResources] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null);
@@ -157,10 +159,12 @@ export default function SitesTable({
});
}
function deleteSite(siteId: number) {
function deleteSite(siteId: number, withResources: boolean) {
startTransition(async () => {
await api
.delete(`/site/${siteId}`)
.delete(`/site/${siteId}`, {
params: { deleteResources: withResources }
})
.catch((e) => {
console.error(t("siteErrorDelete"), e);
toast({
@@ -521,16 +525,33 @@ export default function SitesTable({
)}
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setDeleteWithResources(false);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
{t("sitesTableDeleteSite")}
</span>
</DropdownMenuItem>
{siteRow.resourceCount <= 250 && (
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setDeleteWithResources(true);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t(
"sitesTableDeleteSiteAndResources"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<Link
@@ -639,19 +660,38 @@ export default function SitesTable({
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedSite(null);
setDeleteWithResources(false);
}}
dialog={
<div className="space-y-2">
<p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p>
<p>
{deleteWithResources
? t("siteQuestionRemoveAndResources")
: t("siteQuestionRemove")}
</p>
<p>
{deleteWithResources
? t("siteMessageRemoveAndResources")
: t("siteMessageRemove")}
</p>
</div>
}
buttonText={t("siteConfirmDelete")}
buttonText={
deleteWithResources
? t("siteConfirmDeleteAndResources")
: t("siteConfirmDelete")
}
onConfirm={async () =>
startTransition(() => deleteSite(selectedSite!.id))
startTransition(() =>
deleteSite(selectedSite!.id, deleteWithResources)
)
}
string={selectedSite.name}
title={t("siteDelete")}
title={
deleteWithResources
? t("siteDeleteAndResources")
: t("siteDelete")
}
/>
)}
@@ -74,6 +74,7 @@ import {
sortPolicyRulesForResourceOverlay,
type PolicyAccessRule
} from "./policy-access-rule-utils";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export type PolicyAccessRulesTableProps = {
rules: PolicyAccessRule[];
@@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({
{
accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) =>
row.original.match === "COUNTRY" ? (
cell: ({ row }) => {
let selectedCountry: (typeof COUNTRIES)[number] | undefined;
if (
row.original.match === "COUNTRY" &&
row.original.value
) {
selectedCountry = COUNTRIES.find(
(c) => c.code === row.original.value
);
}
return row.original.match === "COUNTRY" ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -502,15 +512,22 @@ export function PolicyAccessRulesTable({
}
className="w-full min-w-0 justify-between"
>
{row.original.value
? COUNTRIES.find(
(c) =>
c.code === row.original.value
)?.name +
" (" +
row.original.value +
")"
: t("selectCountry")}
{selectedCountry ? (
<>
<span>
{selectedCountry.code === "ALL"
? "🌍"
: countryCodeToFlagEmoji(
selectedCountry.code
)}
&nbsp;&nbsp;
{selectedCountry.name} (
{selectedCountry.code})
</span>
</>
) : (
t("selectCountry")
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({
<Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/>
<span>
{country.code === "ALL"
? "🌍"
: countryCodeToFlagEmoji(
country.code
)}
</span>
{country.name} (
{country.code})
</CommandItem>
@@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({
});
}}
/>
)
);
}
},
{
accessorKey: "enabled",