mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-20 09:36:40 +00:00
Compare commits
114 Commits
1.16.2-s.2
...
crowdin_de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca9ab65228 | ||
|
|
ee4e8f7029 | ||
|
|
f86a1eb32b | ||
|
|
ffd648ed74 | ||
|
|
b2b72169fd | ||
|
|
76746fb6e1 | ||
|
|
6258787c73 | ||
|
|
720080e487 | ||
|
|
46ad1317e4 | ||
|
|
cd28720e46 | ||
|
|
38af02ad3c | ||
|
|
5eed547f91 | ||
|
|
d363ee02ed | ||
|
|
594ee31f43 | ||
|
|
d9766b0f99 | ||
|
|
eeaa1d56ad | ||
|
|
e7f5bc585c | ||
|
|
4f26fb7750 | ||
|
|
cdbc190bfc | ||
|
|
1b1f9ab4cf | ||
|
|
2efe6cfdb3 | ||
|
|
517c607ecf | ||
|
|
802e8f7a22 | ||
|
|
c7cfe2efcb | ||
|
|
ae1f36f39a | ||
|
|
a479ef28ac | ||
|
|
ce2cf50b5a | ||
|
|
f48d01acde | ||
|
|
991fed93ee | ||
|
|
26ab63d0e4 | ||
|
|
03288d2a60 | ||
|
|
1169b68619 | ||
|
|
d3bfd67738 | ||
|
|
d44292cf33 | ||
|
|
2c2be50b19 | ||
|
|
e2db4c6246 | ||
|
|
c4839fee08 | ||
|
|
965b7026f0 | ||
|
|
e14e15fcbb | ||
|
|
4ca5acf158 | ||
|
|
ea41fcc566 | ||
|
|
5736c1d8ce | ||
|
|
d142366dd9 | ||
|
|
bab09dff95 | ||
|
|
23d3345ab9 | ||
|
|
09a64815d4 | ||
|
|
6d5f969798 | ||
|
|
9c430b37aa | ||
|
|
86bba494fe | ||
|
|
1a43f1ef4b | ||
|
|
75ab074805 | ||
|
|
dc4e0253de | ||
|
|
cccf236042 | ||
|
|
63fd63c65c | ||
|
|
beee1d692d | ||
|
|
fde786ca84 | ||
|
|
3086fdd064 | ||
|
|
6c30f6db31 | ||
|
|
f021b73458 | ||
|
|
74f4751bcc | ||
|
|
e5bce4e180 | ||
|
|
9b0e7b381c | ||
|
|
90afe5a7ac | ||
|
|
b24de85157 | ||
|
|
eda43dffe1 | ||
|
|
82c9a1eb70 | ||
|
|
a3d4553d14 | ||
|
|
1cc5f59f66 | ||
|
|
4e2d88efdd | ||
|
|
4975cabb2c | ||
|
|
225591094f | ||
|
|
82f88f2cd3 | ||
|
|
99e6bd31b6 | ||
|
|
5c50590d7b | ||
|
|
072c89e704 | ||
|
|
dbdff6812d | ||
|
|
42b9d5158d | ||
|
|
2ba225299e | ||
|
|
cc841d5640 | ||
|
|
fa0818d3fa | ||
|
|
dec358c4cd | ||
|
|
e98f873f81 | ||
|
|
e9a2a7e752 | ||
|
|
06015d5191 | ||
|
|
af688d2a23 | ||
|
|
7d0b3ec6b5 | ||
|
|
cf5fb8dc33 | ||
|
|
9a0a255445 | ||
|
|
91b7ceb2cf | ||
|
|
d5a37436c0 | ||
|
|
be609b5000 | ||
|
|
0503c6e66e | ||
|
|
d4b830b9bb | ||
|
|
14d6ff25a7 | ||
|
|
1f62f305ce | ||
|
|
9405b0b70a | ||
|
|
a26ee4ac1a | ||
|
|
cebcf3e337 | ||
|
|
4cfcc64481 | ||
|
|
1a2069a6d9 | ||
|
|
2a5c9465e9 | ||
|
|
f36b66e397 | ||
|
|
8c6d44677d | ||
|
|
1bfff630bf | ||
|
|
ebcef28b05 | ||
|
|
e87e12898c | ||
|
|
d60ab281cf | ||
|
|
483d54a9f0 | ||
|
|
0ab6ff9148 | ||
|
|
c73a39f797 | ||
|
|
75a909784a | ||
|
|
244f497a9c | ||
|
|
e58f0c9f07 | ||
|
|
5f18c06e03 |
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.",
|
"resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.",
|
||||||
"resourceRaw": "Суров TCP/UDP ресурс",
|
"resourceRaw": "Суров TCP/UDP ресурс",
|
||||||
"resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.",
|
"resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.",
|
||||||
"resourceRawDescriptionCloud": "Прокси заявките през суров TCP/UDP, използвайки номер на порт. ИЗИСКВА ИЗПОЛЗВАНЕ НА ОТДАЛЕЧЕН УЗЕЛ.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Създайте ресурс",
|
"resourceCreate": "Създайте ресурс",
|
||||||
"resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс",
|
"resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс",
|
||||||
"resourceSeeAll": "Вижте всички ресурси",
|
"resourceSeeAll": "Вижте всички ресурси",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Име на пространство: {namespace}",
|
"domainPickerNamespace": "Име на пространство: {namespace}",
|
||||||
"domainPickerShowMore": "Покажи повече",
|
"domainPickerShowMore": "Покажи повече",
|
||||||
"regionSelectorTitle": "Избор на регион",
|
"regionSelectorTitle": "Избор на регион",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.",
|
"regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.",
|
||||||
"regionSelectorPlaceholder": "Изберете регион",
|
"regionSelectorPlaceholder": "Изберете регион",
|
||||||
"regionSelectorComingSoon": "Очаква се скоро",
|
"regionSelectorComingSoon": "Очаква се скоро",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
"logRetentionEndOfFollowingYear": "Край на следващата година",
|
||||||
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
|
||||||
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
|
||||||
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Необходимо е <enterpriseEditionLink>изданието Enterprise</enterpriseEditionLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Решавач на сертификати",
|
"certResolver": "Решавач на сертификати",
|
||||||
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
|
||||||
"selectCertResolver": "Изберете решавач на сертификати",
|
"selectCertResolver": "Изберете решавач на сертификати",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства",
|
"approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства",
|
||||||
"approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.",
|
"approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед",
|
"approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед",
|
||||||
"approvalsEmptyStateButtonText": "Управлявайте роли"
|
"approvalsEmptyStateButtonText": "Управлявайте роли",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.",
|
"resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.",
|
||||||
"resourceRaw": "Surový TCP/UDP zdroj",
|
"resourceRaw": "Surový TCP/UDP zdroj",
|
||||||
"resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.",
|
"resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.",
|
||||||
"resourceRawDescriptionCloud": "Požadavky na proxy přes syrové TCP/UDP pomocí portového čísla. ŽÁDOSTI POUŽÍVAT POUŽITÍ Z REMOTE NODE.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Vytvořit zdroj",
|
"resourceCreate": "Vytvořit zdroj",
|
||||||
"resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
|
"resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj",
|
||||||
"resourceSeeAll": "Zobrazit všechny zdroje",
|
"resourceSeeAll": "Zobrazit všechny zdroje",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Jmenný prostor: {namespace}",
|
"domainPickerNamespace": "Jmenný prostor: {namespace}",
|
||||||
"domainPickerShowMore": "Zobrazit více",
|
"domainPickerShowMore": "Zobrazit více",
|
||||||
"regionSelectorTitle": "Vybrat region",
|
"regionSelectorTitle": "Vybrat region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.",
|
"regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.",
|
||||||
"regionSelectorPlaceholder": "Vyberte region",
|
"regionSelectorPlaceholder": "Vyberte region",
|
||||||
"regionSelectorComingSoon": "Již brzy",
|
"regionSelectorComingSoon": "Již brzy",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
"logRetentionEndOfFollowingYear": "Konec následujícího roku",
|
||||||
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
|
||||||
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
|
||||||
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Tato funkce je také dostupná v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Oddělovač certifikátů",
|
"certResolver": "Oddělovač certifikátů",
|
||||||
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
|
||||||
"selectCertResolver": "Vyberte řešič certifikátů",
|
"selectCertResolver": "Vyberte řešič certifikátů",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Povolit schválení zařízení",
|
"approvalsEmptyStateStep2Title": "Povolit schválení zařízení",
|
||||||
"approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.",
|
"approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi",
|
"approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi",
|
||||||
"approvalsEmptyStateButtonText": "Spravovat role"
|
"approvalsEmptyStateButtonText": "Spravovat role",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.",
|
"resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.",
|
||||||
"resourceRaw": "Direkte TCP/UDP Ressource (raw)",
|
"resourceRaw": "Direkte TCP/UDP Ressource (raw)",
|
||||||
"resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.",
|
"resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.",
|
||||||
"resourceRawDescriptionCloud": "Proxy-Anfragen über rohe TCP/UDP mit einer Portnummer. Erfordert die NUTZUNG eines REMOTE Knotens.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Ressource erstellen",
|
"resourceCreate": "Ressource erstellen",
|
||||||
"resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen",
|
"resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen",
|
||||||
"resourceSeeAll": "Alle Ressourcen anzeigen",
|
"resourceSeeAll": "Alle Ressourcen anzeigen",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mehr anzeigen",
|
"domainPickerShowMore": "Mehr anzeigen",
|
||||||
"regionSelectorTitle": "Region auswählen",
|
"regionSelectorTitle": "Region auswählen",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.",
|
"regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.",
|
||||||
"regionSelectorPlaceholder": "Wähle eine Region",
|
"regionSelectorPlaceholder": "Wähle eine Region",
|
||||||
"regionSelectorComingSoon": "Kommt bald",
|
"regionSelectorComingSoon": "Kommt bald",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
|
||||||
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
|
||||||
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
|
||||||
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Zertifikatsauflöser",
|
"certResolver": "Zertifikatsauflöser",
|
||||||
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
|
||||||
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
"selectCertResolver": "Zertifikatsauflöser auswählen",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren",
|
"approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren",
|
||||||
"approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.",
|
"approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt",
|
"approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt",
|
||||||
"approvalsEmptyStateButtonText": "Rollen verwalten"
|
"approvalsEmptyStateButtonText": "Rollen verwalten",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||||
"resourceRaw": "Raw TCP/UDP Resource",
|
"resourceRaw": "Raw TCP/UDP Resource",
|
||||||
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
|
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
|
||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. REQUIRES THE USE OF A REMOTE NODE.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
@@ -1427,6 +1427,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Show More",
|
"domainPickerShowMore": "Show More",
|
||||||
"regionSelectorTitle": "Select Region",
|
"regionSelectorTitle": "Select Region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be availble on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
|
"regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.",
|
||||||
"regionSelectorPlaceholder": "Choose a region",
|
"regionSelectorPlaceholder": "Choose a region",
|
||||||
"regionSelectorComingSoon": "Coming Soon",
|
"regionSelectorComingSoon": "Coming Soon",
|
||||||
@@ -2343,8 +2344,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "End of following year",
|
"logRetentionEndOfFollowingYear": "End of following year",
|
||||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
@@ -2681,5 +2682,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
||||||
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
||||||
"approvalsEmptyStateButtonText": "Manage Roles"
|
"approvalsEmptyStateButtonText": "Manage Roles",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.",
|
"resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.",
|
||||||
"resourceRaw": "Recurso TCP/UDP sin procesar",
|
"resourceRaw": "Recurso TCP/UDP sin procesar",
|
||||||
"resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.",
|
"resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.",
|
||||||
"resourceRawDescriptionCloud": "Las peticiones de proxy sobre TCP/UDP crudas usando un número de puerto. REQUIERE EL USO DE UN NODO REMOTE.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Crear Recurso",
|
"resourceCreate": "Crear Recurso",
|
||||||
"resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso",
|
"resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso",
|
||||||
"resourceSeeAll": "Ver todos los recursos",
|
"resourceSeeAll": "Ver todos los recursos",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Espacio de nombres: {namespace}",
|
"domainPickerNamespace": "Espacio de nombres: {namespace}",
|
||||||
"domainPickerShowMore": "Mostrar más",
|
"domainPickerShowMore": "Mostrar más",
|
||||||
"regionSelectorTitle": "Seleccionar Región",
|
"regionSelectorTitle": "Seleccionar Región",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
|
"regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.",
|
||||||
"regionSelectorPlaceholder": "Elige una región",
|
"regionSelectorPlaceholder": "Elige una región",
|
||||||
"regionSelectorComingSoon": "Próximamente",
|
"regionSelectorComingSoon": "Próximamente",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
"logRetentionEndOfFollowingYear": "Fin del año siguiente",
|
||||||
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
|
||||||
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
|
||||||
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> para utilizar esta función. Esta característica también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>versión Enterprise</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Resolver certificado",
|
"certResolver": "Resolver certificado",
|
||||||
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
|
||||||
"selectCertResolver": "Seleccionar Resolver Certificado",
|
"selectCertResolver": "Seleccionar Resolver Certificado",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
|
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
|
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
|
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
|
||||||
"approvalsEmptyStateButtonText": "Administrar roles"
|
"approvalsEmptyStateButtonText": "Administrar roles",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.",
|
"resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.",
|
||||||
"resourceRaw": "Ressource TCP/UDP brute",
|
"resourceRaw": "Ressource TCP/UDP brute",
|
||||||
"resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.",
|
"resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.",
|
||||||
"resourceRawDescriptionCloud": "Requêtes de proxy sur TCP/UDP brute en utilisant un numéro de port. REQUISE L'UTILISATION D'UN Nœud DE REMOTE.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Créer une ressource",
|
"resourceCreate": "Créer une ressource",
|
||||||
"resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource",
|
"resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource",
|
||||||
"resourceSeeAll": "Voir toutes les ressources",
|
"resourceSeeAll": "Voir toutes les ressources",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Espace de noms : {namespace}",
|
"domainPickerNamespace": "Espace de noms : {namespace}",
|
||||||
"domainPickerShowMore": "Afficher plus",
|
"domainPickerShowMore": "Afficher plus",
|
||||||
"regionSelectorTitle": "Sélectionner Région",
|
"regionSelectorTitle": "Sélectionner Région",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.",
|
"regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.",
|
||||||
"regionSelectorPlaceholder": "Choisissez une région",
|
"regionSelectorPlaceholder": "Choisissez une région",
|
||||||
"regionSelectorComingSoon": "Bientôt disponible",
|
"regionSelectorComingSoon": "Bientôt disponible",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
"logRetentionEndOfFollowingYear": "Fin de l'année suivante",
|
||||||
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
|
||||||
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
|
||||||
"licenseRequiredToUse": "Une licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Résolveur de certificat",
|
"certResolver": "Résolveur de certificat",
|
||||||
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
|
||||||
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
"selectCertResolver": "Sélectionnez le résolveur de certificat",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil",
|
"approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil",
|
||||||
"approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.",
|
"approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification",
|
"approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification",
|
||||||
"approvalsEmptyStateButtonText": "Gérer les rôles"
|
"approvalsEmptyStateButtonText": "Gérer les rôles",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
||||||
"resourceRaw": "Risorsa Raw TCP/UDP",
|
"resourceRaw": "Risorsa Raw TCP/UDP",
|
||||||
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
||||||
"resourceRawDescriptionCloud": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta. RICHIEDE L'USO DI UN NODO REMOTO.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Crea Risorsa",
|
"resourceCreate": "Crea Risorsa",
|
||||||
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
||||||
"resourceSeeAll": "Vedi Tutte Le Risorse",
|
"resourceSeeAll": "Vedi Tutte Le Risorse",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mostra Altro",
|
"domainPickerShowMore": "Mostra Altro",
|
||||||
"regionSelectorTitle": "Seleziona regione",
|
"regionSelectorTitle": "Seleziona regione",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.",
|
"regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.",
|
||||||
"regionSelectorPlaceholder": "Scegli una regione",
|
"regionSelectorPlaceholder": "Scegli una regione",
|
||||||
"regionSelectorComingSoon": "Prossimamente",
|
"regionSelectorComingSoon": "Prossimamente",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
|
||||||
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
|
||||||
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
|
||||||
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Risolutore Di Certificato",
|
"certResolver": "Risolutore Di Certificato",
|
||||||
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
|
||||||
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
"selectCertResolver": "Seleziona Risolutore Di Certificato",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo",
|
"approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.",
|
"approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione",
|
"approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione",
|
||||||
"approvalsEmptyStateButtonText": "Gestisci Ruoli"
|
"approvalsEmptyStateButtonText": "Gestisci Ruoli",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.",
|
"resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.",
|
||||||
"resourceRaw": "원시 TCP/UDP 리소스",
|
"resourceRaw": "원시 TCP/UDP 리소스",
|
||||||
"resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.",
|
"resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.",
|
||||||
"resourceRawDescriptionCloud": "원시 TCP/UDP를 포트 번호를 사용하여 프록시 요청합니다. 원격 노드 사용이 필요합니다.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "리소스 생성",
|
"resourceCreate": "리소스 생성",
|
||||||
"resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.",
|
"resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.",
|
||||||
"resourceSeeAll": "모든 리소스 보기",
|
"resourceSeeAll": "모든 리소스 보기",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "이름 공간: {namespace}",
|
"domainPickerNamespace": "이름 공간: {namespace}",
|
||||||
"domainPickerShowMore": "더보기",
|
"domainPickerShowMore": "더보기",
|
||||||
"regionSelectorTitle": "지역 선택",
|
"regionSelectorTitle": "지역 선택",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.",
|
"regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.",
|
||||||
"regionSelectorPlaceholder": "지역 선택",
|
"regionSelectorPlaceholder": "지역 선택",
|
||||||
"regionSelectorComingSoon": "곧 출시 예정",
|
"regionSelectorComingSoon": "곧 출시 예정",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
"logRetentionEndOfFollowingYear": "다음 연도 말",
|
||||||
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
|
||||||
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
|
||||||
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "인증서 해결사",
|
"certResolver": "인증서 해결사",
|
||||||
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
|
||||||
"selectCertResolver": "인증서 해결사 선택",
|
"selectCertResolver": "인증서 해결사 선택",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "장치 승인 활성화",
|
"approvalsEmptyStateStep2Title": "장치 승인 활성화",
|
||||||
"approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.",
|
"approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.",
|
||||||
"approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.",
|
"approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.",
|
||||||
"approvalsEmptyStateButtonText": "역할 관리"
|
"approvalsEmptyStateButtonText": "역할 관리",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.",
|
"resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.",
|
||||||
"resourceRaw": "Rå TCP/UDP-ressurs",
|
"resourceRaw": "Rå TCP/UDP-ressurs",
|
||||||
"resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.",
|
"resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.",
|
||||||
"resourceRawDescriptionCloud": "Proxy ber om et portnummer. Om du vil bruke et sportsnummer.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Opprett ressurs",
|
"resourceCreate": "Opprett ressurs",
|
||||||
"resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs",
|
"resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs",
|
||||||
"resourceSeeAll": "Se alle ressurser",
|
"resourceSeeAll": "Se alle ressurser",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Navnerom: {namespace}",
|
"domainPickerNamespace": "Navnerom: {namespace}",
|
||||||
"domainPickerShowMore": "Vis mer",
|
"domainPickerShowMore": "Vis mer",
|
||||||
"regionSelectorTitle": "Velg Region",
|
"regionSelectorTitle": "Velg Region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.",
|
"regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.",
|
||||||
"regionSelectorPlaceholder": "Velg en region",
|
"regionSelectorPlaceholder": "Velg en region",
|
||||||
"regionSelectorComingSoon": "Kommer snart",
|
"regionSelectorComingSoon": "Kommer snart",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
"logRetentionEndOfFollowingYear": "Slutt på neste år",
|
||||||
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
|
||||||
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
|
||||||
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Sertifikat løser",
|
"certResolver": "Sertifikat løser",
|
||||||
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
|
||||||
"selectCertResolver": "Velg sertifikatløser",
|
"selectCertResolver": "Velg sertifikatløser",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
|
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
|
||||||
"approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.",
|
"approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering",
|
"approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering",
|
||||||
"approvalsEmptyStateButtonText": "Administrer Roller"
|
"approvalsEmptyStateButtonText": "Administrer Roller",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.",
|
"resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.",
|
||||||
"resourceRaw": "TCP/UDP bron",
|
"resourceRaw": "TCP/UDP bron",
|
||||||
"resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.",
|
"resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.",
|
||||||
"resourceRawDescriptionCloud": "Proxy vraagt om onbewerkte TCP/UDP met behulp van een poortnummer. VEREIST HET GEBRUIK VAN EEN AFSTANDSBEDIENING NODE.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Bron maken",
|
"resourceCreate": "Bron maken",
|
||||||
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
"resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken",
|
||||||
"resourceSeeAll": "Alle bronnen bekijken",
|
"resourceSeeAll": "Alle bronnen bekijken",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Naamruimte: {namespace}",
|
"domainPickerNamespace": "Naamruimte: {namespace}",
|
||||||
"domainPickerShowMore": "Meer weergeven",
|
"domainPickerShowMore": "Meer weergeven",
|
||||||
"regionSelectorTitle": "Selecteer Regio",
|
"regionSelectorTitle": "Selecteer Regio",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.",
|
"regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.",
|
||||||
"regionSelectorPlaceholder": "Kies een regio",
|
"regionSelectorPlaceholder": "Kies een regio",
|
||||||
"regionSelectorComingSoon": "Komt binnenkort",
|
"regionSelectorComingSoon": "Komt binnenkort",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
"logRetentionEndOfFollowingYear": "Einde van volgend jaar",
|
||||||
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
|
||||||
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
|
||||||
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Certificaat Resolver",
|
"certResolver": "Certificaat Resolver",
|
||||||
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
|
||||||
"selectCertResolver": "Certificaat Resolver selecteren",
|
"selectCertResolver": "Certificaat Resolver selecteren",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen",
|
"approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen",
|
||||||
"approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.",
|
"approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen",
|
"approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen",
|
||||||
"approvalsEmptyStateButtonText": "Rollen beheren"
|
"approvalsEmptyStateButtonText": "Rollen beheren",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.",
|
"resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.",
|
||||||
"resourceRaw": "Surowy zasób TCP/UDP",
|
"resourceRaw": "Surowy zasób TCP/UDP",
|
||||||
"resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.",
|
"resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.",
|
||||||
"resourceRawDescriptionCloud": "Proxy żądania przesyłania danych nad surowym TCP/UDP przy użyciu numeru portu. Wymaga UŻYTKOWANIA PALIWA węzła.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Utwórz zasób",
|
"resourceCreate": "Utwórz zasób",
|
||||||
"resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób",
|
"resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób",
|
||||||
"resourceSeeAll": "Zobacz wszystkie zasoby",
|
"resourceSeeAll": "Zobacz wszystkie zasoby",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Przestrzeń nazw: {namespace}",
|
"domainPickerNamespace": "Przestrzeń nazw: {namespace}",
|
||||||
"domainPickerShowMore": "Pokaż więcej",
|
"domainPickerShowMore": "Pokaż więcej",
|
||||||
"regionSelectorTitle": "Wybierz region",
|
"regionSelectorTitle": "Wybierz region",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.",
|
"regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.",
|
||||||
"regionSelectorPlaceholder": "Wybierz region",
|
"regionSelectorPlaceholder": "Wybierz region",
|
||||||
"regionSelectorComingSoon": "Wkrótce dostępne",
|
"regionSelectorComingSoon": "Wkrótce dostępne",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
"logRetentionEndOfFollowingYear": "Koniec następnego roku",
|
||||||
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
|
||||||
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
|
||||||
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Rozwiązywanie certyfikatów",
|
"certResolver": "Rozwiązywanie certyfikatów",
|
||||||
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
|
||||||
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
"selectCertResolver": "Wybierz Resolver certyfikatów",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
|
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
|
||||||
"approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.",
|
"approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj",
|
"approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj",
|
||||||
"approvalsEmptyStateButtonText": "Zarządzaj rolami"
|
"approvalsEmptyStateButtonText": "Zarządzaj rolami",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.",
|
"resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.",
|
||||||
"resourceRaw": "Recurso TCP/UDP bruto",
|
"resourceRaw": "Recurso TCP/UDP bruto",
|
||||||
"resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.",
|
"resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.",
|
||||||
"resourceRawDescriptionCloud": "Proxy solicita sobre TCP/UDP bruto usando um número de porta. OBRIGATÓRIO O USO DE UMA NOTA REMOTA.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Criar Recurso",
|
"resourceCreate": "Criar Recurso",
|
||||||
"resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso",
|
"resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso",
|
||||||
"resourceSeeAll": "Ver todos os recursos",
|
"resourceSeeAll": "Ver todos os recursos",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Namespace: {namespace}",
|
"domainPickerNamespace": "Namespace: {namespace}",
|
||||||
"domainPickerShowMore": "Mostrar Mais",
|
"domainPickerShowMore": "Mostrar Mais",
|
||||||
"regionSelectorTitle": "Selecionar Região",
|
"regionSelectorTitle": "Selecionar Região",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.",
|
"regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.",
|
||||||
"regionSelectorPlaceholder": "Escolher uma região",
|
"regionSelectorPlaceholder": "Escolher uma região",
|
||||||
"regionSelectorComingSoon": "Em breve",
|
"regionSelectorComingSoon": "Em breve",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
"logRetentionEndOfFollowingYear": "Fim do ano seguinte",
|
||||||
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
|
||||||
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
|
||||||
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> é necessária para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Resolvedor de Certificado",
|
"certResolver": "Resolvedor de Certificado",
|
||||||
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
|
||||||
"selectCertResolver": "Selecionar solucionador de certificado",
|
"selectCertResolver": "Selecionar solucionador de certificado",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo",
|
"approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo",
|
||||||
"approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.",
|
"approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão",
|
"approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão",
|
||||||
"approvalsEmptyStateButtonText": "Gerir Funções"
|
"approvalsEmptyStateButtonText": "Gerir Funções",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
"resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.",
|
||||||
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
"resourceRaw": "Сырой TCP/UDP-ресурс",
|
||||||
"resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.",
|
"resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.",
|
||||||
"resourceRawDescriptionCloud": "Прокси-запросы через необработанный TCP/UDP с использованием номера порта. ТРЕБУЕТЕСЬ ИСПОЛЬЗОВАТЬ НЕОБХОДИМЫ.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Создание ресурса",
|
"resourceCreate": "Создание ресурса",
|
||||||
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
"resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса",
|
||||||
"resourceSeeAll": "Посмотреть все ресурсы",
|
"resourceSeeAll": "Посмотреть все ресурсы",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Пространство имен: {namespace}",
|
"domainPickerNamespace": "Пространство имен: {namespace}",
|
||||||
"domainPickerShowMore": "Показать еще",
|
"domainPickerShowMore": "Показать еще",
|
||||||
"regionSelectorTitle": "Выберите регион",
|
"regionSelectorTitle": "Выберите регион",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.",
|
"regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.",
|
||||||
"regionSelectorPlaceholder": "Выбор региона",
|
"regionSelectorPlaceholder": "Выбор региона",
|
||||||
"regionSelectorComingSoon": "Скоро будет",
|
"regionSelectorComingSoon": "Скоро будет",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
"logRetentionEndOfFollowingYear": "Конец следующего года",
|
||||||
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
|
||||||
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
|
||||||
"licenseRequiredToUse": "Лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Для использования этой функции требуется <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Резольвер сертификата",
|
"certResolver": "Резольвер сертификата",
|
||||||
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
|
||||||
"selectCertResolver": "Выберите резолвер сертификата",
|
"selectCertResolver": "Выберите резолвер сертификата",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Включить утверждения устройства",
|
"approvalsEmptyStateStep2Title": "Включить утверждения устройства",
|
||||||
"approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.",
|
"approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки",
|
"approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки",
|
||||||
"approvalsEmptyStateButtonText": "Управление ролями"
|
"approvalsEmptyStateButtonText": "Управление ролями",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.",
|
"resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.",
|
||||||
"resourceRaw": "Ham TCP/UDP Kaynağı",
|
"resourceRaw": "Ham TCP/UDP Kaynağı",
|
||||||
"resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.",
|
"resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.",
|
||||||
"resourceRawDescriptionCloud": "Bir port numarası kullanarak ham TCP/UDP üzerinden istekleri proxy ile yönlendirin. UZAKTAN BİR DÜĞÜM KULLANIMINI GEREKTİRİR.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Kaynak Oluştur",
|
"resourceCreate": "Kaynak Oluştur",
|
||||||
"resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin",
|
"resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin",
|
||||||
"resourceSeeAll": "Tüm Kaynakları Gör",
|
"resourceSeeAll": "Tüm Kaynakları Gör",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "Ad Alanı: {namespace}",
|
"domainPickerNamespace": "Ad Alanı: {namespace}",
|
||||||
"domainPickerShowMore": "Daha Fazla Göster",
|
"domainPickerShowMore": "Daha Fazla Göster",
|
||||||
"regionSelectorTitle": "Bölge Seç",
|
"regionSelectorTitle": "Bölge Seç",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.",
|
"regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.",
|
||||||
"regionSelectorPlaceholder": "Bölge Seçin",
|
"regionSelectorPlaceholder": "Bölge Seçin",
|
||||||
"regionSelectorComingSoon": "Yakında Geliyor",
|
"regionSelectorComingSoon": "Yakında Geliyor",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
|
||||||
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
|
||||||
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
|
||||||
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "Sertifika Çözücü",
|
"certResolver": "Sertifika Çözücü",
|
||||||
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
|
||||||
"selectCertResolver": "Sertifika Çözücü Seçin",
|
"selectCertResolver": "Sertifika Çözücü Seçin",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir",
|
"approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir",
|
||||||
"approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.",
|
"approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.",
|
||||||
"approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.",
|
"approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.",
|
||||||
"approvalsEmptyStateButtonText": "Rolleri Yönet"
|
"approvalsEmptyStateButtonText": "Rolleri Yönet",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
"resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。",
|
||||||
"resourceRaw": "TCP/UDP 资源",
|
"resourceRaw": "TCP/UDP 资源",
|
||||||
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
"resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。",
|
||||||
"resourceRawDescriptionCloud": "正在使用端口号的 TCP/UDP 代理请求。请使用一个REMOTE",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "创建资源",
|
"resourceCreate": "创建资源",
|
||||||
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
"resourceCreateDescription": "按照下面的步骤创建新资源",
|
||||||
"resourceSeeAll": "查看所有资源",
|
"resourceSeeAll": "查看所有资源",
|
||||||
@@ -1426,6 +1426,7 @@
|
|||||||
"domainPickerNamespace": "命名空间:{namespace}",
|
"domainPickerNamespace": "命名空间:{namespace}",
|
||||||
"domainPickerShowMore": "显示更多",
|
"domainPickerShowMore": "显示更多",
|
||||||
"regionSelectorTitle": "选择区域",
|
"regionSelectorTitle": "选择区域",
|
||||||
|
"domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.",
|
||||||
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
"regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。",
|
||||||
"regionSelectorPlaceholder": "选择一个区域",
|
"regionSelectorPlaceholder": "选择一个区域",
|
||||||
"regionSelectorComingSoon": "即将推出",
|
"regionSelectorComingSoon": "即将推出",
|
||||||
@@ -2342,8 +2343,8 @@
|
|||||||
"logRetentionEndOfFollowingYear": "下一年结束",
|
"logRetentionEndOfFollowingYear": "下一年结束",
|
||||||
"actionLogsDescription": "查看此机构执行的操作历史",
|
"actionLogsDescription": "查看此机构执行的操作历史",
|
||||||
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
||||||
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
|
||||||
"certResolver": "证书解决器",
|
"certResolver": "证书解决器",
|
||||||
"certResolverDescription": "选择用于此资源的证书解析器。",
|
"certResolverDescription": "选择用于此资源的证书解析器。",
|
||||||
"selectCertResolver": "选择证书解析",
|
"selectCertResolver": "选择证书解析",
|
||||||
@@ -2680,5 +2681,6 @@
|
|||||||
"approvalsEmptyStateStep2Title": "启用设备批准",
|
"approvalsEmptyStateStep2Title": "启用设备批准",
|
||||||
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
|
||||||
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
|
||||||
"approvalsEmptyStateButtonText": "管理角色"
|
"approvalsEmptyStateButtonText": "管理角色",
|
||||||
|
"domainErrorTitle": "We are having trouble verifying your domain"
|
||||||
}
|
}
|
||||||
|
|||||||
3938
package-lock.json
generated
3938
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -33,7 +33,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.989.0",
|
"@aws-sdk/client-s3": "3.1004.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
@@ -80,16 +80,16 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.3.0",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.9.3",
|
"ioredis": "5.10.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.563.0",
|
"lucide-react": "0.577.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.12",
|
"next": "15.5.12",
|
||||||
@@ -99,21 +99,21 @@
|
|||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.1",
|
"nodemailer": "8.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.19.0",
|
"pg": "8.20.0",
|
||||||
"posthog-node": "5.26.0",
|
"posthog-node": "5.28.0",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.2",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.71.2",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"resend": "6.9.2",
|
||||||
"semver": "7.7.4",
|
"semver": "7.7.4",
|
||||||
"sshpk": "^1.18.0",
|
"sshpk": "^1.18.0",
|
||||||
"stripe": "20.3.1",
|
"stripe": "20.4.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
@@ -131,10 +131,10 @@
|
|||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.52.0",
|
"@dotenvx/dotenvx": "1.54.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.8",
|
"@react-email/preview-server": "5.2.8",
|
||||||
"@tailwindcss/postcss": "4.1.18",
|
"@tailwindcss/postcss": "4.2.1",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
@@ -146,10 +146,10 @@
|
|||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.2.3",
|
"@types/node": "25.3.5",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "7.0.11",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.16.0",
|
"@types/pg": "8.18.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
@@ -167,10 +167,14 @@
|
|||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.1",
|
||||||
"react-email": "5.2.8",
|
"react-email": "5.2.8",
|
||||||
"tailwindcss": "4.1.18",
|
"tailwindcss": "4.2.1",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.55.0"
|
"typescript-eslint": "8.56.1"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"esbuild": "0.27.3",
|
||||||
|
"dompurify": "3.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
import { cleanup as wsCleanup } from "#dynamic/routers/ws";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -328,6 +328,14 @@ export const approvals = pgTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const bannedEmails = pgTable("bannedEmails", {
|
||||||
|
email: varchar("email", { length: 255 }).primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bannedIps = pgTable("bannedIps", {
|
||||||
|
ip: varchar("ip", { length: 255 }).primaryKey(),
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export const domains = pgTable("domains", {
|
|||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: varchar("certResolver"),
|
certResolver: varchar("certResolver"),
|
||||||
customCertResolver: varchar("customCertResolver"),
|
customCertResolver: varchar("customCertResolver"),
|
||||||
preferWildcardCert: boolean("preferWildcardCert")
|
preferWildcardCert: boolean("preferWildcardCert"),
|
||||||
|
errorMessage: text("errorMessage")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = pgTable("dnsRecords", {
|
export const dnsRecords = pgTable("dnsRecords", {
|
||||||
@@ -88,6 +89,7 @@ export const sites = pgTable("sites", {
|
|||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||||
online: boolean("online").notNull().default(false),
|
online: boolean("online").notNull().default(false),
|
||||||
|
lastPing: integer("lastPing"),
|
||||||
address: varchar("address"),
|
address: varchar("address"),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey"),
|
publicKey: varchar("publicKey"),
|
||||||
@@ -283,6 +285,7 @@ export const users = pgTable("user", {
|
|||||||
dateCreated: varchar("dateCreated").notNull(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"),
|
||||||
termsVersion: varchar("termsVersion"),
|
termsVersion: varchar("termsVersion"),
|
||||||
|
marketingEmailConsent: boolean("marketingEmailConsent").default(false),
|
||||||
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
serverAdmin: boolean("serverAdmin").notNull().default(false),
|
||||||
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
lastPasswordChange: bigint("lastPasswordChange", { mode: "number" })
|
||||||
});
|
});
|
||||||
@@ -719,6 +722,7 @@ export const clientSitesAssociationsCache = pgTable(
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId").notNull(),
|
siteId: integer("siteId").notNull(),
|
||||||
isRelayed: boolean("isRelayed").notNull().default(false),
|
isRelayed: boolean("isRelayed").notNull().default(false),
|
||||||
|
isJitMode: boolean("isJitMode").notNull().default(false),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,15 @@ export const approvals = sqliteTable("approvals", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const bannedEmails = sqliteTable("bannedEmails", {
|
||||||
|
email: text("email").primaryKey()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bannedIps = sqliteTable("bannedIps", {
|
||||||
|
ip: text("ip").primaryKey()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
|
errorMessage: text("errorMessage")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -89,6 +90,7 @@ export const sites = sqliteTable("sites", {
|
|||||||
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
lastBandwidthUpdate: text("lastBandwidthUpdate"),
|
||||||
type: text("type").notNull(), // "newt" or "wireguard"
|
type: text("type").notNull(), // "newt" or "wireguard"
|
||||||
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
online: integer("online", { mode: "boolean" }).notNull().default(false),
|
||||||
|
lastPing: integer("lastPing"),
|
||||||
|
|
||||||
// exit node stuff that is how to connect to the site when it has a wg server
|
// exit node stuff that is how to connect to the site when it has a wg server
|
||||||
address: text("address"), // this is the address of the wireguard interface in newt
|
address: text("address"), // this is the address of the wireguard interface in newt
|
||||||
@@ -314,6 +316,9 @@ export const users = sqliteTable("user", {
|
|||||||
dateCreated: text("dateCreated").notNull(),
|
dateCreated: text("dateCreated").notNull(),
|
||||||
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
termsAcceptedTimestamp: text("termsAcceptedTimestamp"),
|
||||||
termsVersion: text("termsVersion"),
|
termsVersion: text("termsVersion"),
|
||||||
|
marketingEmailConsent: integer("marketingEmailConsent", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).default(false),
|
||||||
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
serverAdmin: integer("serverAdmin", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -406,6 +411,9 @@ export const clientSitesAssociationsCache = sqliteTable(
|
|||||||
isRelayed: integer("isRelayed", { mode: "boolean" })
|
isRelayed: integer("isRelayed", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
isJitMode: integer("isJitMode", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
endpoint: text("endpoint"),
|
endpoint: text("endpoint"),
|
||||||
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export async function applyBlueprint({
|
|||||||
[target],
|
[target],
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||||
result.proxyResource.protocol,
|
result.proxyResource.protocol,
|
||||||
result.proxyResource.proxyPort
|
site.newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/log
|
|||||||
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
||||||
import { gt, or } from "drizzle-orm";
|
import { gt, or } from "drizzle-orm";
|
||||||
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
|
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export function initLogCleanupInterval() {
|
export function initLogCleanupInterval() {
|
||||||
|
if (build == "saas") { // skip log cleanup for saas builds
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return setInterval(
|
return setInterval(
|
||||||
async () => {
|
async () => {
|
||||||
const orgsToClean = await db
|
const orgsToClean = await db
|
||||||
|
|||||||
20
server/lib/clientVersionChecks.ts
Normal file
20
server/lib/clientVersionChecks.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import semver from "semver";
|
||||||
|
|
||||||
|
export function canCompress(
|
||||||
|
clientVersion: string | null | undefined,
|
||||||
|
type: "newt" | "olm"
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
if (!clientVersion) return false;
|
||||||
|
// check if it is a valid semver
|
||||||
|
if (!semver.valid(clientVersion)) return false;
|
||||||
|
if (type === "newt") {
|
||||||
|
return semver.gte(clientVersion, "1.10.3");
|
||||||
|
} else if (type === "olm") {
|
||||||
|
return semver.gte(clientVersion, "1.4.3");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,9 +85,7 @@ export async function deleteOrgById(
|
|||||||
deletedNewtIds.push(deletedNewt.newtId);
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
await trx
|
await trx
|
||||||
.delete(newtSessions)
|
.delete(newtSessions)
|
||||||
.where(
|
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
||||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,33 +119,38 @@ export async function deleteOrgById(
|
|||||||
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||||
|
|
||||||
const allOrgDomains = await trx
|
const allOrgDomains = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
.innerJoin(domains, eq(orgDomains.domainId, domains.domainId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(orgDomains.orgId, orgId),
|
eq(orgDomains.orgId, orgId),
|
||||||
eq(domains.configManaged, false)
|
eq(domains.configManaged, false)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
logger.info(`Found ${allOrgDomains.length} domains to delete`);
|
||||||
const domainIdsToDelete: string[] = [];
|
const domainIdsToDelete: string[] = [];
|
||||||
for (const orgDomain of allOrgDomains) {
|
for (const orgDomain of allOrgDomains) {
|
||||||
const domainId = orgDomain.domains.domainId;
|
const domainId = orgDomain.domains.domainId;
|
||||||
const orgCount = await trx
|
const [orgCount] = await trx
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: count() })
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
if (orgCount[0].count === 1) {
|
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
||||||
|
if (orgCount.count === 1) {
|
||||||
domainIdsToDelete.push(domainId);
|
domainIdsToDelete.push(domainId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logger.info(`Found ${domainIdsToDelete.length} domains to delete`);
|
||||||
if (domainIdsToDelete.length > 0) {
|
if (domainIdsToDelete.length > 0) {
|
||||||
await trx
|
await trx
|
||||||
.delete(domains)
|
.delete(domains)
|
||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
}
|
}
|
||||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
|
||||||
|
|
||||||
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
@@ -231,15 +234,13 @@ export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const olmId of result.olmsToTerminate) {
|
for (const olmId of result.olmsToTerminate) {
|
||||||
sendTerminateClient(
|
sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch(
|
||||||
0,
|
(error) => {
|
||||||
OlmErrorCodes.TERMINATED_REKEYED,
|
logger.error(
|
||||||
olmId
|
"Failed to send termination message to olm:",
|
||||||
).catch((error) => {
|
error
|
||||||
logger.error(
|
);
|
||||||
"Failed to send termination message to olm:",
|
}
|
||||||
error
|
);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,7 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -571,7 +572,7 @@ export async function updateClientSiteDestinations(
|
|||||||
destinations: [
|
destinations: [
|
||||||
{
|
{
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 0
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -579,7 +580,7 @@ export async function updateClientSiteDestinations(
|
|||||||
// add to the existing destinations
|
// add to the existing destinations
|
||||||
destinations.destinations.push({
|
destinations.destinations.push({
|
||||||
destinationIP: site.sites.subnet.split("/")[0],
|
destinationIP: site.sites.subnet.split("/")[0],
|
||||||
destinationPort: site.sites.listenPort || 0
|
destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +670,11 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
addSubnetProxyTargets(newt.newtId, targetsToAdd)
|
addSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
targetsToAdd,
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +710,11 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
|
||||||
);
|
);
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(newt.newtId, targetsToRemove)
|
removeSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
targetsToRemove,
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1080,6 +1089,7 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: if we are in jit mode here should we really be sending this?
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1146,7 +1156,7 @@ async function handleMessagesForClientResources(
|
|||||||
// Add subnet proxy targets for each site
|
// Add subnet proxy targets for each site
|
||||||
for (const [siteId, resources] of addedBySite.entries()) {
|
for (const [siteId, resources] of addedBySite.entries()) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select({ newtId: newts.newtId })
|
.select({ newtId: newts.newtId, version: newts.version })
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -1168,7 +1178,13 @@ async function handleMessagesForClientResources(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets));
|
proxyJobs.push(
|
||||||
|
addSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
targets,
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1217,7 +1233,7 @@ async function handleMessagesForClientResources(
|
|||||||
// Remove subnet proxy targets for each site
|
// Remove subnet proxy targets for each site
|
||||||
for (const [siteId, resources] of removedBySite.entries()) {
|
for (const [siteId, resources] of removedBySite.entries()) {
|
||||||
const [newt] = await trx
|
const [newt] = await trx
|
||||||
.select({ newtId: newts.newtId })
|
.select({ newtId: newts.newtId, version: newts.version })
|
||||||
.from(newts)
|
.from(newts)
|
||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -1240,7 +1256,11 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
if (targets.length > 0) {
|
if (targets.length > 0) {
|
||||||
proxyJobs.push(
|
proxyJobs.push(
|
||||||
removeSubnetProxyTargets(newt.newtId, targets)
|
removeSubnetProxyTargets(
|
||||||
|
newt.newtId,
|
||||||
|
targets,
|
||||||
|
newt.version
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export enum AudienceIds {
|
|
||||||
SignUps = "",
|
|
||||||
Subscribed = "",
|
|
||||||
Churned = "",
|
|
||||||
Newsletter = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
let resend;
|
|
||||||
export default resend;
|
|
||||||
|
|
||||||
export async function moveEmailToAudience(
|
|
||||||
email: string,
|
|
||||||
audienceId: AudienceIds
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
@@ -218,10 +218,11 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch if it's been more than 24 hours (for renewals)
|
|
||||||
const dayInMs = 24 * 60 * 60 * 1000;
|
const dayInMs = 24 * 60 * 60 * 1000;
|
||||||
const timeSinceLastFetch =
|
const timeSinceLastFetch =
|
||||||
Date.now() - this.lastCertificateFetch.getTime();
|
Date.now() - this.lastCertificateFetch.getTime();
|
||||||
|
|
||||||
|
// Fetch if it's been more than 24 hours (daily routine check)
|
||||||
if (timeSinceLastFetch > dayInMs) {
|
if (timeSinceLastFetch > dayInMs) {
|
||||||
logger.info("Fetching certificates due to 24-hour renewal check");
|
logger.info("Fetching certificates due to 24-hour renewal check");
|
||||||
return true;
|
return true;
|
||||||
@@ -265,7 +266,7 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any local certificates are missing or appear to be outdated
|
// Check if any local certificates are missing (needs immediate fetch)
|
||||||
for (const domain of domainsNeedingCerts) {
|
for (const domain of domainsNeedingCerts) {
|
||||||
const localState = this.lastLocalCertificateState.get(domain);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (!localState || !localState.exists) {
|
if (!localState || !localState.exists) {
|
||||||
@@ -274,17 +275,46 @@ export class TraefikConfigManager {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if certificate is expiring soon (within 30 days)
|
// For expiry checks, throttle to every 6 hours to avoid querying the
|
||||||
if (localState.expiresAt) {
|
// API/DB on every monitor loop. The certificate-service renews certs
|
||||||
const nowInSeconds = Math.floor(Date.now() / 1000);
|
// 45 days before expiry, so checking every 6 hours is plenty frequent
|
||||||
const secondsUntilExpiry = localState.expiresAt - nowInSeconds;
|
// to pick up renewed certs promptly.
|
||||||
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours
|
||||||
if (daysUntilExpiry < 30) {
|
if (timeSinceLastFetch > renewalCheckIntervalMs) {
|
||||||
logger.info(
|
// Check non-wildcard certs for expiry (within 45 days to match
|
||||||
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
// the server-side renewal window in certificate-service)
|
||||||
);
|
for (const domain of domainsNeedingCerts) {
|
||||||
return true;
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
|
if (localState?.expiresAt) {
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsUntilExpiry =
|
||||||
|
localState.expiresAt - nowInSeconds;
|
||||||
|
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 45) {
|
||||||
|
logger.info(
|
||||||
|
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check wildcard certificates for expiry. These are not
|
||||||
|
// included in domainsNeedingCerts since their subdomains are
|
||||||
|
// filtered out, so we must check them separately.
|
||||||
|
for (const [certDomain, state] of this.lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard && state.expiresAt) {
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsUntilExpiry = state.expiresAt - nowInSeconds;
|
||||||
|
const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 45) {
|
||||||
|
logger.info(
|
||||||
|
`Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,6 +391,26 @@ export class TraefikConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also include wildcard cert base domains that are
|
||||||
|
// expiring or expired so they get re-fetched even though
|
||||||
|
// their subdomains were filtered out above.
|
||||||
|
for (const [certDomain, state] of this
|
||||||
|
.lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard && state.expiresAt) {
|
||||||
|
const nowInSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const secondsUntilExpiry =
|
||||||
|
state.expiresAt - nowInSeconds;
|
||||||
|
const daysUntilExpiry =
|
||||||
|
secondsUntilExpiry / (60 * 60 * 24);
|
||||||
|
if (daysUntilExpiry < 45) {
|
||||||
|
domainsToFetch.add(certDomain);
|
||||||
|
logger.info(
|
||||||
|
`Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (domainsToFetch.size > 0) {
|
if (domainsToFetch.size > 0) {
|
||||||
// Get valid certificates for domains not covered by wildcards
|
// Get valid certificates for domains not covered by wildcards
|
||||||
validCertificates =
|
validCertificates =
|
||||||
@@ -507,11 +557,18 @@ export class TraefikConfigManager {
|
|||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.session_cookie_name,
|
.session_cookie_name,
|
||||||
|
|
||||||
// deprecated
|
|
||||||
accessTokenQueryParam:
|
accessTokenQueryParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_access_token_param,
|
.resource_access_token_param,
|
||||||
|
|
||||||
|
accessTokenIdHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.id,
|
||||||
|
|
||||||
|
accessTokenHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.token,
|
||||||
|
|
||||||
resourceSessionRequestParam:
|
resourceSessionRequestParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_session_request_param
|
.resource_session_request_param
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { resources, sites, Target, targets } from "@server/db";
|
import { resources, sites, Target, targets } from "@server/db";
|
||||||
import createPathRewriteMiddleware from "./middleware";
|
import createPathRewriteMiddleware from "./middleware";
|
||||||
import { sanitize, validatePathRewriteConfig } from "./utils";
|
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||||
|
|
||||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||||
const badgerMiddlewareName = "badger";
|
const badgerMiddlewareName = "badger";
|
||||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -145,7 +145,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(key)) {
|
if (!resourcesMap.has(mapKey)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -160,9 +160,10 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(key, {
|
resourcesMap.set(mapKey, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -190,7 +191,7 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.get(key).targets.push({
|
resourcesMap.get(mapKey).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -227,8 +228,9 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
|
const key = resource.key;
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
323
server/lib/traefik/pathEncoding.test.ts
Normal file
323
server/lib/traefik/pathEncoding.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { assertEquals } from "../../../test/assert";
|
||||||
|
|
||||||
|
// ── Pure function copies (inlined to avoid pulling in server dependencies) ──
|
||||||
|
|
||||||
|
function sanitize(input: string | null | undefined): string | undefined {
|
||||||
|
if (!input) return undefined;
|
||||||
|
if (input.length > 50) {
|
||||||
|
input = input.substring(0, 50);
|
||||||
|
}
|
||||||
|
return input
|
||||||
|
.replace(/[^a-zA-Z0-9-]/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePath(path: string | null | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||||
|
return ch.charCodeAt(0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact replica of the OLD key computation from upstream main.
|
||||||
|
* Uses sanitize() for paths — this is what had the collision bug.
|
||||||
|
*/
|
||||||
|
function oldKeyComputation(
|
||||||
|
resourceId: number,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null,
|
||||||
|
rewritePath: string | null,
|
||||||
|
rewritePathType: string | null
|
||||||
|
): string {
|
||||||
|
const targetPath = sanitize(path) || "";
|
||||||
|
const pmt = pathMatchType || "";
|
||||||
|
const rp = rewritePath || "";
|
||||||
|
const rpt = rewritePathType || "";
|
||||||
|
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
return sanitize(mapKey) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replica of the NEW key computation from our fix.
|
||||||
|
* Uses encodePath() for paths — collision-free.
|
||||||
|
*/
|
||||||
|
function newKeyComputation(
|
||||||
|
resourceId: number,
|
||||||
|
path: string | null,
|
||||||
|
pathMatchType: string | null,
|
||||||
|
rewritePath: string | null,
|
||||||
|
rewritePathType: string | null
|
||||||
|
): string {
|
||||||
|
const targetPath = encodePath(path);
|
||||||
|
const pmt = pathMatchType || "";
|
||||||
|
const rp = rewritePath || "";
|
||||||
|
const rpt = rewritePathType || "";
|
||||||
|
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||||
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
|
return sanitize(mapKey) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log("Running path encoding tests...\n");
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
|
||||||
|
// ── encodePath unit tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
// Test 1: null/undefined/empty
|
||||||
|
{
|
||||||
|
assertEquals(encodePath(null), "", "null should return empty");
|
||||||
|
assertEquals(
|
||||||
|
encodePath(undefined),
|
||||||
|
"",
|
||||||
|
"undefined should return empty"
|
||||||
|
);
|
||||||
|
assertEquals(encodePath(""), "", "empty string should return empty");
|
||||||
|
console.log(" PASS: encodePath handles null/undefined/empty");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: root path
|
||||||
|
{
|
||||||
|
assertEquals(encodePath("/"), "2f", "/ should encode to 2f");
|
||||||
|
console.log(" PASS: encodePath encodes root path");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: alphanumeric passthrough
|
||||||
|
{
|
||||||
|
assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only");
|
||||||
|
assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only");
|
||||||
|
assertEquals(encodePath("abc"), "abc", "plain alpha passes through");
|
||||||
|
console.log(" PASS: encodePath preserves alphanumeric chars");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: all special chars produce unique hex
|
||||||
|
{
|
||||||
|
const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"];
|
||||||
|
const results = paths.map((p) => encodePath(p));
|
||||||
|
const unique = new Set(results);
|
||||||
|
assertEquals(
|
||||||
|
unique.size,
|
||||||
|
paths.length,
|
||||||
|
"all special-char paths must produce unique encodings"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" PASS: encodePath produces unique output for different special chars"
|
||||||
|
);
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: output is always alphanumeric (safe for Traefik names)
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/complex/path/here"
|
||||||
|
];
|
||||||
|
for (const p of paths) {
|
||||||
|
const e = encodePath(p);
|
||||||
|
assertEquals(
|
||||||
|
/^[a-zA-Z0-9]+$/.test(e),
|
||||||
|
true,
|
||||||
|
`encodePath("${p}") = "${e}" must be alphanumeric`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(" PASS: encodePath output is always alphanumeric");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: deterministic
|
||||||
|
{
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/api"),
|
||||||
|
encodePath("/api"),
|
||||||
|
"same input same output"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
encodePath("/a/b/c"),
|
||||||
|
"same input same output"
|
||||||
|
);
|
||||||
|
console.log(" PASS: encodePath is deterministic");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 7: many distinct paths never collide
|
||||||
|
{
|
||||||
|
const paths = [
|
||||||
|
"/",
|
||||||
|
"/api",
|
||||||
|
"/api/v1",
|
||||||
|
"/api/v2",
|
||||||
|
"/a/b",
|
||||||
|
"/a-b",
|
||||||
|
"/a.b",
|
||||||
|
"/a_b",
|
||||||
|
"/health",
|
||||||
|
"/health/check",
|
||||||
|
"/admin",
|
||||||
|
"/admin/users",
|
||||||
|
"/api/v1/users",
|
||||||
|
"/api/v1/posts",
|
||||||
|
"/app",
|
||||||
|
"/app/dashboard"
|
||||||
|
];
|
||||||
|
const encoded = new Set(paths.map((p) => encodePath(p)));
|
||||||
|
assertEquals(
|
||||||
|
encoded.size,
|
||||||
|
paths.length,
|
||||||
|
`expected ${paths.length} unique encodings, got ${encoded.size}`
|
||||||
|
);
|
||||||
|
console.log(" PASS: 16 realistic paths all produce unique encodings");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collision fix: the actual bug we're fixing ───────────────────
|
||||||
|
|
||||||
|
// Test 8: /a/b and /a-b now have different keys (THE BUG FIX)
|
||||||
|
{
|
||||||
|
const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null);
|
||||||
|
const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
keyAB !== keyDash,
|
||||||
|
true,
|
||||||
|
"/a/b and /a-b MUST have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
||||||
|
{
|
||||||
|
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
||||||
|
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
oldKeyAB,
|
||||||
|
oldKeyDash,
|
||||||
|
"old code MUST have this collision (confirms the bug exists)"
|
||||||
|
);
|
||||||
|
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
||||||
|
{
|
||||||
|
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||||
|
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
oldKey1,
|
||||||
|
oldKey2,
|
||||||
|
"old code collision for /api/v1 vs /api-v1"
|
||||||
|
);
|
||||||
|
|
||||||
|
const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||||
|
const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
newKey1 !== newKey2,
|
||||||
|
true,
|
||||||
|
"new code must separate /api/v1 and /api-v1"
|
||||||
|
);
|
||||||
|
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
||||||
|
{
|
||||||
|
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
||||||
|
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
||||||
|
const c = newKeyComputation(1, "/app-v2", "prefix", null, null);
|
||||||
|
const keys = new Set([a, b, c]);
|
||||||
|
assertEquals(
|
||||||
|
keys.size,
|
||||||
|
3,
|
||||||
|
"three paths must produce three unique keys"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
||||||
|
);
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge cases ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Test 12: same path in different resources — always separate
|
||||||
|
{
|
||||||
|
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
key1 !== key2,
|
||||||
|
true,
|
||||||
|
"different resources with same path must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different resources");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13: same resource, different pathMatchType — separate keys
|
||||||
|
{
|
||||||
|
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
||||||
|
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
assertEquals(
|
||||||
|
exact !== prefix,
|
||||||
|
true,
|
||||||
|
"exact vs prefix must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different match types");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 14: same resource and path, different rewrite config — separate keys
|
||||||
|
{
|
||||||
|
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
||||||
|
const withRewrite = newKeyComputation(
|
||||||
|
1,
|
||||||
|
"/api",
|
||||||
|
"prefix",
|
||||||
|
"/backend",
|
||||||
|
"prefix"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
noRewrite !== withRewrite,
|
||||||
|
true,
|
||||||
|
"with vs without rewrite must have different keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — same path, different rewrite config");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 15: paths with special URL characters
|
||||||
|
{
|
||||||
|
const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"];
|
||||||
|
const keys = new Set(
|
||||||
|
paths.map((p) => newKeyComputation(1, p, "prefix", null, null))
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
keys.size,
|
||||||
|
paths.length,
|
||||||
|
"special URL chars must produce unique keys"
|
||||||
|
);
|
||||||
|
console.log(" PASS: edge case — special URL characters in paths");
|
||||||
|
passed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nAll ${passed} tests passed!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
|||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a URL path into a collision-free alphanumeric string suitable for use
|
||||||
|
* in Traefik map keys.
|
||||||
|
*
|
||||||
|
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
|
||||||
|
* character as its hex code. Different paths always produce different outputs.
|
||||||
|
*
|
||||||
|
* encodePath("/api") => "2fapi"
|
||||||
|
* encodePath("/a/b") => "2fa2fb"
|
||||||
|
* encodePath("/a-b") => "2fa2db" (different from /a/b)
|
||||||
|
* encodePath("/") => "2f"
|
||||||
|
* encodePath(null) => ""
|
||||||
|
*/
|
||||||
|
export function encodePath(path: string | null | undefined): string {
|
||||||
|
if (!path) return "";
|
||||||
|
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||||
|
return ch.charCodeAt(0).toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePathRewriteConfig(
|
export function validatePathRewriteConfig(
|
||||||
path: string | null,
|
path: string | null,
|
||||||
pathMatchType: string | null,
|
pathMatchType: string | null,
|
||||||
|
|||||||
@@ -13,8 +13,12 @@
|
|||||||
|
|
||||||
import { rateLimitService } from "#private/lib/rateLimit";
|
import { rateLimitService } from "#private/lib/rateLimit";
|
||||||
import { cleanup as wsCleanup } from "#private/routers/ws";
|
import { cleanup as wsCleanup } from "#private/routers/ws";
|
||||||
|
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
|
||||||
|
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
await rateLimitService.cleanup();
|
await rateLimitService.cleanup();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ export const privateConfigSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||||
resend_api_key: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
|
||||||
reo_client_id: z
|
reo_client_id: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Resend } from "resend";
|
|
||||||
import privateConfig from "#private/lib/config";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export enum AudienceIds {
|
|
||||||
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
|
|
||||||
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
|
|
||||||
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
|
|
||||||
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"
|
|
||||||
}
|
|
||||||
|
|
||||||
const resend = new Resend(
|
|
||||||
privateConfig.getRawPrivateConfig().server.resend_api_key || "missing"
|
|
||||||
);
|
|
||||||
|
|
||||||
export default resend;
|
|
||||||
|
|
||||||
export async function moveEmailToAudience(
|
|
||||||
email: string,
|
|
||||||
audienceId: AudienceIds
|
|
||||||
) {
|
|
||||||
if (process.env.ENVIRONMENT !== "prod") {
|
|
||||||
logger.debug(
|
|
||||||
`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { error, data } = await retryWithBackoff(async () => {
|
|
||||||
const { data, error } = await resend.contacts.create({
|
|
||||||
email,
|
|
||||||
unsubscribed: false,
|
|
||||||
audienceId
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { error, data };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(
|
|
||||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
logger.debug(
|
|
||||||
`Added email ${email} to audience ${audienceId} with contact ID ${data.id}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherAudiences = Object.values(AudienceIds).filter(
|
|
||||||
(id) => id !== audienceId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const otherAudienceId of otherAudiences) {
|
|
||||||
const { error, data } = await retryWithBackoff(async () => {
|
|
||||||
const { data, error } = await resend.contacts.remove({
|
|
||||||
email,
|
|
||||||
audienceId: otherAudienceId
|
|
||||||
});
|
|
||||||
if (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { error, data };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error(
|
|
||||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
logger.info(
|
|
||||||
`Removed email ${email} from audience ${otherAudienceId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type RetryOptions = {
|
|
||||||
retries?: number;
|
|
||||||
initialDelayMs?: number;
|
|
||||||
factor?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function retryWithBackoff<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
options: RetryOptions = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const { retries = 5, initialDelayMs = 500, factor = 2 } = options;
|
|
||||||
|
|
||||||
let attempt = 0;
|
|
||||||
let delay = initialDelayMs;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (err) {
|
|
||||||
attempt++;
|
|
||||||
|
|
||||||
if (attempt > retries) throw err;
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
delay *= factor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,11 @@ import {
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||||
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
import {
|
||||||
|
sanitize,
|
||||||
|
encodePath,
|
||||||
|
validatePathRewriteConfig
|
||||||
|
} from "@server/lib/traefik/utils";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -170,7 +174,7 @@ export async function getTraefikConfig(
|
|||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
resourcesWithTargetsAndSites.forEach((row) => {
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
const pathMatchType = row.pathMatchType || "";
|
const pathMatchType = row.pathMatchType || "";
|
||||||
const rewritePath = row.rewritePath || "";
|
const rewritePath = row.rewritePath || "";
|
||||||
const rewritePathType = row.rewritePathType || "";
|
const rewritePathType = row.rewritePathType || "";
|
||||||
@@ -192,7 +196,7 @@ export async function getTraefikConfig(
|
|||||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||||
const key = sanitize(mapKey);
|
const key = sanitize(mapKey);
|
||||||
|
|
||||||
if (!resourcesMap.has(key)) {
|
if (!resourcesMap.has(mapKey)) {
|
||||||
const validation = validatePathRewriteConfig(
|
const validation = validatePathRewriteConfig(
|
||||||
row.path,
|
row.path,
|
||||||
row.pathMatchType,
|
row.pathMatchType,
|
||||||
@@ -207,9 +211,10 @@ export async function getTraefikConfig(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(key, {
|
resourcesMap.set(mapKey, {
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
name: resourceName,
|
name: resourceName,
|
||||||
|
key: key,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
ssl: row.ssl,
|
ssl: row.ssl,
|
||||||
http: row.http,
|
http: row.http,
|
||||||
@@ -243,7 +248,7 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add target with its associated site data
|
// Add target with its associated site data
|
||||||
resourcesMap.get(key).targets.push({
|
resourcesMap.get(mapKey).targets.push({
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
targetId: row.targetId,
|
targetId: row.targetId,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
@@ -296,8 +301,9 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// get the key and the resource
|
// get the key and the resource
|
||||||
for (const [key, resource] of resourcesMap.entries()) {
|
for (const [, resource] of resourcesMap.entries()) {
|
||||||
const targets = resource.targets as TargetWithSite[];
|
const targets = resource.targets as TargetWithSite[];
|
||||||
|
const key = resource.key;
|
||||||
|
|
||||||
const routerName = `${key}-${resource.name}-router`;
|
const routerName = `${key}-${resource.name}-router`;
|
||||||
const serviceName = `${key}-${resource.name}-service`;
|
const serviceName = `${key}-${resource.name}-service`;
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||||
@@ -172,7 +171,7 @@ export async function handleSubscriptionCreated(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
|
||||||
import { getSubType } from "./getSubType";
|
import { getSubType } from "./getSubType";
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
@@ -109,7 +108,7 @@ export async function handleSubscriptionDeleted(
|
|||||||
const email = orgUserRes.user.email;
|
const email = orgUserRes.user.email;
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
moveEmailToAudience(email, AudienceIds.Churned);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
|
|||||||
@@ -515,6 +515,6 @@ authenticated.post(
|
|||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyLimits,
|
verifyLimits,
|
||||||
verifyUserHasAction(ActionsEnum.signSshKey),
|
verifyUserHasAction(ActionsEnum.signSshKey),
|
||||||
logActionAudit(ActionsEnum.signSshKey),
|
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
|
||||||
ssh.signSshKey
|
ssh.signSshKey
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
actionAuditLog,
|
||||||
db,
|
db,
|
||||||
|
logsDb,
|
||||||
newts,
|
newts,
|
||||||
roles,
|
roles,
|
||||||
roundTripMessageTracker,
|
roundTripMessageTracker,
|
||||||
@@ -29,12 +31,12 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { eq, or, and } from "drizzle-orm";
|
import { eq, or, and } from "drizzle-orm";
|
||||||
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -64,6 +66,7 @@ export type SignSshKeyResponse = {
|
|||||||
sshUsername: string;
|
sshUsername: string;
|
||||||
sshHost: string;
|
sshHost: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
|
siteId: number;
|
||||||
keyId: string;
|
keyId: string;
|
||||||
validPrincipals: string[];
|
validPrincipals: string[];
|
||||||
validAfter: string;
|
validAfter: string;
|
||||||
@@ -446,6 +449,20 @@ export async function signSshKey(
|
|||||||
sshHost = resource.destination;
|
sshHost = resource.destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await logsDb.insert(actionAuditLog).values({
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
orgId: orgId,
|
||||||
|
actorType: "user",
|
||||||
|
actor: req.user?.username ?? "",
|
||||||
|
actorId: req.user?.userId ?? "",
|
||||||
|
action: ActionsEnum.signSshKey,
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
resource: resource.name,
|
||||||
|
siteId: resource.siteId,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
return response<SignSshKeyResponse>(res, {
|
return response<SignSshKeyResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
certificate: cert.certificate,
|
certificate: cert.certificate,
|
||||||
@@ -453,6 +470,7 @@ export async function signSshKey(
|
|||||||
sshUsername: usernameToUse,
|
sshUsername: usernameToUse,
|
||||||
sshHost: sshHost,
|
sshHost: sshHost,
|
||||||
resourceId: resource.siteResourceId,
|
resourceId: resource.siteResourceId,
|
||||||
|
siteId: resource.siteId,
|
||||||
keyId: cert.keyId,
|
keyId: cert.keyId,
|
||||||
validPrincipals: cert.validPrincipals,
|
validPrincipals: cert.validPrincipals,
|
||||||
validAfter: cert.validAfter.toISOString(),
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import {
|
|||||||
startRemoteExitNodeOfflineChecker
|
startRemoteExitNodeOfflineChecker
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage
|
"remoteExitNode/ping": handleRemoteExitNodePingMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
if (build != "saas") {
|
||||||
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
|
import zlib from "zlib";
|
||||||
import { Server as HttpServer } from "http";
|
import { Server as HttpServer } from "http";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
@@ -24,7 +25,8 @@ import {
|
|||||||
OlmSession,
|
OlmSession,
|
||||||
RemoteExitNode,
|
RemoteExitNode,
|
||||||
RemoteExitNodeSession,
|
RemoteExitNodeSession,
|
||||||
remoteExitNodes
|
remoteExitNodes,
|
||||||
|
sites
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
@@ -57,11 +59,13 @@ const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection
|
|||||||
const processMessage = async (
|
const processMessage = async (
|
||||||
ws: AuthenticatedWebSocket,
|
ws: AuthenticatedWebSocket,
|
||||||
data: Buffer,
|
data: Buffer,
|
||||||
|
isBinary: boolean,
|
||||||
clientId: string,
|
clientId: string,
|
||||||
clientType: ClientType
|
clientType: ClientType
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const message: WSMessage = JSON.parse(data.toString());
|
const messageBuffer = isBinary ? zlib.gunzipSync(data) : data;
|
||||||
|
const message: WSMessage = JSON.parse(messageBuffer.toString());
|
||||||
|
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}`
|
// `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}`
|
||||||
@@ -76,7 +80,7 @@ const processMessage = async (
|
|||||||
clientId,
|
clientId,
|
||||||
message.type, // Pass message type for granular limiting
|
message.type, // Pass message type for granular limiting
|
||||||
100, // max requests per window
|
100, // max requests per window
|
||||||
20, // max requests per message type per window
|
100, // max requests per message type per window
|
||||||
60 * 1000 // window in milliseconds
|
60 * 1000 // window in milliseconds
|
||||||
);
|
);
|
||||||
if (rateLimitResult.isLimited) {
|
if (rateLimitResult.isLimited) {
|
||||||
@@ -163,8 +167,16 @@ const processPendingMessages = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const jobs = [];
|
const jobs = [];
|
||||||
for (const messageData of ws.pendingMessages) {
|
for (const pending of ws.pendingMessages) {
|
||||||
jobs.push(processMessage(ws, messageData, clientId, clientType));
|
jobs.push(
|
||||||
|
processMessage(
|
||||||
|
ws,
|
||||||
|
pending.data,
|
||||||
|
pending.isBinary,
|
||||||
|
clientId,
|
||||||
|
clientType
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(jobs);
|
await Promise.all(jobs);
|
||||||
@@ -185,6 +197,12 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
|||||||
// Config version tracking map (local to this node, resets on server restart)
|
// Config version tracking map (local to this node, resets on server restart)
|
||||||
const clientConfigVersions: Map<string, number> = new Map();
|
const clientConfigVersions: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
// Tracks the last Unix timestamp (seconds) at which a ping was flushed to the
|
||||||
|
// DB for a given siteId. Resets on server restart which is fine – the first
|
||||||
|
// ping after startup will always write, re-establishing the online state.
|
||||||
|
const lastPingDbWrite: Map<number, number> = new Map();
|
||||||
|
const PING_DB_WRITE_INTERVAL = 45; // seconds
|
||||||
|
|
||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
@@ -325,7 +343,9 @@ const addClient = async (
|
|||||||
// Check Redis first if enabled
|
// Check Redis first if enabled
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
|
const redisVersion = await redisManager.get(
|
||||||
|
getConfigVersionKey(clientId)
|
||||||
|
);
|
||||||
if (redisVersion !== null) {
|
if (redisVersion !== null) {
|
||||||
configVersion = parseInt(redisVersion, 10);
|
configVersion = parseInt(redisVersion, 10);
|
||||||
// Sync to local cache
|
// Sync to local cache
|
||||||
@@ -337,7 +357,10 @@ const addClient = async (
|
|||||||
} else {
|
} else {
|
||||||
// Use local cache version and sync to Redis
|
// Use local cache version and sync to Redis
|
||||||
configVersion = clientConfigVersions.get(clientId) || 0;
|
configVersion = clientConfigVersions.get(clientId) || 0;
|
||||||
await redisManager.set(getConfigVersionKey(clientId), configVersion.toString());
|
await redisManager.set(
|
||||||
|
getConfigVersionKey(clientId),
|
||||||
|
configVersion.toString()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to get/set config version in Redis:", error);
|
logger.error("Failed to get/set config version in Redis:", error);
|
||||||
@@ -432,7 +455,9 @@ const removeClient = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get the current config version for a client
|
// Helper to get the current config version for a client
|
||||||
const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => {
|
const getClientConfigVersion = async (
|
||||||
|
clientId: string
|
||||||
|
): Promise<number | undefined> => {
|
||||||
// Try Redis first if available
|
// Try Redis first if available
|
||||||
if (redisManager.isRedisEnabled()) {
|
if (redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
@@ -502,11 +527,26 @@ const sendToClientLocal = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
const messageString = JSON.stringify(messageWithVersion);
|
||||||
clients.forEach((client) => {
|
if (options.compress) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
logger.debug(
|
||||||
client.send(messageString);
|
`Message size before compression: ${messageString.length} bytes`
|
||||||
}
|
);
|
||||||
});
|
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
||||||
|
logger.debug(
|
||||||
|
`Message size after compression: ${compressed.length} bytes`
|
||||||
|
);
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(messageString);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -532,11 +572,22 @@ const broadcastToAllExceptLocal = async (
|
|||||||
configVersion
|
configVersion
|
||||||
};
|
};
|
||||||
|
|
||||||
clients.forEach((client) => {
|
if (options.compress) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
const compressed = zlib.gzipSync(
|
||||||
client.send(JSON.stringify(messageWithVersion));
|
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
|
||||||
}
|
);
|
||||||
});
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(messageWithVersion));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -762,7 +813,7 @@ const setupConnection = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set up message handler FIRST to prevent race condition
|
// Set up message handler FIRST to prevent race condition
|
||||||
ws.on("message", async (data) => {
|
ws.on("message", async (data, isBinary) => {
|
||||||
if (!ws.isFullyConnected) {
|
if (!ws.isFullyConnected) {
|
||||||
// Queue message for later processing with limits
|
// Queue message for later processing with limits
|
||||||
ws.pendingMessages = ws.pendingMessages || [];
|
ws.pendingMessages = ws.pendingMessages || [];
|
||||||
@@ -777,11 +828,17 @@ const setupConnection = async (
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)`
|
`Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)`
|
||||||
);
|
);
|
||||||
ws.pendingMessages.push(data as Buffer);
|
ws.pendingMessages.push({ data: data as Buffer, isBinary });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await processMessage(ws, data as Buffer, clientId, clientType);
|
await processMessage(
|
||||||
|
ws,
|
||||||
|
data as Buffer,
|
||||||
|
isBinary,
|
||||||
|
clientId,
|
||||||
|
clientType
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up other event handlers before async operations
|
// Set up other event handlers before async operations
|
||||||
@@ -796,6 +853,35 @@ const setupConnection = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket protocol-level pings from older newt clients that do
|
||||||
|
// not send application-level "newt/ping" messages. Update the site's
|
||||||
|
// online state and lastPing timestamp so the offline checker treats them
|
||||||
|
// the same as modern newt clients.
|
||||||
|
if (clientType === "newt") {
|
||||||
|
const newtClient = client as Newt;
|
||||||
|
ws.on("ping", async () => {
|
||||||
|
if (!newtClient.siteId) return;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0;
|
||||||
|
if (now - lastWrite < PING_DB_WRITE_INTERVAL) return;
|
||||||
|
lastPingDbWrite.set(newtClient.siteId, now);
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: now
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newtClient.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Error updating newt site online state on WS ping",
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ws.on("error", (error: Error) => {
|
ws.on("error", (error: Error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import { db, users } from "@server/db";
|
import { bannedEmails, bannedIps, db, users } from "@server/db";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { email, z } from "zod";
|
import { email, z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
@@ -22,7 +22,6 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
|||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
|
||||||
|
|
||||||
export const signupBodySchema = z.object({
|
export const signupBodySchema = z.object({
|
||||||
email: z.email().toLowerCase(),
|
email: z.email().toLowerCase(),
|
||||||
@@ -66,6 +65,30 @@ export async function signup(
|
|||||||
skipVerificationEmail
|
skipVerificationEmail
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
const [bannedEmail] = await db
|
||||||
|
.select()
|
||||||
|
.from(bannedEmails)
|
||||||
|
.where(eq(bannedEmails.email, email))
|
||||||
|
.limit(1);
|
||||||
|
if (bannedEmail) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.ip) {
|
||||||
|
const [bannedIp] = await db
|
||||||
|
.select()
|
||||||
|
.from(bannedIps)
|
||||||
|
.where(eq(bannedIps.ip, req.ip))
|
||||||
|
.limit(1);
|
||||||
|
if (bannedIp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
|
|
||||||
@@ -189,6 +212,7 @@ export async function signup(
|
|||||||
dateCreated: moment().toISOString(),
|
dateCreated: moment().toISOString(),
|
||||||
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
termsAcceptedTimestamp: termsAcceptedTimestamp || null,
|
||||||
termsVersion: "1",
|
termsVersion: "1",
|
||||||
|
marketingEmailConsent: marketingEmailConsent ?? false,
|
||||||
lastPasswordChange: new Date().getTime()
|
lastPasswordChange: new Date().getTime()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -212,7 +236,7 @@ export async function signup(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`User ${email} opted in to marketing emails during signup.`
|
`User ${email} opted in to marketing emails during signup.`
|
||||||
);
|
);
|
||||||
moveEmailToAudience(email, AudienceIds.SignUps);
|
// TODO: update user in Sendy
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.getRawConfig().flags?.require_email_verification) {
|
if (config.getRawConfig().flags?.require_email_verification) {
|
||||||
|
|||||||
@@ -1,51 +1,38 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms, Transaction } from "@server/db";
|
import { db, olms, Transaction } from "@server/db";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import { Alias, SubnetProxyTarget } from "@server/lib/ip";
|
import { Alias, SubnetProxyTarget } from "@server/lib/ip";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
export async function addTargets(
|
||||||
const BATCH_DELAY_MS = 50;
|
newtId: string,
|
||||||
|
targets: SubnetProxyTarget[],
|
||||||
function sleep(ms: number): Promise<void> {
|
version?: string | null
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
) {
|
||||||
}
|
await sendToClient(
|
||||||
|
newtId,
|
||||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
{
|
||||||
const chunks: T[][] = [];
|
|
||||||
for (let i = 0; i < array.length; i += size) {
|
|
||||||
chunks.push(array.slice(i, i + size));
|
|
||||||
}
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
|
||||||
const batches = chunkArray(targets, BATCH_SIZE);
|
|
||||||
for (let i = 0; i < batches.length; i++) {
|
|
||||||
if (i > 0) {
|
|
||||||
await sleep(BATCH_DELAY_MS);
|
|
||||||
}
|
|
||||||
await sendToClient(newtId, {
|
|
||||||
type: `newt/wg/targets/add`,
|
type: `newt/wg/targets/add`,
|
||||||
data: batches[i]
|
data: targets
|
||||||
}, { incrementConfigVersion: true });
|
},
|
||||||
}
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: SubnetProxyTarget[]
|
targets: SubnetProxyTarget[],
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
const batches = chunkArray(targets, BATCH_SIZE);
|
await sendToClient(
|
||||||
for (let i = 0; i < batches.length; i++) {
|
newtId,
|
||||||
if (i > 0) {
|
{
|
||||||
await sleep(BATCH_DELAY_MS);
|
|
||||||
}
|
|
||||||
await sendToClient(newtId, {
|
|
||||||
type: `newt/wg/targets/remove`,
|
type: `newt/wg/targets/remove`,
|
||||||
data: batches[i]
|
data: targets
|
||||||
},{ incrementConfigVersion: true });
|
},
|
||||||
}
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTargets(
|
export async function updateTargets(
|
||||||
@@ -53,26 +40,22 @@ export async function updateTargets(
|
|||||||
targets: {
|
targets: {
|
||||||
oldTargets: SubnetProxyTarget[];
|
oldTargets: SubnetProxyTarget[];
|
||||||
newTargets: SubnetProxyTarget[];
|
newTargets: SubnetProxyTarget[];
|
||||||
}
|
},
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
|
await sendToClient(
|
||||||
const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
|
newtId,
|
||||||
const maxBatches = Math.max(oldBatches.length, newBatches.length);
|
{
|
||||||
|
|
||||||
for (let i = 0; i < maxBatches; i++) {
|
|
||||||
if (i > 0) {
|
|
||||||
await sleep(BATCH_DELAY_MS);
|
|
||||||
}
|
|
||||||
await sendToClient(newtId, {
|
|
||||||
type: `newt/wg/targets/update`,
|
type: `newt/wg/targets/update`,
|
||||||
data: {
|
data: {
|
||||||
oldTargets: oldBatches[i] || [],
|
oldTargets: targets.oldTargets,
|
||||||
newTargets: newBatches[i] || []
|
newTargets: targets.newTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
},
|
||||||
logger.warn(`Error sending message:`, error);
|
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
|
||||||
});
|
).catch((error) => {
|
||||||
}
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPeerData(
|
export async function addPeerData(
|
||||||
@@ -80,7 +63,8 @@ export async function addPeerData(
|
|||||||
siteId: number,
|
siteId: number,
|
||||||
remoteSubnets: string[],
|
remoteSubnets: string[],
|
||||||
aliases: Alias[],
|
aliases: Alias[],
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -92,16 +76,21 @@ export async function addPeerData(
|
|||||||
return; // ignore this because an olm might not be associated with the client anymore
|
return; // ignore this because an olm might not be associated with the client anymore
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
type: `olm/wg/peer/data/add`,
|
olmId,
|
||||||
data: {
|
{
|
||||||
siteId: siteId,
|
type: `olm/wg/peer/data/add`,
|
||||||
remoteSubnets: remoteSubnets,
|
data: {
|
||||||
aliases: aliases
|
siteId: siteId,
|
||||||
}
|
remoteSubnets: remoteSubnets,
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
aliases: aliases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -111,7 +100,8 @@ export async function removePeerData(
|
|||||||
siteId: number,
|
siteId: number,
|
||||||
remoteSubnets: string[],
|
remoteSubnets: string[],
|
||||||
aliases: Alias[],
|
aliases: Alias[],
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -123,16 +113,21 @@ export async function removePeerData(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
type: `olm/wg/peer/data/remove`,
|
olmId,
|
||||||
data: {
|
{
|
||||||
siteId: siteId,
|
type: `olm/wg/peer/data/remove`,
|
||||||
remoteSubnets: remoteSubnets,
|
data: {
|
||||||
aliases: aliases
|
siteId: siteId,
|
||||||
}
|
remoteSubnets: remoteSubnets,
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
aliases: aliases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,7 +147,8 @@ export async function updatePeerData(
|
|||||||
newAliases: Alias[];
|
newAliases: Alias[];
|
||||||
}
|
}
|
||||||
| undefined,
|
| undefined,
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -164,16 +160,21 @@ export async function updatePeerData(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(olmId, {
|
await sendToClient(
|
||||||
type: `olm/wg/peer/data/update`,
|
olmId,
|
||||||
data: {
|
{
|
||||||
siteId: siteId,
|
type: `olm/wg/peer/data/update`,
|
||||||
...remoteSubnets,
|
data: {
|
||||||
...aliases
|
siteId: siteId,
|
||||||
}
|
...remoteSubnets,
|
||||||
}, { incrementConfigVersion: true }).catch((error) => {
|
...aliases
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
|
|||||||
tries: domains.tries,
|
tries: domains.tries,
|
||||||
configManaged: domains.configManaged,
|
configManaged: domains.configManaged,
|
||||||
certResolver: domains.certResolver,
|
certResolver: domains.certResolver,
|
||||||
preferWildcardCert: domains.preferWildcardCert
|
preferWildcardCert: domains.preferWildcardCert,
|
||||||
|
errorMessage: domains.errorMessage
|
||||||
})
|
})
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.orgId, orgId))
|
.where(eq(orgDomains.orgId, orgId))
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
// Add site as a destination for this client
|
// Add site as a destination for this client
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if this destination is already in the array to avoid duplicates
|
// Check if this destination is already in the array to avoid duplicates
|
||||||
@@ -165,7 +165,7 @@ export async function generateRelayMappings(exitNode: ExitNode) {
|
|||||||
|
|
||||||
const destination: PeerDestination = {
|
const destination: PeerDestination = {
|
||||||
destinationIP: peer.subnet.split("/")[0],
|
destinationIP: peer.subnet.split("/")[0],
|
||||||
destinationPort: peer.listenPort
|
destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { eq, and, lt, inArray, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -11,19 +11,31 @@ import { FeatureId } from "@server/lib/billing/features";
|
|||||||
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
|
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// Track sites that are already offline to avoid unnecessary queries
|
|
||||||
const offlineSites = new Set<string>();
|
|
||||||
|
|
||||||
// Retry configuration for deadlock handling
|
|
||||||
const MAX_RETRIES = 3;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
interface PeerBandwidth {
|
interface PeerBandwidth {
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
bytesIn: number;
|
bytesIn: number;
|
||||||
bytesOut: number;
|
bytesOut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccumulatorEntry {
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
/** Present when the update came through a remote exit node. */
|
||||||
|
exitNodeId?: number;
|
||||||
|
/** Whether to record egress usage for billing purposes. */
|
||||||
|
calcUsage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry configuration for deadlock handling
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
// How often to flush accumulated bandwidth data to the database
|
||||||
|
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds
|
||||||
|
|
||||||
|
// In-memory accumulator: publicKey -> AccumulatorEntry
|
||||||
|
let accumulator = new Map<string, AccumulatorEntry>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a deadlock error
|
* Check if an error is a deadlock error
|
||||||
*/
|
*/
|
||||||
@@ -63,6 +75,220 @@ async function withDeadlockRetry<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all accumulated site bandwidth data to the database.
|
||||||
|
*
|
||||||
|
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||||
|
* received during the flush are captured in the new accumulator rather than
|
||||||
|
* being lost or causing contention. Entries that fail to write are re-queued
|
||||||
|
* back into the accumulator so they will be retried on the next flush.
|
||||||
|
*
|
||||||
|
* This function is exported so that the application's graceful-shutdown
|
||||||
|
* cleanup handler can call it before the process exits.
|
||||||
|
*/
|
||||||
|
export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||||
|
if (accumulator.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap out the accumulator so new data keeps flowing in
|
||||||
|
// while we write the snapshot to the database.
|
||||||
|
const snapshot = accumulator;
|
||||||
|
accumulator = new Map<string, AccumulatorEntry>();
|
||||||
|
|
||||||
|
const currentTime = new Date().toISOString();
|
||||||
|
|
||||||
|
// Sort by publicKey for consistent lock ordering across concurrent
|
||||||
|
// writers — deadlock-prevention strategy.
|
||||||
|
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aggregate billing usage by org, collected during the DB update loop.
|
||||||
|
const orgUsageMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) {
|
||||||
|
try {
|
||||||
|
const updatedSite = await withDeadlockRetry(async () => {
|
||||||
|
const [result] = await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
|
||||||
|
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
|
||||||
|
lastBandwidthUpdate: currentTime,
|
||||||
|
})
|
||||||
|
.where(eq(sites.pubKey, publicKey))
|
||||||
|
.returning({
|
||||||
|
orgId: sites.orgId,
|
||||||
|
siteId: sites.siteId
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}, `flush bandwidth for site ${publicKey}`);
|
||||||
|
|
||||||
|
if (updatedSite) {
|
||||||
|
if (exitNodeId) {
|
||||||
|
const notAllowed = await checkExitNodeOrg(
|
||||||
|
exitNodeId,
|
||||||
|
updatedSite.orgId
|
||||||
|
);
|
||||||
|
if (notAllowed) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
||||||
|
);
|
||||||
|
// Skip usage tracking for this site but continue
|
||||||
|
// processing the rest.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (calcUsage) {
|
||||||
|
const totalBandwidth = bytesIn + bytesOut;
|
||||||
|
const current = orgUsageMap.get(updatedSite.orgId) ?? 0;
|
||||||
|
orgUsageMap.set(updatedSite.orgId, current + totalBandwidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to flush bandwidth for site ${publicKey}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-queue the failed entry so it is retried on the next flush
|
||||||
|
// rather than silently dropped.
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, {
|
||||||
|
bytesIn,
|
||||||
|
bytesOut,
|
||||||
|
exitNodeId,
|
||||||
|
calcUsage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process billing usage updates outside the site-update loop to keep
|
||||||
|
// lock scope small and concerns separated.
|
||||||
|
if (orgUsageMap.size > 0) {
|
||||||
|
// Sort org IDs for consistent lock ordering.
|
||||||
|
const sortedOrgIds = [...orgUsageMap.keys()].sort();
|
||||||
|
|
||||||
|
for (const orgId of sortedOrgIds) {
|
||||||
|
try {
|
||||||
|
const totalBandwidth = orgUsageMap.get(orgId)!;
|
||||||
|
const bandwidthUsage = await usageService.add(
|
||||||
|
orgId,
|
||||||
|
FeatureId.EGRESS_DATA_MB,
|
||||||
|
totalBandwidth
|
||||||
|
);
|
||||||
|
if (bandwidthUsage) {
|
||||||
|
// Fire-and-forget — don't block the flush on limit checking.
|
||||||
|
usageService
|
||||||
|
.checkLimitSet(
|
||||||
|
orgId,
|
||||||
|
FeatureId.EGRESS_DATA_MB,
|
||||||
|
bandwidthUsage
|
||||||
|
)
|
||||||
|
.catch((error: any) => {
|
||||||
|
logger.error(
|
||||||
|
`Error checking bandwidth limits for org ${orgId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error processing usage for org ${orgId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Continue with other orgs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Periodic flush timer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const flushTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await flushSiteBandwidthToDb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Unexpected error during periodic site bandwidth flush:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Allow the process to exit normally even while the timer is pending.
|
||||||
|
// The graceful-shutdown path (see server/cleanup.ts) will call
|
||||||
|
// flushSiteBandwidthToDb() explicitly before process.exit(), so no data
|
||||||
|
// is lost.
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accumulate bandwidth data reported by a gerbil or remote exit node.
|
||||||
|
*
|
||||||
|
* Only peers that actually transferred data (bytesIn > 0) are added to the
|
||||||
|
* accumulator; peers with no activity are silently ignored, which means the
|
||||||
|
* flush will only write rows that have genuinely changed.
|
||||||
|
*
|
||||||
|
* The function is intentionally synchronous in its fast path so that the
|
||||||
|
* HTTP handler can respond immediately without waiting for any I/O.
|
||||||
|
*/
|
||||||
|
export async function updateSiteBandwidth(
|
||||||
|
bandwidthData: PeerBandwidth[],
|
||||||
|
calcUsageAndLimits: boolean,
|
||||||
|
exitNodeId?: number
|
||||||
|
): Promise<void> {
|
||||||
|
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||||
|
// Skip peers that haven't transferred any data — writing zeros to the
|
||||||
|
// database would be a no-op anyway.
|
||||||
|
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
// Retain the most-recent exitNodeId for this peer.
|
||||||
|
if (exitNodeId !== undefined) {
|
||||||
|
existing.exitNodeId = exitNodeId;
|
||||||
|
}
|
||||||
|
// Once calcUsage has been requested for a peer, keep it set for
|
||||||
|
// the lifetime of this flush window.
|
||||||
|
if (calcUsageAndLimits) {
|
||||||
|
existing.calcUsage = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, {
|
||||||
|
bytesIn,
|
||||||
|
bytesOut,
|
||||||
|
exitNodeId,
|
||||||
|
calcUsage: calcUsageAndLimits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const receiveBandwidth = async (
|
export const receiveBandwidth = async (
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -75,7 +301,9 @@ export const receiveBandwidth = async (
|
|||||||
throw new Error("Invalid bandwidth data");
|
throw new Error("Invalid bandwidth data");
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSiteBandwidth(bandwidthData, build == "saas"); // we are checking the usage on saas only
|
// Accumulate in memory; the periodic timer (and the shutdown hook)
|
||||||
|
// will write to the database.
|
||||||
|
await updateSiteBandwidth(bandwidthData, build == "saas");
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
@@ -94,201 +322,3 @@ export const receiveBandwidth = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function updateSiteBandwidth(
|
|
||||||
bandwidthData: PeerBandwidth[],
|
|
||||||
calcUsageAndLimits: boolean,
|
|
||||||
exitNodeId?: number
|
|
||||||
) {
|
|
||||||
const currentTime = new Date();
|
|
||||||
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
|
|
||||||
|
|
||||||
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
|
|
||||||
// This is critical for preventing deadlocks when multiple instances update the same sites
|
|
||||||
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
|
||||||
a.publicKey.localeCompare(b.publicKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
// First, handle sites that are actively reporting bandwidth
|
|
||||||
const activePeers = sortedBandwidthData.filter((peer) => peer.bytesIn > 0);
|
|
||||||
|
|
||||||
// Aggregate usage data by organization (collected outside transaction)
|
|
||||||
const orgUsageMap = new Map<string, number>();
|
|
||||||
|
|
||||||
if (activePeers.length > 0) {
|
|
||||||
// Remove any active peers from offline tracking since they're sending data
|
|
||||||
activePeers.forEach((peer) => offlineSites.delete(peer.publicKey));
|
|
||||||
|
|
||||||
// Update each active site individually with retry logic
|
|
||||||
// This reduces transaction scope and allows retries per-site
|
|
||||||
for (const peer of activePeers) {
|
|
||||||
try {
|
|
||||||
const updatedSite = await withDeadlockRetry(async () => {
|
|
||||||
const [result] = await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
|
|
||||||
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
|
|
||||||
lastBandwidthUpdate: currentTime.toISOString(),
|
|
||||||
online: true
|
|
||||||
})
|
|
||||||
.where(eq(sites.pubKey, peer.publicKey))
|
|
||||||
.returning({
|
|
||||||
online: sites.online,
|
|
||||||
orgId: sites.orgId,
|
|
||||||
siteId: sites.siteId,
|
|
||||||
lastBandwidthUpdate: sites.lastBandwidthUpdate
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}, `update active site ${peer.publicKey}`);
|
|
||||||
|
|
||||||
if (updatedSite) {
|
|
||||||
if (exitNodeId) {
|
|
||||||
const notAllowed = await checkExitNodeOrg(
|
|
||||||
exitNodeId,
|
|
||||||
updatedSite.orgId
|
|
||||||
);
|
|
||||||
if (notAllowed) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
|
||||||
);
|
|
||||||
// Skip this site but continue processing others
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate bandwidth usage for the org
|
|
||||||
const totalBandwidth = peer.bytesIn + peer.bytesOut;
|
|
||||||
const currentOrgUsage =
|
|
||||||
orgUsageMap.get(updatedSite.orgId) || 0;
|
|
||||||
orgUsageMap.set(
|
|
||||||
updatedSite.orgId,
|
|
||||||
currentOrgUsage + totalBandwidth
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update bandwidth for site ${peer.publicKey}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other sites
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process usage updates outside of site update transactions
|
|
||||||
// This separates the concerns and reduces lock contention
|
|
||||||
if (calcUsageAndLimits && orgUsageMap.size > 0) {
|
|
||||||
// Sort org IDs to ensure consistent lock ordering
|
|
||||||
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
|
|
||||||
|
|
||||||
for (const orgId of allOrgIds) {
|
|
||||||
try {
|
|
||||||
// Process bandwidth usage for this org
|
|
||||||
const totalBandwidth = orgUsageMap.get(orgId);
|
|
||||||
if (totalBandwidth) {
|
|
||||||
const bandwidthUsage = await usageService.add(
|
|
||||||
orgId,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
|
||||||
totalBandwidth
|
|
||||||
);
|
|
||||||
if (bandwidthUsage) {
|
|
||||||
// Fire and forget - don't block on limit checking
|
|
||||||
usageService
|
|
||||||
.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
FeatureId.EGRESS_DATA_MB,
|
|
||||||
bandwidthUsage
|
|
||||||
)
|
|
||||||
.catch((error: any) => {
|
|
||||||
logger.error(
|
|
||||||
`Error checking bandwidth limits for org ${orgId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error processing usage for org ${orgId}:`, error);
|
|
||||||
// Continue with other orgs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle sites that reported zero bandwidth but need online status updated
|
|
||||||
const zeroBandwidthPeers = sortedBandwidthData.filter(
|
|
||||||
(peer) => peer.bytesIn === 0 && !offlineSites.has(peer.publicKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (zeroBandwidthPeers.length > 0) {
|
|
||||||
// Fetch all zero bandwidth sites in one query
|
|
||||||
const zeroBandwidthSites = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
sites.pubKey,
|
|
||||||
zeroBandwidthPeers.map((p) => p.publicKey)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort by siteId to ensure consistent lock ordering
|
|
||||||
const sortedZeroBandwidthSites = zeroBandwidthSites.sort(
|
|
||||||
(a, b) => a.siteId - b.siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const site of sortedZeroBandwidthSites) {
|
|
||||||
let newOnlineStatus = site.online;
|
|
||||||
|
|
||||||
// Check if site should go offline based on last bandwidth update WITH DATA
|
|
||||||
if (site.lastBandwidthUpdate) {
|
|
||||||
const lastUpdateWithData = new Date(site.lastBandwidthUpdate);
|
|
||||||
if (lastUpdateWithData < oneMinuteAgo) {
|
|
||||||
newOnlineStatus = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No previous data update recorded, set to offline
|
|
||||||
newOnlineStatus = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update online status if it changed
|
|
||||||
if (site.online !== newOnlineStatus) {
|
|
||||||
try {
|
|
||||||
const updatedSite = await withDeadlockRetry(async () => {
|
|
||||||
const [result] = await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: newOnlineStatus
|
|
||||||
})
|
|
||||||
.where(eq(sites.siteId, site.siteId))
|
|
||||||
.returning();
|
|
||||||
return result;
|
|
||||||
}, `update offline status for site ${site.siteId}`);
|
|
||||||
|
|
||||||
if (updatedSite && exitNodeId) {
|
|
||||||
const notAllowed = await checkExitNodeOrg(
|
|
||||||
exitNodeId,
|
|
||||||
updatedSite.orgId
|
|
||||||
);
|
|
||||||
if (notAllowed) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If site went offline, add it to our tracking set
|
|
||||||
if (!newOnlineStatus && site.pubKey) {
|
|
||||||
offlineSites.add(site.pubKey);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update offline status for site ${site.siteId}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other sites
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export async function updateHolePunch(
|
|||||||
destinations: destinations
|
destinations: destinations
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// logger.error(error); // FIX THIS
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
@@ -262,7 +262,7 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
if (site.subnet && site.listenPort) {
|
if (site.subnet && site.listenPort) {
|
||||||
destinations.push({
|
destinations.push({
|
||||||
destinationIP: site.subnet.split("/")[0],
|
destinationIP: site.subnet.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations(
|
|||||||
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!updatedSite || !updatedSite.subnet) {
|
// if (!updatedSite || !updatedSite.subnet) {
|
||||||
logger.warn(`Site not found: ${newt.siteId}`);
|
// logger.warn(`Site not found: ${newt.siteId}`);
|
||||||
throw new Error("Site not found");
|
// throw new Error("Site not found");
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Find all clients that connect to this site
|
// Find all clients that connect to this site
|
||||||
// const sitesClientPairs = await db
|
// const sitesClientPairs = await db
|
||||||
|
|||||||
@@ -309,6 +309,14 @@ authenticated.post(
|
|||||||
siteResource.removeClientFromSiteResource
|
siteResource.removeClientFromSiteResource
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/client/:clientId/site-resources",
|
||||||
|
verifyLimits,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||||
|
logActionAudit(ActionsEnum.setResourceUsers),
|
||||||
|
siteResource.batchAddClientToSiteResources
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/resource",
|
"/org/:orgId/resource",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -1,9 +1,24 @@
|
|||||||
import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db";
|
import {
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
ExitNode,
|
||||||
|
resources,
|
||||||
|
Site,
|
||||||
|
siteResources,
|
||||||
|
targetHealthCheck,
|
||||||
|
targets
|
||||||
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip";
|
import {
|
||||||
|
formatEndpoint,
|
||||||
|
generateSubnetProxyTargets,
|
||||||
|
SubnetProxyTarget
|
||||||
|
} from "@server/lib/ip";
|
||||||
|
|
||||||
export async function buildClientConfigurationForNewtClient(
|
export async function buildClientConfigurationForNewtClient(
|
||||||
site: Site,
|
site: Site,
|
||||||
@@ -69,40 +84,42 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
// )
|
// )
|
||||||
// );
|
// );
|
||||||
|
|
||||||
// update the peer info on the olm
|
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
|
||||||
// if the peer has not been added yet this will be a no-op
|
// update the peer info on the olm
|
||||||
await updatePeer(client.clients.clientId, {
|
// if the peer has not been added yet this will be a no-op
|
||||||
siteId: site.siteId,
|
await updatePeer(client.clients.clientId, {
|
||||||
endpoint: site.endpoint!,
|
siteId: site.siteId,
|
||||||
relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`,
|
endpoint: site.endpoint!,
|
||||||
publicKey: site.publicKey!,
|
relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`,
|
||||||
serverIP: site.address,
|
publicKey: site.publicKey!,
|
||||||
serverPort: site.listenPort
|
serverIP: site.address,
|
||||||
// remoteSubnets: generateRemoteSubnets(
|
serverPort: site.listenPort
|
||||||
// allSiteResources.map(
|
// remoteSubnets: generateRemoteSubnets(
|
||||||
// ({ siteResources }) => siteResources
|
// allSiteResources.map(
|
||||||
// )
|
// ({ siteResources }) => siteResources
|
||||||
// ),
|
// )
|
||||||
// aliases: generateAliasConfig(
|
// ),
|
||||||
// allSiteResources.map(
|
// aliases: generateAliasConfig(
|
||||||
// ({ siteResources }) => siteResources
|
// allSiteResources.map(
|
||||||
// )
|
// ({ siteResources }) => siteResources
|
||||||
// )
|
// )
|
||||||
});
|
// )
|
||||||
|
});
|
||||||
|
|
||||||
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
||||||
// if it has already been added this will be a no-op
|
// if it has already been added this will be a no-op
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clients.clientId,
|
client.clients.clientId,
|
||||||
{
|
{
|
||||||
siteId,
|
siteId,
|
||||||
exitNode: {
|
exitNode: {
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
endpoint: exitNode.endpoint
|
endpoint: exitNode.endpoint
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKey: client.clients.pubKey!,
|
publicKey: client.clients.pubKey!,
|
||||||
@@ -188,7 +205,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: targetHealthCheck.hcTimeout,
|
hcTimeout: targetHealthCheck.hcTimeout,
|
||||||
hcHeaders: targetHealthCheck.hcHeaders,
|
hcHeaders: targetHealthCheck.hcHeaders,
|
||||||
hcMethod: targetHealthCheck.hcMethod,
|
hcMethod: targetHealthCheck.hcMethod,
|
||||||
hcTlsServerName: targetHealthCheck.hcTlsServerName
|
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||||
|
hcStatus: targetHealthCheck.hcStatus
|
||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -205,8 +223,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format target into string
|
// Format target into string (handles IPv6 bracketing)
|
||||||
const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`;
|
const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`;
|
||||||
|
|
||||||
// Add to the appropriate protocol array
|
// Add to the appropriate protocol array
|
||||||
if (target.protocol === "tcp") {
|
if (target.protocol === "tcp") {
|
||||||
@@ -229,9 +247,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
!target.hcInterval ||
|
!target.hcInterval ||
|
||||||
!target.hcMethod
|
!target.hcMethod
|
||||||
) {
|
) {
|
||||||
logger.debug(
|
// logger.debug(
|
||||||
`Skipping adding target health check ${target.targetId} due to missing health check fields`
|
// `Skipping adding target health check ${target.targetId} due to missing health check fields`
|
||||||
);
|
// );
|
||||||
return null; // Skip targets with missing health check fields
|
return null; // Skip targets with missing health check fields
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +279,8 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
|||||||
hcTimeout: target.hcTimeout, // in seconds
|
hcTimeout: target.hcTimeout, // in seconds
|
||||||
hcHeaders: hcHeadersSend,
|
hcHeaders: hcHeadersSend,
|
||||||
hcMethod: target.hcMethod,
|
hcMethod: target.hcMethod,
|
||||||
hcTlsServerName: target.hcTlsServerName
|
hcTlsServerName: target.hcTlsServerName,
|
||||||
|
hcStatus: target.hcStatus
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
const inputSchema = z.object({
|
const inputSchema = z.object({
|
||||||
publicKey: z.string(),
|
publicKey: z.string(),
|
||||||
@@ -104,11 +105,11 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
const payload = {
|
const payload = {
|
||||||
oldDestination: {
|
oldDestination: {
|
||||||
destinationIP: existingSite.subnet?.split("/")[0],
|
destinationIP: existingSite.subnet?.split("/")[0],
|
||||||
destinationPort: existingSite.listenPort
|
destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
},
|
},
|
||||||
newDestination: {
|
newDestination: {
|
||||||
destinationIP: site.subnet?.split("/")[0],
|
destinationIP: site.subnet?.split("/")[0],
|
||||||
destinationPort: site.listenPort
|
destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +136,9 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
|||||||
targets
|
targets
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
excludeSender: false
|
excludeSender: false
|
||||||
};
|
};
|
||||||
|
|||||||
34
server/routers/newt/handleNewtDisconnectingMessage.ts
Normal file
34
server/routers/newt/handleNewtDisconnectingMessage.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { db, Newt, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles disconnecting messages from sites to show disconnected in the ui
|
||||||
|
*/
|
||||||
|
export const handleNewtDisconnectingMessage: MessageHandler = async (context) => {
|
||||||
|
const { message, client: c, sendToClient } = context;
|
||||||
|
const newt = c as Newt;
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
logger.warn("Newt not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newt.siteId) {
|
||||||
|
logger.warn("Newt has no client ID!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update the client's last ping timestamp
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: false
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, sites.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error handling disconnecting message", { error });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,105 +1,107 @@
|
|||||||
import { db, sites } from "@server/db";
|
import { db, newts, sites } from "@server/db";
|
||||||
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
|
import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { clients, Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
|
||||||
import { sha256 } from "@oslojs/crypto/sha2";
|
|
||||||
import { sendNewtSyncMessage } from "./sync";
|
import { sendNewtSyncMessage } from "./sync";
|
||||||
|
|
||||||
// Track if the offline checker interval is running
|
// Track if the offline checker interval is running
|
||||||
// let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||||
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||||
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the background interval that checks for clients that haven't pinged recently
|
* Starts the background interval that checks for newt sites that haven't
|
||||||
* and marks them as offline
|
* pinged recently and marks them as offline. For backward compatibility,
|
||||||
|
* a site is only marked offline when there is no active WebSocket connection
|
||||||
|
* either — so older newt versions that don't send pings but remain connected
|
||||||
|
* continue to be treated as online.
|
||||||
*/
|
*/
|
||||||
// export const startNewtOfflineChecker = (): void => {
|
export const startNewtOfflineChecker = (): void => {
|
||||||
// if (offlineCheckerInterval) {
|
if (offlineCheckerInterval) {
|
||||||
// return; // Already running
|
return; // Already running
|
||||||
// }
|
}
|
||||||
|
|
||||||
// offlineCheckerInterval = setInterval(async () => {
|
offlineCheckerInterval = setInterval(async () => {
|
||||||
// try {
|
try {
|
||||||
// const twoMinutesAgo = Math.floor(
|
const twoMinutesAgo = Math.floor(
|
||||||
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||||
// );
|
);
|
||||||
|
|
||||||
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
// Find all online newt-type sites that haven't pinged recently
|
||||||
|
// (or have never pinged at all). Join newts to obtain the newtId
|
||||||
|
// needed for the WebSocket connection check.
|
||||||
|
const staleSites = await db
|
||||||
|
.select({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
newtId: newts.newtId,
|
||||||
|
lastPing: sites.lastPing
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(newts.siteId, sites.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.online, true),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
or(
|
||||||
|
lt(sites.lastPing, twoMinutesAgo),
|
||||||
|
isNull(sites.lastPing)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
for (const staleSite of staleSites) {
|
||||||
// const offlineClients = await db
|
// Backward-compatibility check: if the newt still has an
|
||||||
// .update(clients)
|
// active WebSocket connection (older clients that don't send
|
||||||
// .set({ online: false })
|
// pings), keep the site online.
|
||||||
// .where(
|
const isConnected = await hasActiveConnections(staleSite.newtId);
|
||||||
// and(
|
if (isConnected) {
|
||||||
// eq(clients.online, true),
|
logger.debug(
|
||||||
// or(
|
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online`
|
||||||
// lt(clients.lastPing, twoMinutesAgo),
|
);
|
||||||
// isNull(clients.lastPing)
|
continue;
|
||||||
// )
|
}
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .returning();
|
|
||||||
|
|
||||||
// for (const offlineClient of offlineClients) {
|
logger.info(
|
||||||
// logger.info(
|
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
|
||||||
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
|
);
|
||||||
// );
|
|
||||||
|
|
||||||
// if (!offlineClient.newtId) {
|
await db
|
||||||
// logger.warn(
|
.update(sites)
|
||||||
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
|
.set({ online: false })
|
||||||
// );
|
.where(eq(sites.siteId, staleSite.siteId));
|
||||||
// continue;
|
}
|
||||||
// }
|
} catch (error) {
|
||||||
|
logger.error("Error in newt offline checker interval", { error });
|
||||||
|
}
|
||||||
|
}, OFFLINE_CHECK_INTERVAL);
|
||||||
|
|
||||||
// // Send a disconnect message to the client if connected
|
logger.debug("Started newt offline checker interval");
|
||||||
// try {
|
};
|
||||||
// await sendTerminateClient(
|
|
||||||
// offlineClient.clientId,
|
|
||||||
// offlineClient.newtId
|
|
||||||
// ); // terminate first
|
|
||||||
// // wait a moment to ensure the message is sent
|
|
||||||
// await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
// await disconnectClient(offlineClient.newtId);
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error(
|
|
||||||
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
|
|
||||||
// { error }
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error in offline checker interval", { error });
|
|
||||||
// }
|
|
||||||
// }, OFFLINE_CHECK_INTERVAL);
|
|
||||||
|
|
||||||
// logger.debug("Started offline checker interval");
|
|
||||||
// };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops the background interval that checks for offline clients
|
* Stops the background interval that checks for offline newt sites.
|
||||||
*/
|
*/
|
||||||
// export const stopNewtOfflineChecker = (): void => {
|
export const stopNewtOfflineChecker = (): void => {
|
||||||
// if (offlineCheckerInterval) {
|
if (offlineCheckerInterval) {
|
||||||
// clearInterval(offlineCheckerInterval);
|
clearInterval(offlineCheckerInterval);
|
||||||
// offlineCheckerInterval = null;
|
offlineCheckerInterval = null;
|
||||||
// logger.info("Stopped offline checker interval");
|
logger.info("Stopped newt offline checker interval");
|
||||||
// }
|
}
|
||||||
// };
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from newt clients.
|
||||||
|
*
|
||||||
|
* On each ping:
|
||||||
|
* - Marks the associated site as online.
|
||||||
|
* - Records the current timestamp as the newt's last-ping time.
|
||||||
|
* - Triggers a config sync if the newt is running an outdated config version.
|
||||||
|
* - Responds with a pong message.
|
||||||
*/
|
*/
|
||||||
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c } = context;
|
||||||
const newt = c as Newt;
|
const newt = c as Newt;
|
||||||
|
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
@@ -112,15 +114,31 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the version
|
try {
|
||||||
|
// Mark the site as online and record the ping timestamp.
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newt.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating online state on newt ping", { error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check config version and sync if stale.
|
||||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||||
|
|
||||||
if (message.configVersion && configVersion != null && configVersion != message.configVersion) {
|
if (
|
||||||
|
message.configVersion != null &&
|
||||||
|
configVersion != null &&
|
||||||
|
configVersion !== message.configVersion
|
||||||
|
) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
|
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// get the site
|
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
@@ -137,19 +155,6 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
|
|||||||
await sendNewtSyncMessage(newt, site);
|
await sendNewtSyncMessage(newt, site);
|
||||||
}
|
}
|
||||||
|
|
||||||
// try {
|
|
||||||
// // Update the client's last ping timestamp
|
|
||||||
// await db
|
|
||||||
// .update(clients)
|
|
||||||
// .set({
|
|
||||||
// lastPing: Math.floor(Date.now() / 1000),
|
|
||||||
// online: true
|
|
||||||
// })
|
|
||||||
// .where(eq(clients.clientId, newt.clientId));
|
|
||||||
// } catch (error) {
|
|
||||||
// logger.error("Error handling ping message", { error });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "pong",
|
type: "pong",
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
findNextAvailableCidr,
|
|
||||||
} from "@server/lib/ip";
|
|
||||||
import {
|
import {
|
||||||
selectBestExitNode,
|
selectBestExitNode,
|
||||||
verifyExitNodeOrgAccess
|
verifyExitNodeOrgAccess
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
import { fetchContainers } from "./dockerSocket";
|
import { fetchContainers } from "./dockerSocket";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export type ExitNodePingResult = {
|
export type ExitNodePingResult = {
|
||||||
exitNodeId: number;
|
exitNodeId: number;
|
||||||
@@ -215,6 +214,9 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
healthCheckTargets: validHealthCheckTargets
|
healthCheckTargets: validHealthCheckTargets
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
|
},
|
||||||
broadcast: false, // Send to all clients
|
broadcast: false, // Send to all clients
|
||||||
excludeSender: false // Include sender in broadcast
|
excludeSender: false // Include sender in broadcast
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,21 @@ interface PeerBandwidth {
|
|||||||
bytesOut: number;
|
bytesOut: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BandwidthAccumulator {
|
||||||
|
bytesIn: number;
|
||||||
|
bytesOut: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Retry configuration for deadlock handling
|
// Retry configuration for deadlock handling
|
||||||
const MAX_RETRIES = 3;
|
const MAX_RETRIES = 3;
|
||||||
const BASE_DELAY_MS = 50;
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
|
// How often to flush accumulated bandwidth data to the database
|
||||||
|
const FLUSH_INTERVAL_MS = 120_000; // 120 seconds
|
||||||
|
|
||||||
|
// In-memory accumulator: publicKey -> { bytesIn, bytesOut }
|
||||||
|
let accumulator = new Map<string, BandwidthAccumulator>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error is a deadlock error
|
* Check if an error is a deadlock error
|
||||||
*/
|
*/
|
||||||
@@ -53,6 +64,90 @@ async function withDeadlockRetry<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flush all accumulated bandwidth data to the database.
|
||||||
|
*
|
||||||
|
* Swaps out the accumulator before writing so that any bandwidth messages
|
||||||
|
* received during the flush are captured in the new accumulator rather than
|
||||||
|
* being lost or causing contention. Entries that fail to write are re-queued
|
||||||
|
* back into the accumulator so they will be retried on the next flush.
|
||||||
|
*
|
||||||
|
* This function is exported so that the application's graceful-shutdown
|
||||||
|
* cleanup handler can call it before the process exits.
|
||||||
|
*/
|
||||||
|
export async function flushBandwidthToDb(): Promise<void> {
|
||||||
|
if (accumulator.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically swap out the accumulator so new data keeps flowing in
|
||||||
|
// while we write the snapshot to the database.
|
||||||
|
const snapshot = accumulator;
|
||||||
|
accumulator = new Map<string, BandwidthAccumulator>();
|
||||||
|
|
||||||
|
const currentTime = new Date().toISOString();
|
||||||
|
|
||||||
|
// Sort by publicKey for consistent lock ordering across concurrent
|
||||||
|
// writers — this is the same deadlock-prevention strategy used in the
|
||||||
|
// original per-message implementation.
|
||||||
|
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Flushing accumulated bandwidth data for ${sortedEntries.length} client(s) to the database`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [publicKey, { bytesIn, bytesOut }] of sortedEntries) {
|
||||||
|
try {
|
||||||
|
await withDeadlockRetry(async () => {
|
||||||
|
// Use atomic SQL increment to avoid the SELECT-then-UPDATE
|
||||||
|
// anti-pattern and the races it would introduce.
|
||||||
|
await db
|
||||||
|
.update(clients)
|
||||||
|
.set({
|
||||||
|
// Note: bytesIn from peer goes to megabytesOut (data
|
||||||
|
// sent to client) and bytesOut from peer goes to
|
||||||
|
// megabytesIn (data received from client).
|
||||||
|
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
|
||||||
|
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
|
||||||
|
lastBandwidthUpdate: currentTime
|
||||||
|
})
|
||||||
|
.where(eq(clients.pubKey, publicKey));
|
||||||
|
}, `flush bandwidth for client ${publicKey}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to flush bandwidth for client ${publicKey}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-queue the failed entry so it is retried on the next flush
|
||||||
|
// rather than silently dropped.
|
||||||
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
|
existing.bytesIn += bytesIn;
|
||||||
|
existing.bytesOut += bytesOut;
|
||||||
|
} else {
|
||||||
|
accumulator.set(publicKey, { bytesIn, bytesOut });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await flushBandwidthToDb();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Unexpected error during periodic bandwidth flush:", error);
|
||||||
|
}
|
||||||
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Calling unref() means this timer will not keep the Node.js event loop alive
|
||||||
|
// on its own — the process can still exit normally when there is no other work
|
||||||
|
// left. The graceful-shutdown path (see server/cleanup.ts) will call
|
||||||
|
// flushBandwidthToDb() explicitly before process.exit(), so no data is lost.
|
||||||
|
flushTimer.unref();
|
||||||
|
|
||||||
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||||
context
|
context
|
||||||
) => {
|
) => {
|
||||||
@@ -69,40 +164,21 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
|
|||||||
throw new Error("Invalid bandwidth data");
|
throw new Error("Invalid bandwidth data");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
|
// Accumulate the incoming data in memory; the periodic timer (and the
|
||||||
// This is critical for preventing deadlocks when multiple instances update the same clients
|
// shutdown hook) will take care of writing it to the database.
|
||||||
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
|
||||||
a.publicKey.localeCompare(b.publicKey)
|
// Skip peers that haven't transferred any data — writing zeros to the
|
||||||
);
|
// database would be a no-op anyway.
|
||||||
|
if (bytesIn <= 0 && bytesOut <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const currentTime = new Date().toISOString();
|
const existing = accumulator.get(publicKey);
|
||||||
|
if (existing) {
|
||||||
// Update each client individually with retry logic
|
existing.bytesIn += bytesIn;
|
||||||
// This reduces transaction scope and allows retries per-client
|
existing.bytesOut += bytesOut;
|
||||||
for (const peer of sortedBandwidthData) {
|
} else {
|
||||||
const { publicKey, bytesIn, bytesOut } = peer;
|
accumulator.set(publicKey, { bytesIn, bytesOut });
|
||||||
|
|
||||||
try {
|
|
||||||
await withDeadlockRetry(async () => {
|
|
||||||
// Use atomic SQL increment to avoid SELECT then UPDATE pattern
|
|
||||||
// This eliminates the need to read the current value first
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
// Note: bytesIn from peer goes to megabytesOut (data sent to client)
|
|
||||||
// and bytesOut from peer goes to megabytesIn (data received from client)
|
|
||||||
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
|
|
||||||
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
|
|
||||||
lastBandwidthUpdate: currentTime
|
|
||||||
})
|
|
||||||
.where(eq(clients.pubKey, publicKey));
|
|
||||||
}, `update client bandwidth ${publicKey}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to update bandwidth for client ${publicKey}:`,
|
|
||||||
error
|
|
||||||
);
|
|
||||||
// Continue with other clients even if one fails
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * from "./handleSocketMessages";
|
|||||||
export * from "./handleNewtPingRequestMessage";
|
export * from "./handleNewtPingRequestMessage";
|
||||||
export * from "./handleApplyBlueprintMessage";
|
export * from "./handleApplyBlueprintMessage";
|
||||||
export * from "./handleNewtPingMessage";
|
export * from "./handleNewtPingMessage";
|
||||||
|
export * from "./handleNewtDisconnectingMessage";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildClientConfigurationForNewtClient,
|
buildClientConfigurationForNewtClient,
|
||||||
buildTargetConfigurationForNewtClient
|
buildTargetConfigurationForNewtClient
|
||||||
} from "./buildConfiguration";
|
} from "./buildConfiguration";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
||||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||||
@@ -24,18 +25,24 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
|
|||||||
exitNode
|
exitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
await sendToClient(newt.newtId, {
|
await sendToClient(
|
||||||
type: "newt/sync",
|
newt.newtId,
|
||||||
data: {
|
{
|
||||||
proxyTargets: {
|
type: "newt/sync",
|
||||||
udp: udpTargets,
|
data: {
|
||||||
tcp: tcpTargets
|
proxyTargets: {
|
||||||
},
|
udp: udpTargets,
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
tcp: tcpTargets
|
||||||
peers: peers,
|
},
|
||||||
clientTargets: targets
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
|
peers: peers,
|
||||||
|
clientTargets: targets
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
compress: canCompress(newt.version, "newt")
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending newt sync message:`, error);
|
logger.warn(`Error sending newt sync message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function addTargets(
|
export async function addTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
healthCheckData: TargetHealthCheck[],
|
healthCheckData: TargetHealthCheck[],
|
||||||
protocol: string,
|
protocol: string,
|
||||||
port: number | null = null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
@@ -22,7 +23,7 @@ export async function addTargets(
|
|||||||
data: {
|
data: {
|
||||||
targets: payloadTargets
|
targets: payloadTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
|
|
||||||
// Create a map for quick lookup
|
// Create a map for quick lookup
|
||||||
const healthCheckMap = new Map<number, TargetHealthCheck>();
|
const healthCheckMap = new Map<number, TargetHealthCheck>();
|
||||||
@@ -103,14 +104,14 @@ export async function addTargets(
|
|||||||
data: {
|
data: {
|
||||||
targets: validHealthCheckTargets
|
targets: validHealthCheckTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: Target[],
|
targets: Target[],
|
||||||
protocol: string,
|
protocol: string,
|
||||||
port: number | null = null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
//create a list of udp and tcp targets
|
//create a list of udp and tcp targets
|
||||||
const payloadTargets = targets.map((target) => {
|
const payloadTargets = targets.map((target) => {
|
||||||
@@ -135,5 +136,5 @@ export async function removeTargets(
|
|||||||
data: {
|
data: {
|
||||||
ids: healthCheckTargets
|
ids: healthCheckTargets
|
||||||
}
|
}
|
||||||
}, { incrementConfigVersion: true });
|
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { Client, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, siteResources, sites } from "@server/db";
|
import {
|
||||||
import { generateAliasConfig, generateRemoteSubnets } from "@server/lib/ip";
|
Client,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
siteResources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import {
|
||||||
|
Alias,
|
||||||
|
generateAliasConfig,
|
||||||
|
generateRemoteSubnets
|
||||||
|
} from "@server/lib/ip";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
@@ -8,9 +20,19 @@ import config from "@server/lib/config";
|
|||||||
export async function buildSiteConfigurationForOlmClient(
|
export async function buildSiteConfigurationForOlmClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
publicKey: string | null,
|
publicKey: string | null,
|
||||||
relay: boolean
|
relay: boolean,
|
||||||
|
jitMode: boolean = false
|
||||||
) {
|
) {
|
||||||
const siteConfigurations = [];
|
const siteConfigurations: {
|
||||||
|
siteId: number;
|
||||||
|
name?: string
|
||||||
|
endpoint?: string
|
||||||
|
publicKey?: string
|
||||||
|
serverIP?: string | null
|
||||||
|
serverPort?: number | null
|
||||||
|
remoteSubnets?: string[];
|
||||||
|
aliases: Alias[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// Get all sites data
|
// Get all sites data
|
||||||
const sitesData = await db
|
const sitesData = await db
|
||||||
@@ -27,6 +49,40 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
sites: site,
|
sites: site,
|
||||||
clientSitesAssociationsCache: association
|
clientSitesAssociationsCache: association
|
||||||
} of sitesData) {
|
} of sitesData) {
|
||||||
|
const allSiteResources = await db // only get the site resources that this client has access to
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.siteId, site.siteId),
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (jitMode) {
|
||||||
|
// Add site configuration to the array
|
||||||
|
siteConfigurations.push({
|
||||||
|
siteId: site.siteId,
|
||||||
|
// remoteSubnets: generateRemoteSubnets(
|
||||||
|
// allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
|
// ),
|
||||||
|
aliases: generateAliasConfig(
|
||||||
|
allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!site.exitNodeId) {
|
if (!site.exitNodeId) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Site ${site.siteId} does not have exit node, skipping`
|
`Site ${site.siteId} does not have exit node, skipping`
|
||||||
@@ -42,6 +98,13 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
|
||||||
|
logger.warn(
|
||||||
|
`Site ${site.siteId} has no public key, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
|
||||||
// logger.warn(
|
// logger.warn(
|
||||||
// `Site ${site.siteId} last hole punch is too old, skipping`
|
// `Site ${site.siteId} last hole punch is too old, skipping`
|
||||||
@@ -103,26 +166,6 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSiteResources = await db // only get the site resources that this client has access to
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.innerJoin(
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
eq(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
clientSiteResourcesAssociationsCache.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteId, site.siteId),
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add site configuration to the array
|
// Add site configuration to the array
|
||||||
siteConfigurations.push({
|
siteConfigurations.push({
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import logger from "@server/logger";
|
|||||||
/**
|
/**
|
||||||
* Handles disconnecting messages from clients to show disconnected in the ui
|
* Handles disconnecting messages from clients to show disconnected in the ui
|
||||||
*/
|
*/
|
||||||
export const handleOlmDisconnecingMessage: MessageHandler = async (context) => {
|
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => {
|
||||||
const { message, client: c, sendToClient } = context;
|
const { message, client: c, sendToClient } = context;
|
||||||
const olm = c as Olm;
|
const olm = c as Olm;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { getUserDeviceName } from "@server/db/names";
|
|||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||||
|
import { Alias } from "@server/lib/ip";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||||
logger.info("Handling register olm message!");
|
logger.info("Handling register olm message!");
|
||||||
@@ -207,6 +210,32 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all sites data
|
||||||
|
const sitesCountResult = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
||||||
|
)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
|
// Extract the count value from the result array
|
||||||
|
const sitesCount =
|
||||||
|
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||||
|
|
||||||
|
// Prepare an array to store site configurations
|
||||||
|
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
||||||
|
|
||||||
|
let jitMode = false;
|
||||||
|
if (sitesCount > 250 && build == "saas") {
|
||||||
|
// THIS IS THE MAX ON THE BUSINESS TIER
|
||||||
|
// we have too many sites
|
||||||
|
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
||||||
|
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
||||||
|
jitMode = true;
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
||||||
);
|
);
|
||||||
@@ -233,28 +262,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
await db
|
await db
|
||||||
.update(clientSitesAssociationsCache)
|
.update(clientSitesAssociationsCache)
|
||||||
.set({
|
.set({
|
||||||
isRelayed: relay == true
|
isRelayed: relay == true,
|
||||||
|
isJitMode: jitMode
|
||||||
})
|
})
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all sites data
|
|
||||||
const sitesCountResult = await db
|
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
|
||||||
.innerJoin(
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
|
||||||
|
|
||||||
// Extract the count value from the result array
|
|
||||||
const sitesCount =
|
|
||||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
|
||||||
|
|
||||||
// Prepare an array to store site configurations
|
|
||||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
|
||||||
|
|
||||||
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
// this prevents us from accepting a register from an olm that has not hole punched yet.
|
||||||
// the olm will pump the register so we can keep checking
|
// the olm will pump the register so we can keep checking
|
||||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||||
@@ -265,19 +278,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||||
client,
|
client,
|
||||||
publicKey,
|
publicKey,
|
||||||
relay
|
relay,
|
||||||
|
jitMode
|
||||||
);
|
);
|
||||||
|
|
||||||
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
|
|
||||||
// if (siteConfigurations.length === 0) {
|
|
||||||
// logger.warn("No valid site configurations found");
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Return connect message with all site configurations
|
// Return connect message with all site configurations
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
@@ -288,6 +296,9 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
utilitySubnet: org.utilitySubnet
|
utilitySubnet: org.utilitySubnet
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
options: {
|
||||||
|
compress: canCompress(olm.version, "olm")
|
||||||
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
excludeSender: false
|
excludeSender: false
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
logger.warn("Olm has no client!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -90,7 +90,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
|
|||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
relayEndpoint: exitNode.endpoint,
|
relayEndpoint: exitNode.endpoint,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
241
server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
Normal file
241
server/routers/olm/handleOlmServerInitAddPeerHandshake.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
Site,
|
||||||
|
siteResources
|
||||||
|
} from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { clients, Olm, sites } from "@server/db";
|
||||||
|
import { and, eq, or } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { initPeerAddHandshake } from "./peers";
|
||||||
|
|
||||||
|
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
logger.info("Handling register olm message!");
|
||||||
|
const { message, client: c, sendToClient } = context;
|
||||||
|
const olm = c as Olm;
|
||||||
|
|
||||||
|
if (!olm) {
|
||||||
|
logger.warn("Olm not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!olm.clientId) {
|
||||||
|
logger.warn("Olm has no client!"); // TODO: Maybe we create the site here?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = olm.clientId;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
logger.warn("Client not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId, resourceId, chainId } = message.data;
|
||||||
|
|
||||||
|
let site: Site | null = null;
|
||||||
|
if (siteId) {
|
||||||
|
// get the site
|
||||||
|
const [siteRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
if (siteRes) {
|
||||||
|
site = siteRes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId && !site) {
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(siteResources.niceId, resourceId),
|
||||||
|
eq(siteResources.alias, resourceId)
|
||||||
|
),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.length > 1) {
|
||||||
|
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resources[0];
|
||||||
|
|
||||||
|
const currentResourceAssociationCaches = await db
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||||
|
resource.siteResourceId
|
||||||
|
),
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentResourceAssociationCaches.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteIdFromResource = resource.siteId;
|
||||||
|
|
||||||
|
// get the site
|
||||||
|
const [siteRes] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteIdFromResource));
|
||||||
|
if (!siteRes) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
site = siteRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if the client can access this site using the cache
|
||||||
|
const currentSiteAssociationCaches = await db
|
||||||
|
.select()
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, client.clientId),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, site.siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSiteAssociationCaches.length === 0) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site.exitNodeId) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||||
|
);
|
||||||
|
// cancel the request from the olm side to not keep doing this
|
||||||
|
await sendToClient(
|
||||||
|
olm.olmId,
|
||||||
|
{
|
||||||
|
type: "olm/wg/peer/chain/cancel",
|
||||||
|
data: {
|
||||||
|
chainId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ incrementConfigVersion: false }
|
||||||
|
).catch((error) => {
|
||||||
|
logger.warn(`Error sending message:`, error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the exit node from the side
|
||||||
|
const [exitNode] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||||
|
|
||||||
|
if (!exitNode) {
|
||||||
|
logger.error(
|
||||||
|
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
|
||||||
|
// if it has already been added this will be a no-op
|
||||||
|
await initPeerAddHandshake(
|
||||||
|
// this will kick off the add peer process for the client
|
||||||
|
client.clientId,
|
||||||
|
{
|
||||||
|
siteId: site.siteId,
|
||||||
|
exitNode: {
|
||||||
|
publicKey: exitNode.publicKey,
|
||||||
|
endpoint: exitNode.endpoint
|
||||||
|
}
|
||||||
|
},
|
||||||
|
olm.olmId,
|
||||||
|
chainId
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -54,7 +54,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// get the site
|
// get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -179,7 +179,8 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
|||||||
),
|
),
|
||||||
aliases: generateAliasConfig(
|
aliases: generateAliasConfig(
|
||||||
allSiteResources.map(({ siteResources }) => siteResources)
|
allSiteResources.map(({ siteResources }) => siteResources)
|
||||||
)
|
),
|
||||||
|
chainId: chainId,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!olm.clientId) {
|
if (!olm.clientId) {
|
||||||
logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
|
logger.warn("Olm has no client!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { siteId } = message.data;
|
const { siteId, chainId } = message.data;
|
||||||
|
|
||||||
// Get the site
|
// Get the site
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -87,7 +87,8 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
|
|||||||
type: "olm/wg/peer/unrelay",
|
type: "olm/wg/peer/unrelay",
|
||||||
data: {
|
data: {
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
endpoint: site.endpoint
|
endpoint: site.endpoint,
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
broadcast: false,
|
broadcast: false,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export * from "./handleOlmServerPeerAddMessage";
|
|||||||
export * from "./handleOlmUnRelayMessage";
|
export * from "./handleOlmUnRelayMessage";
|
||||||
export * from "./recoverOlmWithFingerprint";
|
export * from "./recoverOlmWithFingerprint";
|
||||||
export * from "./handleOlmDisconnectingMessage";
|
export * from "./handleOlmDisconnectingMessage";
|
||||||
|
export * from "./handleOlmServerInitAddPeerHandshake";
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, olms } from "@server/db";
|
import { clientSitesAssociationsCache, db, olms } from "@server/db";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { Alias } from "yaml";
|
import { Alias } from "yaml";
|
||||||
|
|
||||||
export async function addPeer(
|
export async function addPeer(
|
||||||
@@ -18,7 +19,8 @@ export async function addPeer(
|
|||||||
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
|
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
|
||||||
aliases: Alias[];
|
aliases: Alias[];
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -30,6 +32,7 @@ export async function addPeer(
|
|||||||
return; // ignore this because an olm might not be associated with the client anymore
|
return; // ignore this because an olm might not be associated with the client anymore
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -48,7 +51,7 @@ export async function addPeer(
|
|||||||
aliases: peer.aliases
|
aliases: peer.aliases
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -60,7 +63,8 @@ export async function deletePeer(
|
|||||||
clientId: number,
|
clientId: number,
|
||||||
siteId: number,
|
siteId: number,
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -72,6 +76,7 @@ export async function deletePeer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -83,7 +88,7 @@ export async function deletePeer(
|
|||||||
siteId: siteId
|
siteId: siteId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -103,7 +108,8 @@ export async function updatePeer(
|
|||||||
remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that
|
remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that
|
||||||
aliases?: Alias[] | null;
|
aliases?: Alias[] | null;
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -115,6 +121,7 @@ export async function updatePeer(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
olmId = olm.olmId;
|
olmId = olm.olmId;
|
||||||
|
version = olm.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(
|
||||||
@@ -132,7 +139,7 @@ export async function updatePeer(
|
|||||||
aliases: peer.aliases
|
aliases: peer.aliases
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
@@ -149,7 +156,8 @@ export async function initPeerAddHandshake(
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
olmId?: string
|
olmId?: string,
|
||||||
|
chainId?: string
|
||||||
) {
|
) {
|
||||||
if (!olmId) {
|
if (!olmId) {
|
||||||
const [olm] = await db
|
const [olm] = await db
|
||||||
@@ -173,7 +181,8 @@ export async function initPeerAddHandshake(
|
|||||||
publicKey: peer.exitNode.publicKey,
|
publicKey: peer.exitNode.publicKey,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
endpoint: peer.exitNode.endpoint
|
endpoint: peer.exitNode.endpoint
|
||||||
}
|
},
|
||||||
|
chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ incrementConfigVersion: true }
|
{ incrementConfigVersion: true }
|
||||||
@@ -181,6 +190,17 @@ export async function initPeerAddHandshake(
|
|||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection
|
||||||
|
await db
|
||||||
|
.update(clientSitesAssociationsCache)
|
||||||
|
.set({ isJitMode: false })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, clientId),
|
||||||
|
eq(clientSitesAssociationsCache.siteId, peer.siteId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { Client, db, exitNodes, Olm, sites, clientSitesAssociationsCache } from "@server/db";
|
import {
|
||||||
|
Client,
|
||||||
|
db,
|
||||||
|
exitNodes,
|
||||||
|
Olm,
|
||||||
|
sites,
|
||||||
|
clientSitesAssociationsCache
|
||||||
|
} from "@server/db";
|
||||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
|
||||||
export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
||||||
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
|
||||||
@@ -17,10 +25,7 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
const clientSites = await db
|
const clientSites = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clientSitesAssociationsCache)
|
.from(clientSitesAssociationsCache)
|
||||||
.innerJoin(
|
.innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId))
|
||||||
sites,
|
|
||||||
eq(sites.siteId, clientSitesAssociationsCache.siteId)
|
|
||||||
)
|
|
||||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
|
||||||
// Extract unique exit node IDs
|
// Extract unique exit node IDs
|
||||||
@@ -68,13 +73,20 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
|
|||||||
|
|
||||||
logger.debug("sendOlmSyncMessage: sending sync message");
|
logger.debug("sendOlmSyncMessage: sending sync message");
|
||||||
|
|
||||||
await sendToClient(olm.olmId, {
|
await sendToClient(
|
||||||
type: "olm/sync",
|
olm.olmId,
|
||||||
data: {
|
{
|
||||||
sites: siteConfigurations,
|
type: "olm/sync",
|
||||||
exitNodes: exitNodesData
|
data: {
|
||||||
|
sites: siteConfigurations,
|
||||||
|
exitNodes: exitNodesData
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
compress: canCompress(olm.version, "olm")
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
).catch((error) => {
|
||||||
logger.warn(`Error sending olm sync message:`, error);
|
logger.warn(`Error sending olm sync message:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,6 +223,20 @@ async function createHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent creating resource with same domain as dashboard
|
||||||
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
|
if (dashboardUrl) {
|
||||||
|
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||||
|
if (fullDomain === dashboardHost) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource domain cannot be the same as the dashboard domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build != "oss") {
|
||||||
const existingLoginPages = await db
|
const existingLoginPages = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -353,6 +353,20 @@ async function updateHttpResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent updating resource with same domain as dashboard
|
||||||
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
|
if (dashboardUrl) {
|
||||||
|
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||||
|
if (fullDomain === dashboardHost) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Resource domain cannot be the same as the dashboard domain"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (build != "oss") {
|
if (build != "oss") {
|
||||||
const existingLoginPages = await db
|
const existingLoginPages = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal file
247
server/routers/siteResource/batchAddClientToSiteResources.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
clients,
|
||||||
|
clientSiteResources,
|
||||||
|
siteResources,
|
||||||
|
apiKeyOrg
|
||||||
|
} from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
rebuildClientAssociationsFromClient,
|
||||||
|
rebuildClientAssociationsFromSiteResource
|
||||||
|
} from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const batchAddClientToSiteResourcesParamsSchema = z
|
||||||
|
.object({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const batchAddClientToSiteResourcesBodySchema = z
|
||||||
|
.object({
|
||||||
|
siteResourceIds: z
|
||||||
|
.array(z.number().int().positive())
|
||||||
|
.min(1, "At least one siteResourceId is required")
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/client/{clientId}/site-resources",
|
||||||
|
description: "Add a machine client to multiple site resources at once.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: batchAddClientToSiteResourcesParamsSchema,
|
||||||
|
body: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: batchAddClientToSiteResourcesBodySchema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function batchAddClientToSiteResources(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const apiKey = req.apiKey;
|
||||||
|
if (!apiKey) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedParams =
|
||||||
|
batchAddClientToSiteResourcesParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse(
|
||||||
|
req.body
|
||||||
|
);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId } = parsedParams.data;
|
||||||
|
const { siteResourceIds } = parsedBody.data;
|
||||||
|
const uniqueSiteResourceIds = [...new Set(siteResourceIds)];
|
||||||
|
|
||||||
|
const batchSiteResources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, uniqueSiteResourceIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (batchSiteResources.length !== uniqueSiteResourceIds.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"One or more site resources not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey.isRoot) {
|
||||||
|
const orgIds = [
|
||||||
|
...new Set(batchSiteResources.map((sr) => sr.orgId))
|
||||||
|
];
|
||||||
|
if (orgIds.length > 1) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"All site resources must belong to the same organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const orgId = orgIds[0];
|
||||||
|
const [apiKeyOrgRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(apiKeyOrg)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||||
|
eq(apiKeyOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!apiKeyOrgRow) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to the organization of the specified site resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [clientInOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.clientId, clientId),
|
||||||
|
eq(clients.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!clientInOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Key does not have access to the specified client"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Client not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.userId !== null) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"This endpoint only supports machine (non-user) clients; the specified client is associated with a user"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEntries = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: clientSiteResources.siteResourceId
|
||||||
|
})
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSiteResources.clientId, clientId),
|
||||||
|
inArray(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
batchSiteResources.map((sr) => sr.siteResourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSiteResourceIds = new Set(
|
||||||
|
existingEntries.map((e) => e.siteResourceId)
|
||||||
|
);
|
||||||
|
const siteResourcesToAdd = batchSiteResources.filter(
|
||||||
|
(sr) => !existingSiteResourceIds.has(sr.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siteResourcesToAdd.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Client is already assigned to all specified site resources"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const siteResource of siteResourcesToAdd) {
|
||||||
|
await trx.insert(clientSiteResources).values({
|
||||||
|
clientId,
|
||||||
|
siteResourceId: siteResource.siteResourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client, trx);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {
|
||||||
|
addedCount: siteResourcesToAdd.length,
|
||||||
|
skippedCount:
|
||||||
|
batchSiteResources.length - siteResourcesToAdd.length,
|
||||||
|
siteResourceIds: siteResourcesToAdd.map(
|
||||||
|
(sr) => sr.siteResourceId
|
||||||
|
)
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`,
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,4 +15,5 @@ export * from "./addUserToSiteResource";
|
|||||||
export * from "./removeUserFromSiteResource";
|
export * from "./removeUserFromSiteResource";
|
||||||
export * from "./setSiteResourceClients";
|
export * from "./setSiteResourceClients";
|
||||||
export * from "./addClientToSiteResource";
|
export * from "./addClientToSiteResource";
|
||||||
|
export * from "./batchAddClientToSiteResources";
|
||||||
export * from "./removeClientFromSiteResource";
|
export * from "./removeClientFromSiteResource";
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
await updateTargets(newt.newtId, {
|
await updateTargets(newt.newtId, {
|
||||||
oldTargets: oldTargets,
|
oldTargets: oldTargets,
|
||||||
newTargets: newTargets
|
newTargets: newTargets
|
||||||
});
|
}, newt.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
const olmJobs: Promise<void>[] = [];
|
const olmJobs: Promise<void>[] = [];
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export async function createTarget(
|
|||||||
newTarget,
|
newTarget,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
resource.protocol,
|
resource.protocol,
|
||||||
resource.proxyPort
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export async function updateTarget(
|
|||||||
[updatedTarget],
|
[updatedTarget],
|
||||||
[updatedHc],
|
[updatedHc],
|
||||||
resource.protocol,
|
resource.protocol,
|
||||||
resource.proxyPort
|
newt.version
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,18 @@ export async function traefikConfigProvider(
|
|||||||
userSessionCookieName:
|
userSessionCookieName:
|
||||||
config.getRawConfig().server.session_cookie_name,
|
config.getRawConfig().server.session_cookie_name,
|
||||||
|
|
||||||
// deprecated
|
|
||||||
accessTokenQueryParam:
|
accessTokenQueryParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_access_token_param,
|
.resource_access_token_param,
|
||||||
|
|
||||||
|
accessTokenIdHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.id,
|
||||||
|
|
||||||
|
accessTokenHeader:
|
||||||
|
config.getRawConfig().server
|
||||||
|
.resource_access_token_headers.token,
|
||||||
|
|
||||||
resourceSessionRequestParam:
|
resourceSessionRequestParam:
|
||||||
config.getRawConfig().server
|
config.getRawConfig().server
|
||||||
.resource_session_request_param
|
.resource_session_request_param
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
handleNewtRegisterMessage,
|
handleNewtRegisterMessage,
|
||||||
handleReceiveBandwidthMessage,
|
handleReceiveBandwidthMessage,
|
||||||
@@ -6,7 +7,9 @@ import {
|
|||||||
handleDockerContainersMessage,
|
handleDockerContainersMessage,
|
||||||
handleNewtPingRequestMessage,
|
handleNewtPingRequestMessage,
|
||||||
handleApplyBlueprintMessage,
|
handleApplyBlueprintMessage,
|
||||||
handleNewtPingMessage
|
handleNewtPingMessage,
|
||||||
|
startNewtOfflineChecker,
|
||||||
|
handleNewtDisconnectingMessage
|
||||||
} from "../newt";
|
} from "../newt";
|
||||||
import {
|
import {
|
||||||
handleOlmRegisterMessage,
|
handleOlmRegisterMessage,
|
||||||
@@ -15,7 +18,8 @@ import {
|
|||||||
startOlmOfflineChecker,
|
startOlmOfflineChecker,
|
||||||
handleOlmServerPeerAddMessage,
|
handleOlmServerPeerAddMessage,
|
||||||
handleOlmUnRelayMessage,
|
handleOlmUnRelayMessage,
|
||||||
handleOlmDisconnecingMessage
|
handleOlmDisconnectingMessage,
|
||||||
|
handleOlmServerInitAddPeerHandshake
|
||||||
} from "../olm";
|
} from "../olm";
|
||||||
import { handleHealthcheckStatusMessage } from "../target";
|
import { handleHealthcheckStatusMessage } from "../target";
|
||||||
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||||
@@ -23,11 +27,13 @@ import { MessageHandler } from "./types";
|
|||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
"olm/wg/server/peer/add": handleOlmServerPeerAddMessage,
|
"olm/wg/server/peer/add": handleOlmServerPeerAddMessage,
|
||||||
|
"olm/wg/server/peer/init": handleOlmServerInitAddPeerHandshake,
|
||||||
"olm/wg/register": handleOlmRegisterMessage,
|
"olm/wg/register": handleOlmRegisterMessage,
|
||||||
"olm/wg/relay": handleOlmRelayMessage,
|
"olm/wg/relay": handleOlmRelayMessage,
|
||||||
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
||||||
"olm/ping": handleOlmPingMessage,
|
"olm/ping": handleOlmPingMessage,
|
||||||
"olm/disconnecting": handleOlmDisconnecingMessage,
|
"olm/disconnecting": handleOlmDisconnectingMessage,
|
||||||
|
"newt/disconnecting": handleNewtDisconnectingMessage,
|
||||||
"newt/ping": handleNewtPingMessage,
|
"newt/ping": handleNewtPingMessage,
|
||||||
"newt/wg/register": handleNewtRegisterMessage,
|
"newt/wg/register": handleNewtRegisterMessage,
|
||||||
"newt/wg/get-config": handleGetConfigMessage,
|
"newt/wg/get-config": handleGetConfigMessage,
|
||||||
@@ -40,4 +46,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"ws/round-trip/complete": handleRoundTripMessage
|
"ws/round-trip/complete": handleRoundTripMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
if (build != "saas") {
|
||||||
|
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||||
|
startNewtOfflineChecker(); // this is to handle the offline check for newts
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface AuthenticatedWebSocket extends WebSocket {
|
|||||||
clientType?: ClientType;
|
clientType?: ClientType;
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
isFullyConnected?: boolean;
|
isFullyConnected?: boolean;
|
||||||
pendingMessages?: Buffer[];
|
pendingMessages?: { data: Buffer; isBinary: boolean }[];
|
||||||
configVersion?: number;
|
configVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ export type MessageHandler = (
|
|||||||
// Options for sending messages with config version tracking
|
// Options for sending messages with config version tracking
|
||||||
export interface SendMessageOptions {
|
export interface SendMessageOptions {
|
||||||
incrementConfigVersion?: boolean;
|
incrementConfigVersion?: boolean;
|
||||||
|
compress?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redis message type for cross-node communication
|
// Redis message type for cross-node communication
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Router, Request, Response } from "express";
|
import { Router, Request, Response } from "express";
|
||||||
|
import zlib from "zlib";
|
||||||
import { Server as HttpServer } from "http";
|
import { Server as HttpServer } from "http";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db";
|
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
import { validateNewtSessionToken } from "@server/auth/sessions/newt";
|
||||||
@@ -116,11 +117,20 @@ const sendToClientLocal = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const messageString = JSON.stringify(messageWithVersion);
|
const messageString = JSON.stringify(messageWithVersion);
|
||||||
clients.forEach((client) => {
|
if (options.compress) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
|
||||||
client.send(messageString);
|
clients.forEach((client) => {
|
||||||
}
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
});
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(messageString);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,11 +157,22 @@ const broadcastToAllExceptLocal = async (
|
|||||||
...message,
|
...message,
|
||||||
configVersion
|
configVersion
|
||||||
};
|
};
|
||||||
clients.forEach((client) => {
|
if (options.compress) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
const compressed = zlib.gzipSync(
|
||||||
client.send(JSON.stringify(messageWithVersion));
|
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
|
||||||
}
|
);
|
||||||
});
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(compressed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
clients.forEach((client) => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(messageWithVersion));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -286,9 +307,12 @@ const setupConnection = async (
|
|||||||
clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId;
|
clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId;
|
||||||
await addClient(clientType, clientId, ws);
|
await addClient(clientType, clientId, ws);
|
||||||
|
|
||||||
ws.on("message", async (data) => {
|
ws.on("message", async (data, isBinary) => {
|
||||||
try {
|
try {
|
||||||
const message: WSMessage = JSON.parse(data.toString());
|
const messageBuffer = isBinary
|
||||||
|
? zlib.gunzipSync(data as Buffer)
|
||||||
|
: (data as Buffer);
|
||||||
|
const message: WSMessage = JSON.parse(messageBuffer.toString());
|
||||||
|
|
||||||
if (!message.type || typeof message.type !== "string") {
|
if (!message.type || typeof message.type !== "string") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -356,6 +380,31 @@ const setupConnection = async (
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket protocol-level pings from older newt clients that do
|
||||||
|
// not send application-level "newt/ping" messages. Update the site's
|
||||||
|
// online state and lastPing timestamp so the offline checker treats them
|
||||||
|
// the same as modern newt clients.
|
||||||
|
if (clientType === "newt") {
|
||||||
|
const newtClient = client as Newt;
|
||||||
|
ws.on("ping", async () => {
|
||||||
|
if (!newtClient.siteId) return;
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(sites)
|
||||||
|
.set({
|
||||||
|
online: true,
|
||||||
|
lastPing: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(sites.siteId, newtClient.siteId));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Error updating newt site online state on WS ping",
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ws.on("error", (error: Error) => {
|
ws.on("error", (error: Error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,
|
||||||
|
|||||||
@@ -35,11 +35,7 @@ import {
|
|||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
||||||
import {
|
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
||||||
Alert,
|
|
||||||
AlertTitle,
|
|
||||||
AlertDescription
|
|
||||||
} from "@app/components/ui/alert";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
@@ -69,6 +65,7 @@ type PlanOption = {
|
|||||||
price: string;
|
price: string;
|
||||||
priceDetail?: string;
|
priceDetail?: string;
|
||||||
tierType: Tier | null;
|
tierType: Tier | null;
|
||||||
|
features: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const planOptions: PlanOption[] = [
|
const planOptions: PlanOption[] = [
|
||||||
@@ -76,41 +73,87 @@ const planOptions: PlanOption[] = [
|
|||||||
id: "basic",
|
id: "basic",
|
||||||
name: "Basic",
|
name: "Basic",
|
||||||
price: "Free",
|
price: "Free",
|
||||||
tierType: null
|
tierType: null,
|
||||||
|
features: [
|
||||||
|
"Basic Pangolin features",
|
||||||
|
"Free provided domains",
|
||||||
|
"Web-based proxy resources",
|
||||||
|
"Private resources and clients",
|
||||||
|
"Peer-to-peer connections"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "home",
|
id: "home",
|
||||||
name: "Home",
|
name: "Home",
|
||||||
price: "$12.50",
|
price: "$12.50",
|
||||||
priceDetail: "/ month",
|
priceDetail: "/ month",
|
||||||
tierType: "tier1"
|
tierType: "tier1",
|
||||||
|
features: [
|
||||||
|
"Everything in Basic",
|
||||||
|
"OAuth2/OIDC, Google, & Azure SSO",
|
||||||
|
"Bring your own identity provider",
|
||||||
|
"Pangolin SSH",
|
||||||
|
"Custom branding",
|
||||||
|
"Device admin approvals"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "team",
|
id: "team",
|
||||||
name: "Team",
|
name: "Team",
|
||||||
price: "$4",
|
price: "$4",
|
||||||
priceDetail: "per user / month",
|
priceDetail: "per user / month",
|
||||||
tierType: "tier2"
|
tierType: "tier2",
|
||||||
|
features: [
|
||||||
|
"Everything in Basic",
|
||||||
|
"Custom domains",
|
||||||
|
"OAuth2/OIDC, Google, & Azure SSO",
|
||||||
|
"Access and action audit logs",
|
||||||
|
"Device posture information"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "business",
|
id: "business",
|
||||||
name: "Business",
|
name: "Business",
|
||||||
price: "$9",
|
price: "$9",
|
||||||
priceDetail: "per user / month",
|
priceDetail: "per user / month",
|
||||||
tierType: "tier3"
|
tierType: "tier3",
|
||||||
|
features: [
|
||||||
|
"Everything in Team",
|
||||||
|
"Multiple organizations (multi-tenancy)",
|
||||||
|
"Auto-provisioning via IdP",
|
||||||
|
"Pangolin SSH",
|
||||||
|
"Device approvals",
|
||||||
|
"Custom branding",
|
||||||
|
"Business support"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
name: "Enterprise",
|
name: "Enterprise",
|
||||||
price: "Custom",
|
price: "Custom",
|
||||||
tierType: null
|
tierType: null,
|
||||||
|
features: [
|
||||||
|
"Everything in Business",
|
||||||
|
"Custom limits",
|
||||||
|
"Priority support and SLA",
|
||||||
|
"Log push and export",
|
||||||
|
"Private and Gov-Cloud deployment options",
|
||||||
|
"Dedicated, premium relay/exit nodes",
|
||||||
|
"Pay by invoice "
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
// Tier limits mapping derived from limit sets
|
// Tier limits mapping derived from limit sets
|
||||||
const tierLimits: Record<
|
const tierLimits: Record<
|
||||||
Tier | "basic",
|
Tier | "basic",
|
||||||
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
{
|
||||||
|
users: number;
|
||||||
|
sites: number;
|
||||||
|
domains: number;
|
||||||
|
remoteNodes: number;
|
||||||
|
organizations: number;
|
||||||
|
}
|
||||||
> = {
|
> = {
|
||||||
basic: {
|
basic: {
|
||||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||||
@@ -463,7 +506,10 @@ export default function BillingPage() {
|
|||||||
const isProblematicState = hasProblematicSubscription();
|
const isProblematicState = hasProblematicSubscription();
|
||||||
|
|
||||||
// Get user-friendly subscription status message
|
// Get user-friendly subscription status message
|
||||||
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
const getSubscriptionStatusMessage = (): {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
} | null => {
|
||||||
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||||
|
|
||||||
const status = tierSubscription.subscription.status;
|
const status = tierSubscription.subscription.status;
|
||||||
@@ -472,22 +518,31 @@ export default function BillingPage() {
|
|||||||
case "past_due":
|
case "past_due":
|
||||||
return {
|
return {
|
||||||
title: t("billingPastDueTitle") || "Payment Past Due",
|
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||||
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
description:
|
||||||
|
t("billingPastDueDescription") ||
|
||||||
|
"Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||||
};
|
};
|
||||||
case "unpaid":
|
case "unpaid":
|
||||||
return {
|
return {
|
||||||
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||||
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
description:
|
||||||
|
t("billingUnpaidDescription") ||
|
||||||
|
"Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||||
};
|
};
|
||||||
case "incomplete":
|
case "incomplete":
|
||||||
return {
|
return {
|
||||||
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||||
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
description:
|
||||||
|
t("billingIncompleteDescription") ||
|
||||||
|
"Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||||
};
|
};
|
||||||
case "incomplete_expired":
|
case "incomplete_expired":
|
||||||
return {
|
return {
|
||||||
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
title:
|
||||||
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||||
|
description:
|
||||||
|
t("billingIncompleteExpiredDescription") ||
|
||||||
|
"Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||||
};
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
@@ -509,7 +564,11 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
if (plan.id === currentPlanId) {
|
if (plan.id === currentPlanId) {
|
||||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||||
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
if (
|
||||||
|
plan.id === "basic" &&
|
||||||
|
!hasSubscription &&
|
||||||
|
!isProblematicState
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
label: "Current Plan",
|
label: "Current Plan",
|
||||||
action: () => {},
|
action: () => {},
|
||||||
@@ -632,7 +691,9 @@ export default function BillingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check if downgrading to a tier would violate current usage limits
|
// Check if downgrading to a tier would violate current usage limits
|
||||||
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
|
const checkLimitViolations = (
|
||||||
|
targetTier: Tier | "basic"
|
||||||
|
): Array<{
|
||||||
feature: string;
|
feature: string;
|
||||||
currentUsage: number;
|
currentUsage: number;
|
||||||
newLimit: number;
|
newLimit: number;
|
||||||
@@ -687,7 +748,10 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
// Check organizations
|
// Check organizations
|
||||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||||
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
if (
|
||||||
|
limits.organizations > 0 &&
|
||||||
|
organizationsUsage > limits.organizations
|
||||||
|
) {
|
||||||
violations.push({
|
violations.push({
|
||||||
feature: "Organizations",
|
feature: "Organizations",
|
||||||
currentUsage: organizationsUsage,
|
currentUsage: organizationsUsage,
|
||||||
@@ -712,17 +776,15 @@ export default function BillingPage() {
|
|||||||
{isProblematicState && statusMessage && (
|
{isProblematicState && statusMessage && (
|
||||||
<Alert variant="destructive" className="mb-6">
|
<Alert variant="destructive" className="mb-6">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>{statusMessage.title}</AlertTitle>
|
||||||
{statusMessage.title}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{statusMessage.description}
|
{statusMessage.description}{" "}
|
||||||
{" "}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleModifySubscription}
|
onClick={handleModifySubscription}
|
||||||
className="underline font-semibold hover:no-underline"
|
className="underline font-semibold hover:no-underline"
|
||||||
>
|
>
|
||||||
{t("billingManageSubscription") || "Manage your subscription"}
|
{t("billingManageSubscription") ||
|
||||||
|
"Manage your subscription"}
|
||||||
</button>
|
</button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -772,7 +834,10 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
{isProblematicState &&
|
||||||
|
planAction.disabled &&
|
||||||
|
!isCurrentPlan &&
|
||||||
|
plan.id !== "enterprise" ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
@@ -784,18 +849,29 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={planAction.action}
|
onClick={
|
||||||
disabled={
|
planAction.action
|
||||||
isLoading || planAction.disabled
|
}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
planAction.disabled
|
||||||
|
}
|
||||||
|
loading={
|
||||||
|
isLoading &&
|
||||||
|
isCurrentPlan
|
||||||
}
|
}
|
||||||
loading={isLoading && isCurrentPlan}
|
|
||||||
>
|
>
|
||||||
{planAction.label}
|
{planAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingResolvePaymentIssue"
|
||||||
|
) ||
|
||||||
|
"Please resolve your payment issue before upgrading or downgrading"}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -809,9 +885,12 @@ export default function BillingPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={planAction.action}
|
onClick={planAction.action}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading || planAction.disabled
|
isLoading ||
|
||||||
|
planAction.disabled
|
||||||
|
}
|
||||||
|
loading={
|
||||||
|
isLoading && isCurrentPlan
|
||||||
}
|
}
|
||||||
loading={isLoading && isCurrentPlan}
|
|
||||||
>
|
>
|
||||||
{planAction.label}
|
{planAction.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -886,18 +965,38 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span className={cn(
|
<span
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
className={cn(
|
||||||
)}>
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{getLimitValue(USERS) ??
|
{getLimitValue(USERS) ??
|
||||||
t("billingUnlimited") ??
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(USERS) !== null &&
|
{getLimitValue(
|
||||||
"users"}
|
USERS
|
||||||
|
) !== null && "users"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
USERS
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
USERS
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -905,8 +1004,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(USERS) ??
|
{getLimitValue(USERS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(USERS) !== null &&
|
{getLimitValue(USERS) !==
|
||||||
"users"}
|
null && "users"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -920,18 +1019,38 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span className={cn(
|
<span
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
className={cn(
|
||||||
)}>
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{getLimitValue(SITES) ??
|
{getLimitValue(SITES) ??
|
||||||
t("billingUnlimited") ??
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(SITES) !== null &&
|
{getLimitValue(
|
||||||
"sites"}
|
SITES
|
||||||
|
) !== null && "sites"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
SITES
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
SITES
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -939,8 +1058,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(SITES) ??
|
{getLimitValue(SITES) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(SITES) !== null &&
|
{getLimitValue(SITES) !==
|
||||||
"sites"}
|
null && "sites"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -954,18 +1073,40 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span className={cn(
|
<span
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
className={cn(
|
||||||
)}>
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
{getLimitValue(DOMAINS) ??
|
)}
|
||||||
t("billingUnlimited") ??
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
DOMAINS
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(DOMAINS) !== null &&
|
{getLimitValue(
|
||||||
"domains"}
|
DOMAINS
|
||||||
|
) !== null && "domains"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
DOMAINS
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
DOMAINS
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -973,8 +1114,8 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(DOMAINS) ??
|
{getLimitValue(DOMAINS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(DOMAINS) !== null &&
|
{getLimitValue(DOMAINS) !==
|
||||||
"domains"}
|
null && "domains"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -989,18 +1130,40 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span className={cn(
|
<span
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
className={cn(
|
||||||
)}>
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
{getLimitValue(ORGINIZATIONS) ??
|
)}
|
||||||
t("billingUnlimited") ??
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
ORGINIZATIONS
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(ORGINIZATIONS) !==
|
{getLimitValue(
|
||||||
null && "orgs"}
|
ORGINIZATIONS
|
||||||
|
) !== null && "orgs"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
ORGINIZATIONS
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
ORGINIZATIONS
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
@@ -1008,8 +1171,9 @@ export default function BillingPage() {
|
|||||||
{getLimitValue(ORGINIZATIONS) ??
|
{getLimitValue(ORGINIZATIONS) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(ORGINIZATIONS) !==
|
{getLimitValue(
|
||||||
null && "orgs"}
|
ORGINIZATIONS
|
||||||
|
) !== null && "orgs"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1024,27 +1188,52 @@ export default function BillingPage() {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger className="flex items-center gap-1">
|
<TooltipTrigger className="flex items-center gap-1">
|
||||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||||
<span className={cn(
|
<span
|
||||||
"text-orange-600 dark:text-orange-400 font-medium"
|
className={cn(
|
||||||
)}>
|
"text-orange-600 dark:text-orange-400 font-medium"
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
)}
|
||||||
t("billingUnlimited") ??
|
>
|
||||||
|
{getLimitValue(
|
||||||
|
REMOTE_EXIT_NODES
|
||||||
|
) ??
|
||||||
|
t(
|
||||||
|
"billingUnlimited"
|
||||||
|
) ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
{getLimitValue(
|
||||||
null && "nodes"}
|
REMOTE_EXIT_NODES
|
||||||
|
) !== null && "nodes"}
|
||||||
</span>
|
</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}</p>
|
<p>
|
||||||
|
{t(
|
||||||
|
"billingUsageExceedsLimit",
|
||||||
|
{
|
||||||
|
current:
|
||||||
|
getUsageValue(
|
||||||
|
REMOTE_EXIT_NODES
|
||||||
|
),
|
||||||
|
limit:
|
||||||
|
getLimitValue(
|
||||||
|
REMOTE_EXIT_NODES
|
||||||
|
) ?? 0
|
||||||
|
}
|
||||||
|
) ||
|
||||||
|
`Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
{getLimitValue(
|
||||||
|
REMOTE_EXIT_NODES
|
||||||
|
) ??
|
||||||
t("billingUnlimited") ??
|
t("billingUnlimited") ??
|
||||||
"∞"}{" "}
|
"∞"}{" "}
|
||||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
{getLimitValue(
|
||||||
null && "nodes"}
|
REMOTE_EXIT_NODES
|
||||||
|
) !== null && "nodes"}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -1072,7 +1261,8 @@ export default function BillingPage() {
|
|||||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm text-muted-foreground mb-1">
|
<div className="text-sm text-muted-foreground mb-1">
|
||||||
{t("billingCurrentKeys") || "Current Keys"}
|
{t("billingCurrentKeys") ||
|
||||||
|
"Current Keys"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-3xl font-bold">
|
<span className="text-3xl font-bold">
|
||||||
@@ -1137,61 +1327,101 @@ export default function BillingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Features with check marks */}
|
||||||
|
{(() => {
|
||||||
|
const plan = planOptions.find(
|
||||||
|
(p) =>
|
||||||
|
p.tierType === pendingTier.tier ||
|
||||||
|
(pendingTier.tier === "basic" &&
|
||||||
|
p.id === "basic")
|
||||||
|
);
|
||||||
|
return plan?.features?.length ? (
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold mb-3">
|
||||||
|
{"What's included:"}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{plan.features.map(
|
||||||
|
(feature, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 text-green-600 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Limits without check marks */}
|
||||||
{tierLimits[pendingTier.tier] && (
|
{tierLimits[pendingTier.tier] && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-3">
|
<h4 className="font-semibold mb-3">
|
||||||
{t("billingPlanIncludes") ||
|
{"Up to:"}
|
||||||
"Plan Includes:"}
|
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[pendingTier.tier]
|
tierLimits[
|
||||||
.users
|
pendingTier.tier
|
||||||
|
].users
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingUsers") || "Users"}
|
{t("billingUsers") ||
|
||||||
|
"Users"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[pendingTier.tier]
|
tierLimits[
|
||||||
.sites
|
pendingTier.tier
|
||||||
|
].sites
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingSites") || "Sites"}
|
{t("billingSites") ||
|
||||||
|
"Sites"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[pendingTier.tier]
|
tierLimits[
|
||||||
.domains
|
pendingTier.tier
|
||||||
|
].domains
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingDomains") ||
|
{t("billingDomains") ||
|
||||||
"Domains"}
|
"Domains"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[pendingTier.tier]
|
tierLimits[
|
||||||
.organizations
|
pendingTier.tier
|
||||||
|
].organizations
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingOrganizations") ||
|
{t(
|
||||||
"Organizations"}
|
"billingOrganizations"
|
||||||
|
) || "Organizations"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{
|
{
|
||||||
tierLimits[pendingTier.tier]
|
tierLimits[
|
||||||
.remoteNodes
|
pendingTier.tier
|
||||||
|
].remoteNodes
|
||||||
}{" "}
|
}{" "}
|
||||||
{t("billingRemoteNodes") ||
|
{t("billingRemoteNodes") ||
|
||||||
"Remote Nodes"}
|
"Remote Nodes"}
|
||||||
@@ -1202,43 +1432,84 @@ export default function BillingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warning for limit violations when downgrading */}
|
{/* Warning for limit violations when downgrading */}
|
||||||
{pendingTier.action === "downgrade" && (() => {
|
{pendingTier.action === "downgrade" &&
|
||||||
const violations = checkLimitViolations(pendingTier.tier);
|
(() => {
|
||||||
if (violations.length > 0) {
|
const violations = checkLimitViolations(
|
||||||
return (
|
pendingTier.tier
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>
|
|
||||||
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
<p className="mb-3">
|
|
||||||
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{violations.map((violation, index) => (
|
|
||||||
<li key={index} className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{violation.feature}:</span>
|
|
||||||
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
);
|
||||||
}
|
if (violations.length > 0) {
|
||||||
return null;
|
return (
|
||||||
})()}
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{t(
|
||||||
|
"billingLimitViolationWarning"
|
||||||
|
) ||
|
||||||
|
"Usage Exceeds New Plan Limits"}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p className="mb-3">
|
||||||
|
{t(
|
||||||
|
"billingLimitViolationDescription"
|
||||||
|
) ||
|
||||||
|
"Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{violations.map(
|
||||||
|
(
|
||||||
|
violation,
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<li
|
||||||
|
key={
|
||||||
|
index
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{
|
||||||
|
violation.feature
|
||||||
|
}
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Currently
|
||||||
|
using{" "}
|
||||||
|
{
|
||||||
|
violation.currentUsage
|
||||||
|
}
|
||||||
|
,
|
||||||
|
new
|
||||||
|
limit
|
||||||
|
is{" "}
|
||||||
|
{
|
||||||
|
violation.newLimit
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Warning for feature loss when downgrading */}
|
{/* Warning for feature loss when downgrading */}
|
||||||
{pendingTier.action === "downgrade" && (
|
{pendingTier.action === "downgrade" && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
|
{t("billingFeatureLossWarning") ||
|
||||||
|
"Feature Availability Notice"}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
{t(
|
||||||
|
"billingFeatureLossDescription"
|
||||||
|
) ||
|
||||||
|
"By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default async function DomainSettingsPage({
|
|||||||
failed={domain.failed}
|
failed={domain.failed}
|
||||||
verified={domain.verified}
|
verified={domain.verified}
|
||||||
type={domain.type}
|
type={domain.type}
|
||||||
|
errorMessage={domain.errorMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@app/components/ui/tooltip";
|
} from "@app/components/ui/tooltip";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
@@ -65,6 +66,7 @@ import { build } from "@server/build";
|
|||||||
import { Resource } from "@server/db";
|
import { Resource } from "@server/db";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
import { ListTargetsResponse } from "@server/routers/target";
|
||||||
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
import { ArrayElement } from "@server/types/ArrayElement";
|
import { ArrayElement } from "@server/types/ArrayElement";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +83,7 @@ import {
|
|||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleX,
|
CircleX,
|
||||||
Info,
|
Info,
|
||||||
|
InfoIcon,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
SquareArrowOutUpRight
|
SquareArrowOutUpRight
|
||||||
@@ -210,6 +213,13 @@ export default function Page() {
|
|||||||
orgQueries.sites({ orgId: orgId as string })
|
orgQueries.sites({ orgId: orgId as string })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [remoteExitNodes, setRemoteExitNodes] = useState<
|
||||||
|
ListRemoteExitNodesResponse["remoteExitNodes"]
|
||||||
|
>([]);
|
||||||
|
const [loadingExitNodes, setLoadingExitNodes] = useState(
|
||||||
|
build === "saas"
|
||||||
|
);
|
||||||
|
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [showSnippets, setShowSnippets] = useState(false);
|
const [showSnippets, setShowSnippets] = useState(false);
|
||||||
const [niceId, setNiceId] = useState<string>("");
|
const [niceId, setNiceId] = useState<string>("");
|
||||||
@@ -224,6 +234,27 @@ export default function Page() {
|
|||||||
useState<LocalTarget | null>(null);
|
useState<LocalTarget | null>(null);
|
||||||
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (build !== "saas") return;
|
||||||
|
|
||||||
|
const fetchExitNodes = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<
|
||||||
|
AxiosResponse<ListRemoteExitNodesResponse>
|
||||||
|
>(`/org/${orgId}/remote-exit-nodes`);
|
||||||
|
if (res && res.status === 200) {
|
||||||
|
setRemoteExitNodes(res.data.data.remoteExitNodes);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch remote exit nodes:", e);
|
||||||
|
} finally {
|
||||||
|
setLoadingExitNodes(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchExitNodes();
|
||||||
|
}, [orgId]);
|
||||||
|
|
||||||
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const saved = localStorage.getItem("create-advanced-mode");
|
const saved = localStorage.getItem("create-advanced-mode");
|
||||||
@@ -289,15 +320,25 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...(!env.flags.allowRawResources
|
...(!env.flags.allowRawResources
|
||||||
? []
|
? []
|
||||||
: [
|
: build === "saas" && remoteExitNodes.length === 0
|
||||||
{
|
? []
|
||||||
id: "raw" as ResourceType,
|
: [
|
||||||
title: t("resourceRaw"),
|
{
|
||||||
description: build == "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription")
|
id: "raw" as ResourceType,
|
||||||
}
|
title: t("resourceRaw"),
|
||||||
])
|
description:
|
||||||
|
build == "saas"
|
||||||
|
? t("resourceRawDescriptionCloud")
|
||||||
|
: t("resourceRawDescription")
|
||||||
|
}
|
||||||
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// In saas mode with no exit nodes, force HTTP
|
||||||
|
const showTypeSelector =
|
||||||
|
build !== "saas" ||
|
||||||
|
(!loadingExitNodes && remoteExitNodes.length > 0);
|
||||||
|
|
||||||
const baseForm = useForm({
|
const baseForm = useForm({
|
||||||
resolver: zodResolver(baseResourceFormSchema),
|
resolver: zodResolver(baseResourceFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -559,7 +600,7 @@ export default function Page() {
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("resourceErrorCreate"),
|
title: t("resourceErrorCreate"),
|
||||||
description: t("resourceErrorCreateMessageDescription")
|
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -984,34 +1025,35 @@ export default function Page() {
|
|||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
{resourceTypes.length > 1 && (
|
{showTypeSelector &&
|
||||||
<>
|
resourceTypes.length > 1 && (
|
||||||
<div className="mb-2">
|
<>
|
||||||
<span className="text-sm font-medium">
|
<div className="mb-2">
|
||||||
{t("type")}
|
<span className="text-sm font-medium">
|
||||||
</span>
|
{t("type")}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StrategySelect
|
<StrategySelect
|
||||||
options={resourceTypes}
|
options={resourceTypes}
|
||||||
defaultValue="http"
|
defaultValue="http"
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
baseForm.setValue(
|
baseForm.setValue(
|
||||||
"http",
|
"http",
|
||||||
value === "http"
|
value === "http"
|
||||||
);
|
);
|
||||||
// Update method default when switching resource type
|
// Update method default when switching resource type
|
||||||
addTargetForm.setValue(
|
addTargetForm.setValue(
|
||||||
"method",
|
"method",
|
||||||
value === "http"
|
value === "http"
|
||||||
? "http"
|
? "http"
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
cols={2}
|
cols={2}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...baseForm}>
|
<Form {...baseForm}>
|
||||||
@@ -1067,6 +1109,9 @@ export default function Page() {
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<DomainPicker
|
<DomainPicker
|
||||||
orgId={orgId as string}
|
orgId={orgId as string}
|
||||||
|
warnOnProvidedDomain={
|
||||||
|
remoteExitNodes.length >= 1
|
||||||
|
}
|
||||||
onDomainChange={(res) => {
|
onDomainChange={(res) => {
|
||||||
if (!res) return;
|
if (!res) return;
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
"overflow-y-auto max-h-[100dvh] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
type DomainInfoCardProps = {
|
type DomainInfoCardProps = {
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DomainInfoCard({
|
export default function DomainInfoCard({
|
||||||
failed,
|
failed,
|
||||||
verified,
|
verified,
|
||||||
type
|
type,
|
||||||
|
errorMessage
|
||||||
}: DomainInfoCardProps) {
|
}: DomainInfoCardProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const env = useEnvContext();
|
const env = useEnvContext();
|
||||||
@@ -39,6 +42,7 @@ export default function DomainInfoCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<InfoSections cols={3}>
|
<InfoSections cols={3}>
|
||||||
@@ -79,5 +83,19 @@ export default function DomainInfoCard({
|
|||||||
</InfoSections>
|
</InfoSections>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
{errorMessage && (failed || !verified) && (
|
||||||
|
<Alert variant={failed ? "destructive" : "warning"}>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{failed
|
||||||
|
? t("domainErrorTitle", { fallback: "Domain Error" })
|
||||||
|
: t("domainPendingErrorTitle", { fallback: "Verification Issue" })}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="font-mono text-xs break-all">
|
||||||
|
{errorMessage}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ interface DomainPickerProps {
|
|||||||
defaultFullDomain?: string | null;
|
defaultFullDomain?: string | null;
|
||||||
defaultSubdomain?: string | null;
|
defaultSubdomain?: string | null;
|
||||||
defaultDomainId?: string | null;
|
defaultDomainId?: string | null;
|
||||||
|
warnOnProvidedDomain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DomainPicker({
|
export default function DomainPicker({
|
||||||
@@ -88,7 +89,8 @@ export default function DomainPicker({
|
|||||||
hideFreeDomain = false,
|
hideFreeDomain = false,
|
||||||
defaultSubdomain,
|
defaultSubdomain,
|
||||||
defaultFullDomain,
|
defaultFullDomain,
|
||||||
defaultDomainId
|
defaultDomainId,
|
||||||
|
warnOnProvidedDomain = false
|
||||||
}: DomainPickerProps) {
|
}: DomainPickerProps) {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
@@ -689,6 +691,14 @@ export default function DomainPicker({
|
|||||||
|
|
||||||
{showProvidedDomainSearch && (
|
{showProvidedDomainSearch && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{warnOnProvidedDomain && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{t("domainPickerRemoteExitNodeWarning")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
{isChecking && (
|
{isChecking && (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "./ui/dropdown-menu";
|
} from "./ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger
|
||||||
|
} from "./ui/tooltip";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export type DomainRow = {
|
export type DomainRow = {
|
||||||
@@ -39,6 +45,7 @@ export type DomainRow = {
|
|||||||
configManaged: boolean;
|
configManaged: boolean;
|
||||||
certResolver: string;
|
certResolver: string;
|
||||||
preferWildcardCert: boolean;
|
preferWildcardCert: boolean;
|
||||||
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -175,7 +182,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { verified, failed, type } = row.original;
|
const { verified, failed, type, errorMessage } = row.original;
|
||||||
if (verified) {
|
if (verified) {
|
||||||
return type == "wildcard" ? (
|
return type == "wildcard" ? (
|
||||||
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
<Badge variant="outlinePrimary">{t("manual")}</Badge>
|
||||||
@@ -183,12 +190,44 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
|||||||
<Badge variant="green">{t("verified")}</Badge>
|
<Badge variant="green">{t("verified")}</Badge>
|
||||||
);
|
);
|
||||||
} else if (failed) {
|
} else if (failed) {
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="red" className="cursor-help">
|
||||||
|
{t("failed", { fallback: "Failed" })}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="break-words">{errorMessage}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Badge variant="red">
|
<Badge variant="red">
|
||||||
{t("failed", { fallback: "Failed" })}
|
{t("failed", { fallback: "Failed" })}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="yellow" className="cursor-help">
|
||||||
|
{t("pending")}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<p className="break-words">{errorMessage}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <Badge variant="yellow">{t("pending")}</Badge>;
|
return <Badge variant="yellow">{t("pending")}</Badge>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => {
|
|||||||
resource.pincode ||
|
resource.pincode ||
|
||||||
resource.whitelist;
|
resource.whitelist;
|
||||||
|
|
||||||
|
const hasAnyInfo =
|
||||||
|
Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled;
|
||||||
|
|
||||||
|
if (!hasAnyInfo) return null;
|
||||||
|
|
||||||
const infoContent = (
|
const infoContent = (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Site Information */}
|
{/* Site Information */}
|
||||||
@@ -828,6 +833,12 @@ export default function MemberResourcesPortal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Destination:</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{siteResource.destination}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{siteResource.alias && (
|
{siteResource.alias && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Alias:</span>
|
<span className="font-medium">Alias:</span>
|
||||||
@@ -836,14 +847,6 @@ export default function MemberResourcesPortal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{siteResource.aliasAddress && (
|
|
||||||
<div>
|
|
||||||
<span className="font-medium">Alias Address:</span>
|
|
||||||
<span className="ml-2 text-muted-foreground">
|
|
||||||
{siteResource.aliasAddress}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Status:</span>
|
<span className="font-medium">Status:</span>
|
||||||
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
|
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const docsLinkClassName =
|
|||||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||||
const ENTERPRISE_DOCS_URL =
|
const ENTERPRISE_DOCS_URL =
|
||||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||||
|
const BOOK_A_DEMO_URL = "https://click.fossorial.io/ep922";
|
||||||
|
|
||||||
function getTierLinkRenderer(billingHref: string) {
|
function getTierLinkRenderer(billingHref: string) {
|
||||||
return function tierLinkRenderer(chunks: React.ReactNode) {
|
return function tierLinkRenderer(chunks: React.ReactNode) {
|
||||||
@@ -78,6 +79,22 @@ function getPangolinCloudLinkRenderer() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBookADemoLinkRenderer() {
|
||||||
|
return function bookADemoLinkRenderer(chunks: React.ReactNode) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={BOOK_A_DEMO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={docsLinkClassName}
|
||||||
|
>
|
||||||
|
{chunks}
|
||||||
|
<ExternalLink className="size-3.5 shrink-0" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getDocsLinkRenderer(href: string) {
|
function getDocsLinkRenderer(href: string) {
|
||||||
return function docsLinkRenderer(chunks: React.ReactNode) {
|
return function docsLinkRenderer(chunks: React.ReactNode) {
|
||||||
return (
|
return (
|
||||||
@@ -116,6 +133,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||||
|
const bookADemoLinkRenderer = getBookADemoLinkRenderer();
|
||||||
|
|
||||||
if (env.flags.disableEnterpriseFeatures) {
|
if (env.flags.disableEnterpriseFeatures) {
|
||||||
return null;
|
return null;
|
||||||
@@ -157,7 +175,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
{t.rich("licenseRequiredToUse", {
|
{t.rich("licenseRequiredToUse", {
|
||||||
enterpriseLicenseLink:
|
enterpriseLicenseLink:
|
||||||
enterpriseDocsLinkRenderer,
|
enterpriseDocsLinkRenderer,
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||||
|
bookADemoLink: bookADemoLinkRenderer
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,7 +193,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
|||||||
{t.rich("ossEnterpriseEditionRequired", {
|
{t.rich("ossEnterpriseEditionRequired", {
|
||||||
enterpriseEditionLink:
|
enterpriseEditionLink:
|
||||||
enterpriseDocsLinkRenderer,
|
enterpriseDocsLinkRenderer,
|
||||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
pangolinCloudLink: pangolinCloudLinkRenderer,
|
||||||
|
bookADemoLink: bookADemoLinkRenderer
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user