Compare commits
104 Commits
dependabot
...
private-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ae5eecb6 | ||
|
|
646e440dec | ||
|
|
03d95874e6 | ||
|
|
1b9a395432 | ||
|
|
3996e14e70 | ||
|
|
7a40084bf4 | ||
|
|
30fd48a14a | ||
|
|
5d51af4330 | ||
|
|
173a81ead8 | ||
|
|
676eacc9cf | ||
|
|
93998f9fd5 | ||
|
|
c554e69514 | ||
|
|
a6e10e55cc | ||
|
|
9cb1043545 | ||
|
|
96e33d33b0 | ||
|
|
ccc7003ac1 | ||
|
|
93cbd47b5d | ||
|
|
8b808e44b6 | ||
|
|
0644e26297 | ||
|
|
682653b977 | ||
|
|
0053cfc8fc | ||
|
|
5cb62a30cc | ||
|
|
e596a63058 | ||
|
|
3ec32afb37 | ||
|
|
0189a86757 | ||
|
|
ee32307654 | ||
|
|
2f08e6b838 | ||
|
|
c8a3fc350d | ||
|
|
dc63ef1284 | ||
|
|
92332fb02f | ||
|
|
acc6a26654 | ||
|
|
2bd4d2faaf | ||
|
|
1e77ead488 | ||
|
|
561a9ab379 | ||
|
|
c008ef7c1b | ||
|
|
02dfeed3ce | ||
|
|
34cc2e0ed1 | ||
|
|
71497a7887 | ||
|
|
aa41a63430 | ||
|
|
0db55daff6 | ||
|
|
9b271950d2 | ||
|
|
89b6b1fb56 | ||
|
|
f5d0694574 | ||
|
|
f91da2ec46 | ||
|
|
89471a0174 | ||
|
|
789b991c56 | ||
|
|
0cbcc0c29c | ||
|
|
b5e239d1ad | ||
|
|
5f79e8ebbd | ||
|
|
1564c4bee7 | ||
|
|
0cf385b718 | ||
|
|
0cb04d0290 | ||
|
|
83ecf53776 | ||
|
|
5803da4893 | ||
|
|
e118e5b047 | ||
|
|
7e4e8ea266 | ||
|
|
fc4633db91 | ||
|
|
2f386f8e47 | ||
|
|
f4ea572f6b | ||
|
|
825df7da63 | ||
|
|
cd34f0a7b0 | ||
|
|
b1b22c439a | ||
|
|
eac747849b | ||
|
|
9e50569c31 | ||
|
|
a19f0acfb9 | ||
|
|
1aedf9da0a | ||
|
|
8a47d69d0d | ||
|
|
73482c2a05 | ||
|
|
79751c208d | ||
|
|
510931e7d6 | ||
|
|
584a8e7d1d | ||
|
|
a74378e1d3 | ||
|
|
840684aeba | ||
|
|
c027c8958b | ||
|
|
a730f4da1d | ||
|
|
d73796b92e | ||
|
|
96b9123306 | ||
|
|
e4cbf088b4 | ||
|
|
333ccb8438 | ||
|
|
f57012eb90 | ||
|
|
34387d9859 | ||
|
|
eb771ceda4 | ||
|
|
80f5914fdd | ||
|
|
eaa70da4dd | ||
|
|
1efd2af44b | ||
|
|
466f137590 | ||
|
|
028df8bf27 | ||
|
|
28ef5238c9 | ||
|
|
7d3d5b2b22 | ||
|
|
81eba50c9a | ||
|
|
02033f611f | ||
|
|
1366901e24 | ||
|
|
c4f48f5748 | ||
|
|
c48bc71443 | ||
|
|
d85496453f | ||
|
|
21b91374a3 | ||
|
|
a1ce7f54a0 | ||
|
|
87524fe8ae | ||
|
|
2093bb5357 | ||
|
|
6f2e37948c | ||
|
|
b7421e47cc | ||
|
|
7cbe3d42a1 | ||
|
|
d8b511b198 | ||
|
|
102a235407 |
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
58
README.md
@@ -35,43 +35,53 @@
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.pangolin.net/careers/join-us">
|
||||
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Get started with Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||
|
||||
## Installation
|
||||
|
||||
- Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
- Get started for free with [Pangolin Cloud](https://app.pangolin.net/).
|
||||
- Or, check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to self-host Pangolin.
|
||||
- Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer.
|
||||
|
||||
<img src="public/screenshots/hero.png" />
|
||||
<img src="public/screenshots/hero.png" alt="Pangolin" width="100%" />
|
||||
|
||||
## Deployment Options
|
||||
|
||||
| <img width=500 /> | Description |
|
||||
|-----------------|--------------|
|
||||
| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. |
|
||||
| **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. |
|
||||
| **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. |
|
||||
- **Pangolin Cloud** — Fully managed service - no infrastructure required.
|
||||
- **Self-Host: Community Edition** — Free, open source, and licensed under AGPL-3.
|
||||
- **Self-Host: Enterprise Edition** — Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses making less than \$100K USD gross annual revenue.
|
||||
|
||||
## Key Features
|
||||
|
||||
| <img width=500 /> | <img width=500 /> |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||
| **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
|
||||
| **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
|
||||
### Connect remote networks with sites and NAT traversal
|
||||
|
||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||
|
||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||
|
||||
### Browser-based reverse proxy access
|
||||
|
||||
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||
|
||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||
|
||||
### Client-based private resource access
|
||||
|
||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||
|
||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||
|
||||
### Give users and roles access to resources
|
||||
|
||||
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||
|
||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
@@ -87,7 +97,7 @@ Download the Pangolin client for your platform:
|
||||
|
||||
### Sign up now
|
||||
|
||||
Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available.
|
||||
Create a free account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud.
|
||||
|
||||
### Check out the docs
|
||||
|
||||
@@ -102,7 +112,3 @@ Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License
|
||||
## Contributions
|
||||
|
||||
Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices.
|
||||
|
||||
---
|
||||
|
||||
WireGuard® is a registered trademark of Jason A. Donenfeld.
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ",
|
||||
"licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.",
|
||||
"licenseAbout": "Относно лицензите",
|
||||
"licenseBannerTitle": "Активирайте своята корпоративна лицензия",
|
||||
"licenseBannerDescription": "Отключете корпоративните функции за вашият хостинг на Pangolin. Закупете лицензионен ключ, за да активирате премиум възможности, след това го добавете по-долу.",
|
||||
"licenseBannerGetLicense": "Вземете лиценз",
|
||||
"licenseBannerViewDocs": "Преглед на документацията",
|
||||
"communityEdition": "Комюнити издание",
|
||||
"licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.",
|
||||
"licenseKeyActivated": "Лицензионният ключ е активиран",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията",
|
||||
"domainPickerProvidedDomain": "Предоставен домейн",
|
||||
"domainPickerFreeProvidedDomain": "Безплатен предоставен домейн",
|
||||
"domainPickerFreeDomainsPaidFeature": "Предоставените домейни са платена функция. Абонирайте се, за да получите домейн, включен във вашия план - няма нужда да използвате вашия собствен.",
|
||||
"domainPickerVerified": "Проверено",
|
||||
"domainPickerUnverified": "Непроверено",
|
||||
"domainPickerManual": "Ръчно",
|
||||
"domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.",
|
||||
"domainPickerError": "Грешка",
|
||||
"domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč",
|
||||
"licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.",
|
||||
"licenseAbout": "O licencích",
|
||||
"licenseBannerTitle": "Aktivovat vaši firemní licenci",
|
||||
"licenseBannerDescription": "Odemkněte firemní funkce pro vaši samohostovanou instanci Pangolin. Zakupte si licenční klíč pro aktivaci prémiových možností a poté jej přidejte níže.",
|
||||
"licenseBannerGetLicense": "Zakoupit licenci",
|
||||
"licenseBannerViewDocs": "Zobrazit dokumentaci",
|
||||
"communityEdition": "Komunitní edice",
|
||||
"licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.",
|
||||
"licenseKeyActivated": "Licenční klíč aktivován",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace",
|
||||
"domainPickerProvidedDomain": "Poskytnutá doména",
|
||||
"domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména",
|
||||
"domainPickerFreeDomainsPaidFeature": "Poskytnuté domény jsou placenou funkcí. Předplaťte si plán, abyste získali doménu zahrnutou v plánu – nemusíte si přinést vlastní.",
|
||||
"domainPickerVerified": "Ověřeno",
|
||||
"domainPickerUnverified": "Neověřeno",
|
||||
"domainPickerManual": "Ruční nastavení",
|
||||
"domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.",
|
||||
"domainPickerError": "Chyba",
|
||||
"domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels",
|
||||
"licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.",
|
||||
"licenseAbout": "Über Lizenzierung",
|
||||
"licenseBannerTitle": "Aktivieren Sie Ihre Enterprise-Lizenz",
|
||||
"licenseBannerDescription": "Schalten Sie Unternehmensfunktionen für Ihre selbstgehostete Pangolin-Instanz frei. Kaufen Sie einen Lizenzschlüssel, um Premium-Funktionen zu aktivieren, und fügen Sie ihn dann unten hinzu.",
|
||||
"licenseBannerGetLicense": "Lizenz erhalten",
|
||||
"licenseBannerViewDocs": "Dokumentation anzeigen",
|
||||
"communityEdition": "Community-Edition",
|
||||
"licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.",
|
||||
"licenseKeyActivated": "Lizenzschlüssel aktiviert",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation",
|
||||
"domainPickerProvidedDomain": "Angegebene Domain",
|
||||
"domainPickerFreeProvidedDomain": "Kostenlose Domain",
|
||||
"domainPickerFreeDomainsPaidFeature": "Bereitgestellte Domains sind ein kostenpflichtiges Feature. Abonnieren Sie, um eine Domain in Ihrem Tarif zu erhalten – keine Notwendigkeit, Ihre eigene mitzubringen.",
|
||||
"domainPickerVerified": "Verifiziert",
|
||||
"domainPickerUnverified": "Nicht verifiziert",
|
||||
"domainPickerManual": "Manuell",
|
||||
"domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.",
|
||||
"domainPickerError": "Fehler",
|
||||
"domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Failed to activate license key",
|
||||
"licenseErrorKeyActivateDescription": "An error occurred while activating the license key.",
|
||||
"licenseAbout": "About Licensing",
|
||||
"licenseBannerTitle": "Enable Your Enterprise License",
|
||||
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
|
||||
"licenseBannerGetLicense": "Get a License",
|
||||
"licenseBannerViewDocs": "View Documentation",
|
||||
"communityEdition": "Community Edition",
|
||||
"licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.",
|
||||
"licenseKeyActivated": "License key activated",
|
||||
@@ -1817,6 +1821,11 @@
|
||||
"editInternalResourceDialogModePort": "Port",
|
||||
"editInternalResourceDialogModeHost": "Host",
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogScheme": "Scheme",
|
||||
"editInternalResourceDialogEnableSsl": "Enable SSL",
|
||||
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||
"editInternalResourceDialogDestination": "Destination",
|
||||
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
||||
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
@@ -1832,6 +1841,7 @@
|
||||
"createInternalResourceDialogName": "Name",
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
"selectSite": "Select site...",
|
||||
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
||||
"noSitesFound": "No sites found.",
|
||||
"createInternalResourceDialogProtocol": "Protocol",
|
||||
"createInternalResourceDialogTcp": "TCP",
|
||||
@@ -1860,11 +1870,19 @@
|
||||
"createInternalResourceDialogModePort": "Port",
|
||||
"createInternalResourceDialogModeHost": "Host",
|
||||
"createInternalResourceDialogModeCidr": "CIDR",
|
||||
"createInternalResourceDialogModeHttp": "HTTP",
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable SSL",
|
||||
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||
"createInternalResourceDialogDestination": "Destination",
|
||||
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
||||
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
||||
"createInternalResourceDialogAlias": "Alias",
|
||||
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
||||
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
||||
"siteConfiguration": "Configuration",
|
||||
"siteAcceptClientConnections": "Accept Client Connections",
|
||||
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
|
||||
@@ -2113,9 +2131,11 @@
|
||||
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
||||
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
@@ -2422,6 +2442,7 @@
|
||||
"validPassword": "Valid Password",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"connectedClient": "Connected Client",
|
||||
"resourceBlocked": "Resource Blocked",
|
||||
"droppedByRule": "Dropped by Rule",
|
||||
"noSessions": "No Sessions",
|
||||
@@ -2659,8 +2680,12 @@
|
||||
"editInternalResourceDialogAddUsers": "Add Users",
|
||||
"editInternalResourceDialogAddClients": "Add Clients",
|
||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
|
||||
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
|
||||
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||
"editInternalResourceDialogTcp": "TCP",
|
||||
"editInternalResourceDialogUdp": "UDP",
|
||||
"editInternalResourceDialogIcmp": "ICMP",
|
||||
@@ -2699,6 +2724,8 @@
|
||||
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
||||
"maintenancePageMessageDescription": "Detailed message explaining the maintenance",
|
||||
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
|
||||
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
|
||||
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
|
||||
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
|
||||
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
|
||||
"editDomain": "Edit Domain",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Error al activar la clave de licencia",
|
||||
"licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.",
|
||||
"licenseAbout": "Acerca de la licencia",
|
||||
"licenseBannerTitle": "Habilitar su Licencia Enterprise",
|
||||
"licenseBannerDescription": "Desbloquea funciones empresariales para tu instancia autohospedada de Pangolin. Compra una clave de licencia para activar capacidades premium, luego agréguela a continuación.",
|
||||
"licenseBannerGetLicense": "Obtener una Licencia",
|
||||
"licenseBannerViewDocs": "Ver Documentación",
|
||||
"communityEdition": "Edición comunitaria",
|
||||
"licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.",
|
||||
"licenseKeyActivated": "Clave de licencia activada",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización",
|
||||
"domainPickerProvidedDomain": "Dominio proporcionado",
|
||||
"domainPickerFreeProvidedDomain": "Dominio proporcionado gratis",
|
||||
"domainPickerFreeDomainsPaidFeature": "Los dominios proporcionados son una función de pago. Suscríbete para obtener un dominio incluido con tu plan — no necesitas traer el tuyo propio.",
|
||||
"domainPickerVerified": "Verificado",
|
||||
"domainPickerUnverified": "Sin verificar",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Échec de l'activation de la clé de licence",
|
||||
"licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.",
|
||||
"licenseAbout": "À propos de la licence",
|
||||
"licenseBannerTitle": "Activer Votre Licence Entreprise",
|
||||
"licenseBannerDescription": "Débloquez les fonctionnalités d'entreprise pour votre instance autohébergée de Pangolin. Achetez une clé de licence pour activer les capacités premium, puis ajoutez-la ci-dessous.",
|
||||
"licenseBannerGetLicense": "Obtenez une Licence",
|
||||
"licenseBannerViewDocs": "Afficher la Documentation",
|
||||
"communityEdition": "Edition Communautaire",
|
||||
"licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.",
|
||||
"licenseKeyActivated": "Clé de licence activée",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation",
|
||||
"domainPickerProvidedDomain": "Domaine fourni",
|
||||
"domainPickerFreeProvidedDomain": "Domaine fourni gratuitement",
|
||||
"domainPickerFreeDomainsPaidFeature": "Les domaines fournis sont une fonctionnalité payante. Abonnez-vous pour obtenir un domaine inclus avec votre plan — plus besoin de fournir le vôtre.",
|
||||
"domainPickerVerified": "Vérifié",
|
||||
"domainPickerUnverified": "Non vérifié",
|
||||
"domainPickerManual": "Manuel",
|
||||
"domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.",
|
||||
"domainPickerError": "Erreur",
|
||||
"domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"setupCreate": "Creare l'organizzazione, il sito e le risorse",
|
||||
"headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
|
||||
"headerAuthCompatibilityInfo": "Abilita questa funzionalità per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.",
|
||||
"headerAuthCompatibility": "Compatibilità estesa",
|
||||
"setupNewOrg": "Nuova Organizzazione",
|
||||
"setupCreateOrg": "Crea Organizzazione",
|
||||
"setupCreateResources": "Crea Risorse",
|
||||
"setupOrgName": "Nome Dell'Organizzazione",
|
||||
"setupOrgName": "Nome dell'Organizzazione",
|
||||
"orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.",
|
||||
"orgId": "Id Organizzazione",
|
||||
"setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.",
|
||||
"setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.",
|
||||
"componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.",
|
||||
"componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.",
|
||||
"welcome": "Benvenuti a Pangolin",
|
||||
"welcomeTo": "Benvenuto a",
|
||||
"welcome": "Benvenuto su Pangolin!",
|
||||
"welcomeTo": "Benvenuto su Pangolin!",
|
||||
"componentsCreateOrg": "Crea un'organizzazione",
|
||||
"componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.",
|
||||
"componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.",
|
||||
@@ -27,7 +27,7 @@
|
||||
"inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.",
|
||||
"inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.",
|
||||
"inviteCreateUser": "Si prega di creare un account prima.",
|
||||
"goHome": "Vai A Home",
|
||||
"goHome": "Vai alla Home",
|
||||
"inviteLogInOtherUser": "Accedi come utente diverso",
|
||||
"createAnAccount": "Crea un account",
|
||||
"inviteNotAccepted": "Invito Non Accettato",
|
||||
@@ -51,7 +51,7 @@
|
||||
"edit": "Modifica",
|
||||
"siteConfirmDelete": "Conferma Eliminazione Sito",
|
||||
"siteDelete": "Elimina Sito",
|
||||
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.",
|
||||
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
|
||||
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
|
||||
"siteManageSites": "Gestisci Siti",
|
||||
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
|
||||
@@ -75,9 +75,9 @@
|
||||
"siteLoadWGConfig": "Caricamento configurazione WireGuard...",
|
||||
"siteDocker": "Espandi per i dettagli di distribuzione Docker",
|
||||
"toggle": "Attiva/disattiva",
|
||||
"dockerCompose": "Composizione Docker",
|
||||
"dockerCompose": "Docker Compose",
|
||||
"dockerRun": "Corsa Docker",
|
||||
"siteLearnLocal": "I siti locali non tunnel, saperne di più",
|
||||
"siteLearnLocal": "I siti locali non effettuano il tunnel, per saperne di più",
|
||||
"siteConfirmCopy": "Ho copiato la configurazione",
|
||||
"searchSitesProgress": "Cerca siti...",
|
||||
"siteAdd": "Aggiungi Sito",
|
||||
@@ -88,29 +88,29 @@
|
||||
"operatingSystem": "Sistema Operativo",
|
||||
"commands": "Comandi",
|
||||
"recommended": "Consigliato",
|
||||
"siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
|
||||
"siteNewtDescription": "Per la migliore esperienza utente utilizzare Newt, che usa WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.",
|
||||
"siteRunsInDocker": "Esegue nel Docker",
|
||||
"siteRunsInShell": "Esegue in shell su macOS, Linux e Windows",
|
||||
"siteErrorDelete": "Errore nell'eliminare il sito",
|
||||
"siteErrorDelete": "Errore nella eliminazione del sito",
|
||||
"siteErrorUpdate": "Impossibile aggiornare il sito",
|
||||
"siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.",
|
||||
"siteUpdated": "Sito aggiornato",
|
||||
"siteUpdatedDescription": "Il sito è stato aggiornato.",
|
||||
"siteGeneralDescription": "Configura le impostazioni generali per questo sito",
|
||||
"siteSettingDescription": "Configura le impostazioni del sito",
|
||||
"siteSetting": "Impostazioni {siteName}",
|
||||
"siteSetting": "Impostazioni del sito {siteName}",
|
||||
"siteNewtTunnel": "Nuovo Sito (Consigliato)",
|
||||
"siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.",
|
||||
"siteWg": "WireGuard Base",
|
||||
"siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI",
|
||||
"siteWgDescription": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteWgDescriptionSaas": "Usa un qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.",
|
||||
"siteLocalDescription": "Solo risorse locali. Nessun tunneling.",
|
||||
"siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.",
|
||||
"siteSeeAll": "Vedi Tutti I Siti",
|
||||
"siteTunnelDescription": "Determinare come si desidera connettersi al sito",
|
||||
"siteTunnelDescription": "Selezionare la modalità con la quale si desidera connettersi al sito",
|
||||
"siteNewtCredentials": "Credenziali",
|
||||
"siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server",
|
||||
"remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server",
|
||||
"siteNewtCredentialsDescription": "Questo è come il sito si autenticherà con il server",
|
||||
"remoteNodeCredentialsDescription": "Questo è il modo in cui il nodo remoto si autenticherà con il server",
|
||||
"siteCredentialsSave": "Salva le credenziali",
|
||||
"siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.",
|
||||
"siteInfo": "Informazioni Sito",
|
||||
@@ -140,8 +140,8 @@
|
||||
"shareCreateDescription": "Chiunque con questo link può accedere alla risorsa",
|
||||
"shareTitleOptional": "Titolo (facoltativo)",
|
||||
"expireIn": "Scadenza In",
|
||||
"neverExpire": "Mai scadere",
|
||||
"shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
|
||||
"neverExpire": "Nessuna scadenza",
|
||||
"shareExpireDescription": "Il tempo di scadenza indica per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.",
|
||||
"shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.",
|
||||
"shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.",
|
||||
"shareTokenUsage": "Vedi Utilizzo Token Di Accesso",
|
||||
@@ -161,9 +161,9 @@
|
||||
"never": "Mai",
|
||||
"shareErrorSelectResource": "Seleziona una risorsa",
|
||||
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
|
||||
"proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web",
|
||||
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
|
||||
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
|
||||
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
||||
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
||||
"clientResourceTitle": "Gestisci Risorse Private",
|
||||
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
|
||||
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
|
||||
@@ -174,12 +174,12 @@
|
||||
"authentication": "Autenticazione",
|
||||
"protected": "Protetto",
|
||||
"notProtected": "Non Protetto",
|
||||
"resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.",
|
||||
"resourceMessageRemove": "Una volta rimossa la risorsa non sarà più accessibile. Tutti gli oggetti target associati alla risorsa saranno rimossi.",
|
||||
"resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?",
|
||||
"resourceHTTP": "Risorsa HTTPS",
|
||||
"resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.",
|
||||
"resourceRaw": "Risorsa Raw TCP/UDP",
|
||||
"resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.",
|
||||
"resourceRawDescription": "Richieste proxy su TCP/UDP raw utilizzando un numero di porta.",
|
||||
"resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.",
|
||||
"resourceCreate": "Crea Risorsa",
|
||||
"resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa",
|
||||
@@ -192,7 +192,7 @@
|
||||
"selectCountry": "Seleziona paese",
|
||||
"searchCountries": "Cerca paesi...",
|
||||
"noCountryFound": "Nessun paese trovato.",
|
||||
"siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.",
|
||||
"siteSelectionDescription": "Questo sito fornirà connettività all'oggetto target.",
|
||||
"resourceType": "Tipo Di Risorsa",
|
||||
"resourceTypeDescription": "Determinare come accedere alla risorsa",
|
||||
"resourceHTTPSSettings": "Impostazioni HTTPS",
|
||||
@@ -206,13 +206,13 @@
|
||||
"protocol": "Protocollo",
|
||||
"protocolSelect": "Seleziona un protocollo",
|
||||
"resourcePortNumber": "Numero Porta",
|
||||
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.",
|
||||
"resourcePortNumberDescription": "Il numero di porta esterna per le richieste proxy.",
|
||||
"back": "Indietro",
|
||||
"cancel": "Annulla",
|
||||
"resourceConfig": "Snippet Di Configurazione",
|
||||
"resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP",
|
||||
"resourceAddEntrypoints": "Traefik: Aggiungi Ingresso",
|
||||
"resourceExposePorts": "Gerbil: espone le porte in Docker componi",
|
||||
"resourceAddEntrypoints": "Traefik: Aggiungi Entrypoint",
|
||||
"resourceExposePorts": "Gerbil: espone le porte in Docker Compose",
|
||||
"resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP",
|
||||
"resourceBack": "Torna alle risorse",
|
||||
"resourceGoTo": "Vai alla Risorsa",
|
||||
@@ -228,7 +228,7 @@
|
||||
"rules": "Regole",
|
||||
"resourceSettingDescription": "Configura le impostazioni sulla risorsa",
|
||||
"resourceSetting": "Impostazioni {resourceName}",
|
||||
"alwaysAllow": "Autenticazione Bypass",
|
||||
"alwaysAllow": "Bypass Autenticazione",
|
||||
"alwaysDeny": "Blocca Accesso",
|
||||
"passToAuth": "Passa all'autenticazione",
|
||||
"orgSettingsDescription": "Configura le impostazioni dell'organizzazione",
|
||||
@@ -237,11 +237,11 @@
|
||||
"saveGeneralSettings": "Salva Impostazioni Generali",
|
||||
"saveSettings": "Salva Impostazioni",
|
||||
"orgDangerZone": "Zona Pericolosa",
|
||||
"orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.",
|
||||
"orgDangerZoneDescription": "Una volta che si elimina questa org non sarà possibile tornare indietro, assicurarsi quindi di essere certi della decisione.",
|
||||
"orgDelete": "Elimina Organizzazione",
|
||||
"orgDeleteConfirm": "Conferma Elimina Organizzazione",
|
||||
"orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.",
|
||||
"orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.",
|
||||
"orgMessageConfirm": "Per confermare digita il nome dell'organizzazione qui sotto.",
|
||||
"orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?",
|
||||
"orgUpdated": "Organizzazione aggiornata",
|
||||
"orgUpdatedDescription": "L'organizzazione è stata aggiornata.",
|
||||
@@ -254,10 +254,10 @@
|
||||
"orgDeleted": "Organizzazione eliminata",
|
||||
"orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.",
|
||||
"deleteAccount": "Elimina Account",
|
||||
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
|
||||
"deleteAccountButton": "Elimina Account",
|
||||
"deleteAccountConfirmTitle": "Elimina Account",
|
||||
"deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.",
|
||||
"deleteAccountConfirmMessage": "Questa operazione cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questa operazione non può essere annullata.",
|
||||
"deleteAccountConfirmString": "elimina account",
|
||||
"deleteAccountSuccess": "Account Eliminato",
|
||||
"deleteAccountSuccessMessage": "Il tuo account è stato eliminato.",
|
||||
@@ -272,7 +272,7 @@
|
||||
"accessUserCreate": "Crea Utente",
|
||||
"accessUserRemove": "Rimuovi Utente",
|
||||
"username": "Nome utente",
|
||||
"identityProvider": "Provider Di Identità",
|
||||
"identityProvider": "Provider Identità",
|
||||
"role": "Ruolo",
|
||||
"nameRequired": "Il nome è obbligatorio",
|
||||
"accessRolesManage": "Gestisci Ruoli",
|
||||
@@ -328,8 +328,8 @@
|
||||
"apiKeysDelete": "Elimina Chiave API",
|
||||
"apiKeysManage": "Gestisci Chiavi API",
|
||||
"apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione",
|
||||
"provisioningKeysTitle": "Chiave Di Provvedimento",
|
||||
"provisioningKeysManage": "Gestisci Chiavi Di Provvedimento",
|
||||
"provisioningKeysTitle": "Chiave di provisioning",
|
||||
"provisioningKeysManage": "Gestisci Chiavi di provisioning",
|
||||
"provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.",
|
||||
"provisioningManage": "Accantonamento",
|
||||
"provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.",
|
||||
@@ -337,25 +337,25 @@
|
||||
"siteApproveSuccess": "Sito approvato con successo",
|
||||
"siteApproveError": "Errore nell'approvazione del sito",
|
||||
"provisioningKeys": "Chiavi Di Provvedimento",
|
||||
"searchProvisioningKeys": "Cerca i tasti di provisioning ...",
|
||||
"provisioningKeysAdd": "Genera Chiave Di Provvedimento",
|
||||
"provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning",
|
||||
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning",
|
||||
"searchProvisioningKeys": "Cerca le chiavi di provisioning...",
|
||||
"provisioningKeysAdd": "Genera Chiave di provisioning",
|
||||
"provisioningKeysErrorDelete": "Errore nell'eliminazione della chiave di provisioning",
|
||||
"provisioningKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave di provisioning",
|
||||
"provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?",
|
||||
"provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.",
|
||||
"provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria",
|
||||
"provisioningKeysDeleteConfirm": "Conferma Eliminazione della chiave di provisioning",
|
||||
"provisioningKeysDelete": "Elimina chiave di provisioning",
|
||||
"provisioningKeysCreate": "Genera Chiave Di Provvedimento",
|
||||
"provisioningKeysCreate": "Genera Chiave di provisioning",
|
||||
"provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione",
|
||||
"provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning",
|
||||
"provisioningKeysSave": "Salva la chiave di provisioning",
|
||||
"provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.",
|
||||
"provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning",
|
||||
"provisioningKeysList": "Nuova chiave di provisioning",
|
||||
"provisioningKeysMaxBatchSize": "Dimensione massima lotto",
|
||||
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)",
|
||||
"provisioningKeysMaxBatchSize": "Dimensione massima batch",
|
||||
"provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del batch (nessun limite)",
|
||||
"provisioningKeysMaxBatchUnlimited": "Illimitato",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (1–1.000.000).",
|
||||
"provisioningKeysMaxBatchSizeInvalid": "Inserisci una dimensione massima valida del batch (1–1.000.000).",
|
||||
"provisioningKeysValidUntil": "Valido fino al",
|
||||
"provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.",
|
||||
"provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.",
|
||||
@@ -363,14 +363,14 @@
|
||||
"provisioningKeysLastUsed": "Ultimo utilizzo",
|
||||
"provisioningKeysNoExpiry": "Nessuna scadenza",
|
||||
"provisioningKeysNeverUsed": "Mai",
|
||||
"provisioningKeysEdit": "Modifica Chiave Di Provvedimento",
|
||||
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.",
|
||||
"provisioningKeysEdit": "Modifica Chiave di provisioning",
|
||||
"provisioningKeysEditDescription": "Aggiorna la dimensione massima del batch e il tempo di scadenza per questa chiave.",
|
||||
"provisioningKeysApproveNewSites": "Approva nuovi siti",
|
||||
"provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.",
|
||||
"provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning",
|
||||
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata",
|
||||
"provisioningKeysUpdated": "Chiave di provisioning aggiornata",
|
||||
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
|
||||
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito",
|
||||
"provisioningKeysBannerTitle": "Chiavi di provisioning del Sito",
|
||||
"provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
|
||||
"provisioningKeysBannerButtonText": "Scopri di più",
|
||||
"pendingSitesBannerTitle": "Siti In Attesa",
|
||||
@@ -386,7 +386,7 @@
|
||||
"userErrorDelete": "Errore nell'eliminare l'utente",
|
||||
"userDeleteConfirm": "Conferma Eliminazione Utente",
|
||||
"userDeleteServer": "Elimina utente dal server",
|
||||
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.",
|
||||
"userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni e verrà completamente rimosso dal server.",
|
||||
"userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?",
|
||||
"licenseKey": "Chiave Di Licenza",
|
||||
"valid": "Valido",
|
||||
@@ -404,9 +404,13 @@
|
||||
"licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.",
|
||||
"licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita",
|
||||
"licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.",
|
||||
"licenseAbout": "Informazioni Su Licenze",
|
||||
"licenseAbout": "Informazioni sul Licensing",
|
||||
"licenseBannerTitle": "Attiva la tua Licenza Enterprise",
|
||||
"licenseBannerDescription": "Sblocca le funzionalità enterprise per la tua istanza Pangolin auto-ospitata. Acquista una chiave di licenza per attivare le capacità premium e poi aggiungila qui sotto.",
|
||||
"licenseBannerGetLicense": "Ottieni una Licenza",
|
||||
"licenseBannerViewDocs": "Visualizza Documentazione",
|
||||
"communityEdition": "Edizione Community",
|
||||
"licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
|
||||
"licenseAboutDescription": "Questa sezione è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.",
|
||||
"licenseKeyActivated": "Chiave di licenza attivata",
|
||||
"licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.",
|
||||
"licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza",
|
||||
@@ -429,7 +433,7 @@
|
||||
"licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.",
|
||||
"licensedNot": "Non Licenziato",
|
||||
"hostId": "ID Host",
|
||||
"licenseReckeckAll": "Ricontrolla Tutte Le Tasti",
|
||||
"licenseReckeckAll": "Ricontrolla Tutte le chiavi",
|
||||
"licenseSiteUsage": "Utilizzo Siti",
|
||||
"licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.",
|
||||
"licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.",
|
||||
@@ -480,7 +484,7 @@
|
||||
"userOrgRemoved": "Utente rimosso",
|
||||
"userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.",
|
||||
"userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?",
|
||||
"userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
|
||||
"userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
|
||||
"userRemoveOrgConfirm": "Conferma Rimozione Utente",
|
||||
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
|
||||
"users": "Utenti",
|
||||
@@ -532,13 +536,13 @@
|
||||
"approve": "Approva",
|
||||
"approved": "Approvato",
|
||||
"denied": "Negato",
|
||||
"deniedApproval": "Omologazione Negata",
|
||||
"deniedApproval": "Approvazione Negata",
|
||||
"all": "Tutti",
|
||||
"deny": "Nega",
|
||||
"viewDetails": "Visualizza Dettagli",
|
||||
"requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo",
|
||||
"resetFilters": "Ripristina Filtri",
|
||||
"totalBlocked": "Richieste Bloccate Da Pangolino",
|
||||
"totalBlocked": "Richieste Bloccate Da Pangolin",
|
||||
"totalRequests": "Totale Richieste",
|
||||
"requestsByCountry": "Richieste Per Paese",
|
||||
"requestsByDay": "Richieste Per Giorno",
|
||||
@@ -546,7 +550,7 @@
|
||||
"allowed": "Consentito",
|
||||
"topCountries": "Paesi Principali",
|
||||
"accessRoleSelect": "Seleziona ruolo",
|
||||
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.",
|
||||
"inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. L'utente deve accedere al link per accettare l'invito.",
|
||||
"inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.",
|
||||
"inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.",
|
||||
"idpTitle": "Informazioni Generali",
|
||||
@@ -562,7 +566,7 @@
|
||||
"userSaved": "Utente salvato",
|
||||
"userSavedDescription": "L'utente è stato aggiornato.",
|
||||
"autoProvisioned": "Auto Provisioned",
|
||||
"autoProvisionSettings": "Impostazioni Automatiche Di Fornitura",
|
||||
"autoProvisionSettings": "Impostazioni Automatiche di provisioning",
|
||||
"autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità",
|
||||
"accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione",
|
||||
"accessControlsSubmit": "Salva Controlli di Accesso",
|
||||
@@ -576,9 +580,9 @@
|
||||
"proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.",
|
||||
"proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.",
|
||||
"proxyEnableSSL": "Abilita SSL",
|
||||
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.",
|
||||
"proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alle risorse interne target.",
|
||||
"target": "Target",
|
||||
"configureTarget": "Configura Obiettivi",
|
||||
"configureTarget": "Configura Risorse Interne",
|
||||
"targetErrorFetch": "Impossibile recuperare i target",
|
||||
"targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target",
|
||||
"siteErrorFetch": "Impossibile recuperare la risorsa",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione",
|
||||
"domainPickerProvidedDomain": "Dominio Fornito",
|
||||
"domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito",
|
||||
"domainPickerFreeDomainsPaidFeature": "I domini forniti sono una funzionalità a pagamento. Abbonati per ricevere un dominio incluso con il tuo piano — non è necessario portare il proprio.",
|
||||
"domainPickerVerified": "Verificato",
|
||||
"domainPickerUnverified": "Non Verificato",
|
||||
"domainPickerManual": "Manuale",
|
||||
"domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.",
|
||||
"domainPickerError": "Errore",
|
||||
"domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.",
|
||||
"licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다",
|
||||
"licenseAbout": "라이센스에 대한 정보",
|
||||
"licenseBannerTitle": "기업 라이선스 활성화",
|
||||
"licenseBannerDescription": "자체 호스팅된 Pangolin 인스턴스에서 기업 기능을 잠금 해제하십시오. 라이선스 키를 구입하여 프리미엄 기능을 활성화하고 아래에 추가하십시오.",
|
||||
"licenseBannerGetLicense": "라이선스 획득",
|
||||
"licenseBannerViewDocs": "문서 보기",
|
||||
"communityEdition": "커뮤니티 에디션",
|
||||
"licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.",
|
||||
"licenseKeyActivated": "라이센스 키가 활성화되었습니다",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.",
|
||||
"domainPickerProvidedDomain": "제공된 도메인",
|
||||
"domainPickerFreeProvidedDomain": "무료 제공된 도메인",
|
||||
"domainPickerFreeDomainsPaidFeature": "제공된 도메인은 유료 기능입니다. 요금제에 도메인이 포함되도록 구독하세요. — 별도로 도메인을 준비할 필요 없습니다.",
|
||||
"domainPickerVerified": "검증됨",
|
||||
"domainPickerUnverified": "검증되지 않음",
|
||||
"domainPickerManual": "수동",
|
||||
"domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.",
|
||||
"domainPickerError": "오류",
|
||||
"domainPickerErrorLoadDomains": "조직 도메인 로드 실패",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet",
|
||||
"licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.",
|
||||
"licenseAbout": "Om Lisensiering",
|
||||
"licenseBannerTitle": "Aktiver din bedriftslisens",
|
||||
"licenseBannerDescription": "Lås opp bedriftsfunksjoner for din egenvertede Pangolin-instans. Kjøp en lisensnøkkel for å aktivere premium-funksjoner og legg den inn nedenfor.",
|
||||
"licenseBannerGetLicense": "Få en lisens",
|
||||
"licenseBannerViewDocs": "Vis dokumentasjon",
|
||||
"communityEdition": "Fellesskapsutgave",
|
||||
"licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.",
|
||||
"licenseKeyActivated": "Lisensnøkkel aktivert",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside",
|
||||
"domainPickerProvidedDomain": "Gitt domene",
|
||||
"domainPickerFreeProvidedDomain": "Gratis oppgitt domene",
|
||||
"domainPickerFreeDomainsPaidFeature": "Angitte domener er en betalingsfunksjon. Abonner for å få et domene inkludert i din plan – ingen behov for å ta med ditt eget.",
|
||||
"domainPickerVerified": "Bekreftet",
|
||||
"domainPickerUnverified": "Uverifisert",
|
||||
"domainPickerManual": "Manuell",
|
||||
"domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.",
|
||||
"domainPickerError": "Feil",
|
||||
"domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Licentiesleutel activeren mislukt",
|
||||
"licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.",
|
||||
"licenseAbout": "Over licenties",
|
||||
"licenseBannerTitle": "Activeer Uw Enterprise Licentie",
|
||||
"licenseBannerDescription": "Ontgrendel enterprise-functies voor uw zelf-gehoste Pangolin-instantie. Koop een licentiesleutel om premium mogelijkheden te activeren, voeg deze vervolgens hieronder toe.",
|
||||
"licenseBannerGetLicense": "Koop een Licentie",
|
||||
"licenseBannerViewDocs": "Bekijk Documentatie",
|
||||
"communityEdition": "Community editie",
|
||||
"licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.",
|
||||
"licenseKeyActivated": "Licentiesleutel geactiveerd",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie",
|
||||
"domainPickerProvidedDomain": "Opgegeven domein",
|
||||
"domainPickerFreeProvidedDomain": "Gratis verstrekt domein",
|
||||
"domainPickerFreeDomainsPaidFeature": "Geleverde domeinen zijn een betaalde functie. Abonneer je om een domein bij je plan te krijgen — je hoeft er zelf geen mee te brengen.",
|
||||
"domainPickerVerified": "Geverifieerd",
|
||||
"domainPickerUnverified": "Ongeverifieerd",
|
||||
"domainPickerManual": "Handleiding",
|
||||
"domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.",
|
||||
"domainPickerError": "Foutmelding",
|
||||
"domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji",
|
||||
"licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.",
|
||||
"licenseAbout": "O licencjonowaniu",
|
||||
"licenseBannerTitle": "Aktywuj swoją licencję Enterprise",
|
||||
"licenseBannerDescription": "Odblokuj funkcje korporacyjne dla swojego autonomicznego wdrożenia Pangolin. Kup klucz licencyjny, aby aktywować możliwości premium, a następnie wprowadź go poniżej.",
|
||||
"licenseBannerGetLicense": "Uzyskaj licencję",
|
||||
"licenseBannerViewDocs": "Zobacz dokumentację",
|
||||
"communityEdition": "Edycja Społecznościowa",
|
||||
"licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.",
|
||||
"licenseKeyActivated": "Klucz licencyjny aktywowany",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji",
|
||||
"domainPickerProvidedDomain": "Dostarczona domena",
|
||||
"domainPickerFreeProvidedDomain": "Darmowa oferowana domena",
|
||||
"domainPickerFreeDomainsPaidFeature": "Dostarczane domeny to funkcja płatna. Subskrybuj, aby uzyskać domenę w ramach swojego planu — nie ma potrzeby przynoszenia własnej.",
|
||||
"domainPickerVerified": "Zweryfikowano",
|
||||
"domainPickerUnverified": "Niezweryfikowane",
|
||||
"domainPickerManual": "Podręcznik",
|
||||
"domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.",
|
||||
"domainPickerError": "Błąd",
|
||||
"domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Falha ao ativar a chave de licença",
|
||||
"licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.",
|
||||
"licenseAbout": "Sobre Licenciamento",
|
||||
"licenseBannerTitle": "Ative Sua Licença Corporativa",
|
||||
"licenseBannerDescription": "Desbloqueie recursos empresariais para sua instância de Pangolin autohospedada. Compre uma chave de licença para ativar recursos premium e adicione-a abaixo.",
|
||||
"licenseBannerGetLicense": "Obter Licença",
|
||||
"licenseBannerViewDocs": "Ver Documentação",
|
||||
"communityEdition": "Edição da Comunidade",
|
||||
"licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.",
|
||||
"licenseKeyActivated": "Chave de licença ativada",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização",
|
||||
"domainPickerProvidedDomain": "Domínio fornecido",
|
||||
"domainPickerFreeProvidedDomain": "Domínio fornecido grátis",
|
||||
"domainPickerFreeDomainsPaidFeature": "Os domínios fornecidos são um recurso pago. Assine para obter um domínio incluído no seu plano — não há necessidade de trazer o seu próprio.",
|
||||
"domainPickerVerified": "Verificada",
|
||||
"domainPickerUnverified": "Não verificado",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.",
|
||||
"domainPickerError": "ERRO",
|
||||
"domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ",
|
||||
"licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.",
|
||||
"licenseAbout": "О лицензировании",
|
||||
"licenseBannerTitle": "Активируйте вашу корпоративную лицензию",
|
||||
"licenseBannerDescription": "Откройте доступ к корпоративным функциям для вашей локально размещаемой версии Pangolin. Приобретите лицензионный ключ, чтобы активировать премиум-функции, затем добавьте его ниже.",
|
||||
"licenseBannerGetLicense": "Получить лицензию",
|
||||
"licenseBannerViewDocs": "Посмотреть документацию",
|
||||
"communityEdition": "Community Edition",
|
||||
"licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.",
|
||||
"licenseKeyActivated": "Лицензионный ключ активирован",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации",
|
||||
"domainPickerProvidedDomain": "Домен предоставлен",
|
||||
"domainPickerFreeProvidedDomain": "Бесплатный домен",
|
||||
"domainPickerFreeDomainsPaidFeature": "Предоставленные домены являются платной функцией. Подпишитесь, чтобы получить домен, включенный в ваш план — не нужно приносить свой собственный.",
|
||||
"domainPickerVerified": "Подтверждено",
|
||||
"domainPickerUnverified": "Не подтверждено",
|
||||
"domainPickerManual": "Ручной",
|
||||
"domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.",
|
||||
"domainPickerError": "Ошибка",
|
||||
"domainPickerErrorLoadDomains": "Не удалось загрузить домены организации",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi",
|
||||
"licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.",
|
||||
"licenseAbout": "Lisans Hakkında",
|
||||
"licenseBannerTitle": "Kurumsal Lisansınızı Etkinleştirin",
|
||||
"licenseBannerDescription": "Kendi barındırdığınız Pangolin örneğiniz için kurumsal özelliklerin kilidini açın. Premium yetenekleri etkinleştirmek için bir lisans anahtarı satın alın, ardından aşağıya ekleyin.",
|
||||
"licenseBannerGetLicense": "Lisans Alın",
|
||||
"licenseBannerViewDocs": "Dokümantasyonu Görüntüleyin",
|
||||
"communityEdition": "Topluluk Sürümü",
|
||||
"licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.",
|
||||
"licenseKeyActivated": "Lisans anahtarı etkinleştirildi",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin",
|
||||
"domainPickerProvidedDomain": "Sağlanan Alan Adı",
|
||||
"domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı",
|
||||
"domainPickerFreeDomainsPaidFeature": "Sağlanan alan adları ücretli bir özelliktir. Planınıza dahil bir alan adı almak için abone olun - kendi alan adınızı getirmenize gerek yok.",
|
||||
"domainPickerVerified": "Doğrulandı",
|
||||
"domainPickerUnverified": "Doğrulanmadı",
|
||||
"domainPickerManual": "Manuel",
|
||||
"domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.",
|
||||
"domainPickerError": "Hata",
|
||||
"domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi",
|
||||
|
||||
@@ -405,6 +405,10 @@
|
||||
"licenseErrorKeyActivate": "激活许可证密钥失败",
|
||||
"licenseErrorKeyActivateDescription": "激活许可证密钥时出错。",
|
||||
"licenseAbout": "关于许可协议",
|
||||
"licenseBannerTitle": "启用您的企业许可证",
|
||||
"licenseBannerDescription": "为您自行托管的Pangolin实例解锁企业功能。购买许可证密钥以激活高级功能,然后在下方添加。",
|
||||
"licenseBannerGetLicense": "获取许可证",
|
||||
"licenseBannerViewDocs": "查看文档",
|
||||
"communityEdition": "社区版",
|
||||
"licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。",
|
||||
"licenseKeyActivated": "授权密钥已激活",
|
||||
@@ -2114,8 +2118,10 @@
|
||||
"selectDomainForOrgAuthPage": "选择组织认证页面的域",
|
||||
"domainPickerProvidedDomain": "提供的域",
|
||||
"domainPickerFreeProvidedDomain": "免费提供的域",
|
||||
"domainPickerFreeDomainsPaidFeature": "提供的域名是付费功能。订阅即可将域名包含在您的计划中—无需自带域名。",
|
||||
"domainPickerVerified": "已验证",
|
||||
"domainPickerUnverified": "未验证",
|
||||
"domainPickerManual": "手动",
|
||||
"domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。",
|
||||
"domainPickerError": "错误",
|
||||
"domainPickerErrorLoadDomains": "加载组织域名失败",
|
||||
|
||||
4790
messages/zh-TW.json
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 742 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 765 KiB |
|
Before Width: | Height: | Size: 396 KiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 243 KiB |
@@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", {
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
settingsLogRetentionDaysConnection: integer(
|
||||
"settingsLogRetentionDaysConnection"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
@@ -101,7 +103,9 @@ export const sites = pgTable("sites", {
|
||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||
listenPort: integer("listenPort"),
|
||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||
status: varchar("status").$type<"pending" | "approved">().default("approved")
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
});
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
@@ -222,16 +226,23 @@ export const exitNodes = pgTable("exitNodes", {
|
||||
export const siteResources = pgTable("siteResources", {
|
||||
// this is for the clients
|
||||
siteResourceId: serial("siteResourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
defaultNetworkId: integer("defaultNetworkId").references(
|
||||
() => networks.networkId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
niceId: varchar("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: varchar("protocol"), // only for port mode
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||
@@ -244,7 +255,38 @@ export const siteResources = pgTable("siteResources", {
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
.default("site"),
|
||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain")
|
||||
});
|
||||
|
||||
export const networks = pgTable("networks", {
|
||||
networkId: serial("networkId").primaryKey(),
|
||||
niceId: text("niceId"),
|
||||
name: text("name"),
|
||||
scope: varchar("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const siteNetworks = pgTable("siteNetworks", {
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
networkId: integer("networkId")
|
||||
.notNull()
|
||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||
@@ -994,6 +1036,7 @@ export const requestAuditLog = pgTable(
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
@@ -1106,3 +1149,4 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
|
||||
@@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", {
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
settingsLogRetentionDaysConnection: integer(
|
||||
"settingsLogRetentionDaysConnection"
|
||||
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||
.notNull()
|
||||
.default(0),
|
||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||
@@ -92,6 +94,9 @@ export const sites = sqliteTable("sites", {
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet"),
|
||||
@@ -250,16 +255,21 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
siteResourceId: integer("siteResourceId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
defaultNetworkId: integer("defaultNetworkId").references(
|
||||
() => networks.networkId,
|
||||
{ onDelete: "restrict" }
|
||||
),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
protocol: text("protocol"), // only for port mode
|
||||
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||
@@ -274,7 +284,36 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote">()
|
||||
.default("site")
|
||||
.default("site"),
|
||||
domainId: text("domainId").references(() => domains.domainId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain"),
|
||||
});
|
||||
|
||||
export const networks = sqliteTable("networks", {
|
||||
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
|
||||
niceId: text("niceId"),
|
||||
name: text("name"),
|
||||
scope: text("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const siteNetworks = sqliteTable("siteNetworks", {
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
networkId: integer("networkId")
|
||||
.notNull()
|
||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||
@@ -1096,6 +1135,7 @@ export const requestAuditLog = sqliteTable(
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
siteResourceId: integer("siteResourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
@@ -1195,6 +1235,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||
import { initCleanup } from "#dynamic/cleanup";
|
||||
import license from "#dynamic/license/license";
|
||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
|
||||
async function startServers() {
|
||||
@@ -39,6 +40,7 @@ async function startServers() {
|
||||
initTelemetryClient();
|
||||
|
||||
initLogCleanupInterval();
|
||||
initAcmeCertSync();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
|
||||
3
server/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function initAcmeCertSync(): void {
|
||||
// stub
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export enum TierFeature {
|
||||
SshPam = "sshPam",
|
||||
FullRbac = "fullRbac",
|
||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||
SIEM = "siem" // handle downgrade by disabling SIEM integrations
|
||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -56,5 +58,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||
[TierFeature.SIEM]: ["enterprise"]
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -121,8 +121,8 @@ export async function applyBlueprint({
|
||||
for (const result of clientResourcesResults) {
|
||||
if (
|
||||
result.oldSiteResource &&
|
||||
result.oldSiteResource.siteId !=
|
||||
result.newSiteResource.siteId
|
||||
JSON.stringify(result.newSites?.sort()) !==
|
||||
JSON.stringify(result.oldSites?.sort())
|
||||
) {
|
||||
// query existing associations
|
||||
const existingRoleIds = await trx
|
||||
@@ -222,38 +222,46 @@ export async function applyBlueprint({
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
const [newSite] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, result.newSiteResource.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
let good = true;
|
||||
for (const newSite of result.newSites) {
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, newSite.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
logger.debug(
|
||||
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||
);
|
||||
good = false;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!newSite) {
|
||||
logger.debug(
|
||||
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
|
||||
);
|
||||
if (!good) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
result.oldSiteResource,
|
||||
result.newSiteResource,
|
||||
{
|
||||
siteId: newSite.sites.siteId,
|
||||
orgId: newSite.sites.orgId
|
||||
},
|
||||
result.newSites.map((site) => ({
|
||||
siteId: site.siteId,
|
||||
orgId: result.newSiteResource.orgId
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,104 @@
|
||||
import {
|
||||
clients,
|
||||
clientSiteResources,
|
||||
domains,
|
||||
orgDomains,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
users,
|
||||
userSiteResources
|
||||
userSiteResources,
|
||||
networks
|
||||
} from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or } from "drizzle-orm";
|
||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||
import { Config } from "./types";
|
||||
import logger from "@server/logger";
|
||||
import { getNextAvailableAliasAddress } from "../ip";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
|
||||
async function getDomainForSiteResource(
|
||||
siteResourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string }> {
|
||||
const [fullDomainExists] = await trx
|
||||
.select({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, fullDomain),
|
||||
eq(siteResources.orgId, orgId),
|
||||
siteResourceId
|
||||
? ne(siteResources.siteResourceId, siteResourceId)
|
||||
: isNotNull(siteResources.siteResourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (fullDomainExists) {
|
||||
throw new Error(
|
||||
`Site resource already exists with domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const possibleDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
.innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId))
|
||||
.where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true)))
|
||||
.execute();
|
||||
|
||||
if (possibleDomains.length === 0) {
|
||||
throw new Error(
|
||||
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const validDomains = possibleDomains.filter((domain) => {
|
||||
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
||||
return (
|
||||
fullDomain === domain.domains.baseDomain ||
|
||||
fullDomain.endsWith(`.${domain.domains.baseDomain}`)
|
||||
);
|
||||
} else if (domain.domains.type == "cname") {
|
||||
return fullDomain === domain.domains.baseDomain;
|
||||
}
|
||||
});
|
||||
|
||||
if (validDomains.length === 0) {
|
||||
throw new Error(
|
||||
`Domain not found for full-domain: ${fullDomain} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
const domainSelection = validDomains[0].domains;
|
||||
const baseDomain = domainSelection.baseDomain;
|
||||
|
||||
let subdomain: string | null = null;
|
||||
if (fullDomain !== baseDomain) {
|
||||
subdomain = fullDomain.replace(`.${baseDomain}`, "");
|
||||
}
|
||||
|
||||
await createCertificate(domainSelection.domainId, fullDomain, trx);
|
||||
|
||||
return {
|
||||
subdomain,
|
||||
domainId: domainSelection.domainId
|
||||
};
|
||||
}
|
||||
|
||||
export type ClientResourcesResults = {
|
||||
newSiteResource: SiteResource;
|
||||
oldSiteResource?: SiteResource;
|
||||
newSites: { siteId: number }[];
|
||||
oldSites: { siteId: number }[];
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
@@ -43,53 +123,104 @@ export async function updateClientResources(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const resourceSiteId = resourceData.site;
|
||||
let site;
|
||||
const existingSiteIds = existingResource?.networkId
|
||||
? await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, existingResource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
let allSites: { siteId: number }[] = [];
|
||||
if (resourceData.site) {
|
||||
let siteSingle;
|
||||
const resourceSiteId = resourceData.site;
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!siteSingle) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
if (resourceData.sites) {
|
||||
for (const siteNiceId of resourceData.sites) {
|
||||
const [site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, siteNiceId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${siteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
existingResource.siteResourceId,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
siteId: site.siteId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp: resourceData["disable-icmp"],
|
||||
tcpPortRangeString: resourceData["tcp-ports"],
|
||||
udpPortRangeString: resourceData["udp-ports"]
|
||||
udpPortRangeString: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -100,7 +231,21 @@ export async function updateClientResources(
|
||||
.returning();
|
||||
|
||||
const siteResourceId = existingResource.siteResourceId;
|
||||
const orgId = existingResource.orgId;
|
||||
|
||||
if (updatedResource.networkId) {
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(siteNetworks.networkId, updatedResource.networkId)
|
||||
);
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: updatedResource.networkId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await trx
|
||||
.delete(clientSiteResources)
|
||||
@@ -204,37 +349,72 @@ export async function updateClientResources(
|
||||
|
||||
results.push({
|
||||
newSiteResource: updatedResource,
|
||||
oldSiteResource: existingResource
|
||||
oldSiteResource: existingResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
if (resourceData.mode == "host") {
|
||||
// we can only have an alias on a host
|
||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||
}
|
||||
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
| undefined;
|
||||
if (resourceData["full-domain"] && resourceData.mode === "http") {
|
||||
domainInfo = await getDomainForSiteResource(
|
||||
undefined,
|
||||
resourceData["full-domain"],
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
orgId: orgId,
|
||||
siteId: site.siteId,
|
||||
niceId: resourceNiceId,
|
||||
networkId: network.networkId,
|
||||
defaultNetworkId: network.networkId,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceData.ssl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: true, // hardcoded for now
|
||||
// enabled: resourceData.enabled ?? true,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp: resourceData["disable-icmp"],
|
||||
tcpPortRangeString: resourceData["tcp-ports"],
|
||||
udpPortRangeString: resourceData["udp-ports"]
|
||||
udpPortRangeString: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null
|
||||
})
|
||||
.returning();
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -324,7 +504,11 @@ export async function updateClientResources(
|
||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||
);
|
||||
|
||||
results.push({ newSiteResource: newResource });
|
||||
results.push({
|
||||
newSiteResource: newResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1100,7 +1100,7 @@ function checkIfTargetChanged(
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDomain(
|
||||
export async function getDomain(
|
||||
resourceId: number | undefined,
|
||||
fullDomain: string,
|
||||
orgId: string,
|
||||
|
||||
@@ -164,6 +164,7 @@ export const ResourceSchema = z
|
||||
name: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
"full-domain": z.string().optional(),
|
||||
"proxy-port": z.int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -325,16 +326,20 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
export const ClientResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr"]),
|
||||
site: z.string(),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
site: z.string(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
"destination-port": z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
// enabled: z.boolean().default(true),
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"disable-icmp": z.boolean().optional().default(false),
|
||||
"full-domain": z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional().nullable(),
|
||||
alias: z
|
||||
.string()
|
||||
.regex(
|
||||
@@ -477,6 +482,39 @@ export const ConfigSchema = z
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce the full-domain uniqueness across client-resources in the same stack
|
||||
const clientFullDomainMap = new Map<string, string[]>();
|
||||
|
||||
Object.entries(config["client-resources"]).forEach(
|
||||
([resourceKey, resource]) => {
|
||||
const fullDomain = resource["full-domain"];
|
||||
if (fullDomain) {
|
||||
if (!clientFullDomainMap.has(fullDomain)) {
|
||||
clientFullDomainMap.set(fullDomain, []);
|
||||
}
|
||||
clientFullDomainMap.get(fullDomain)!.push(resourceKey);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const clientFullDomainDuplicates = Array.from(
|
||||
clientFullDomainMap.entries()
|
||||
)
|
||||
.filter(([_, resourceKeys]) => resourceKeys.length > 1)
|
||||
.map(
|
||||
([fullDomain, resourceKeys]) =>
|
||||
`'${fullDomain}' used by resources: ${resourceKeys.join(", ")}`
|
||||
)
|
||||
.join("; ");
|
||||
|
||||
if (clientFullDomainDuplicates.length !== 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["client-resources"],
|
||||
message: `Duplicate 'full-domain' values found: ${clientFullDomainDuplicates}`
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce proxy-port uniqueness within proxy-resources per protocol
|
||||
const protocolPortMap = new Map<string, string[]>();
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function encryptData(data: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
|
||||
let encrypted = cipher.update(data, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Combine IV, auth tag, and encrypted data
|
||||
return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
|
||||
}
|
||||
|
||||
// Helper function to decrypt data (you'll need this to read certificates)
|
||||
export function decryptData(encryptedData: string, key: Buffer): string {
|
||||
const algorithm = "aes-256-gcm";
|
||||
const parts = encryptedData.split(":");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid encrypted data format");
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], "hex");
|
||||
const authTag = Buffer.from(parts[1], "hex");
|
||||
const encrypted = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// openssl rand -hex 32 > config/encryption.key
|
||||
166
server/lib/ip.ts
@@ -5,6 +5,7 @@ import config from "@server/lib/config";
|
||||
import z from "zod";
|
||||
import logger from "@server/logger";
|
||||
import semver from "semver";
|
||||
import { getValidCertificatesForDomains } from "#dynamic/lib/certificates";
|
||||
|
||||
interface IPRange {
|
||||
start: bigint;
|
||||
@@ -477,9 +478,9 @@ export type Alias = { alias: string | null; aliasAddress: string | null };
|
||||
|
||||
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||
return allSiteResources
|
||||
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
|
||||
.filter((sr) => sr.aliasAddress && ((sr.alias && sr.mode == "host") || (sr.fullDomain && sr.mode == "http")))
|
||||
.map((sr) => ({
|
||||
alias: sr.alias,
|
||||
alias: sr.alias || sr.fullDomain,
|
||||
aliasAddress: sr.aliasAddress
|
||||
}));
|
||||
}
|
||||
@@ -582,16 +583,26 @@ export type SubnetProxyTargetV2 = {
|
||||
protocol: "tcp" | "udp";
|
||||
}[];
|
||||
resourceId?: number;
|
||||
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
|
||||
httpTargets?: HTTPTarget[];
|
||||
tlsCert?: string;
|
||||
tlsKey?: string;
|
||||
};
|
||||
|
||||
export function generateSubnetProxyTargetV2(
|
||||
export type HTTPTarget = {
|
||||
destAddr: string; // must be an IP or hostname
|
||||
destPort: number;
|
||||
scheme: "http" | "https";
|
||||
};
|
||||
|
||||
export async function generateSubnetProxyTargetV2(
|
||||
siteResource: SiteResource,
|
||||
clients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
subnet: string | null;
|
||||
}[]
|
||||
): SubnetProxyTargetV2 | undefined {
|
||||
): Promise<SubnetProxyTargetV2[] | undefined> {
|
||||
if (clients.length === 0) {
|
||||
logger.debug(
|
||||
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||
@@ -599,7 +610,7 @@ export function generateSubnetProxyTargetV2(
|
||||
return;
|
||||
}
|
||||
|
||||
let target: SubnetProxyTargetV2 | null = null;
|
||||
let targets: SubnetProxyTargetV2[] = [];
|
||||
|
||||
const portRange = [
|
||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||
@@ -614,52 +625,115 @@ export function generateSubnetProxyTargetV2(
|
||||
if (ipSchema.safeParse(destination).success) {
|
||||
destination = `${destination}/32`;
|
||||
|
||||
target = {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: destination,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId,
|
||||
};
|
||||
resourceId: siteResource.siteResourceId
|
||||
});
|
||||
}
|
||||
|
||||
if (siteResource.alias && siteResource.aliasAddress) {
|
||||
// also push a match for the alias address
|
||||
target = {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId,
|
||||
};
|
||||
resourceId: siteResource.siteResourceId
|
||||
});
|
||||
}
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
target = {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: siteResource.destination,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId
|
||||
});
|
||||
} else if (siteResource.mode == "http") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
if (ipSchema.safeParse(destination).success) {
|
||||
destination = `${destination}/32`;
|
||||
}
|
||||
|
||||
if (
|
||||
!siteResource.aliasAddress ||
|
||||
!siteResource.destinationPort ||
|
||||
!siteResource.scheme ||
|
||||
!siteResource.fullDomain
|
||||
) {
|
||||
logger.debug(
|
||||
`Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
// also push a match for the alias address
|
||||
let tlsCert: string | undefined;
|
||||
let tlsKey: string | undefined;
|
||||
|
||||
if (siteResource.ssl && siteResource.fullDomain) {
|
||||
try {
|
||||
const certs = await getValidCertificatesForDomains(
|
||||
new Set([siteResource.fullDomain]),
|
||||
true
|
||||
);
|
||||
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
|
||||
tlsCert = certs[0].certFile;
|
||||
tlsKey = certs[0].keyFile;
|
||||
} else {
|
||||
logger.warn(
|
||||
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.fullDomain}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.fullDomain}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination,
|
||||
portRange,
|
||||
disableIcmp,
|
||||
resourceId: siteResource.siteResourceId,
|
||||
};
|
||||
protocol: siteResource.ssl ? "https" : "http",
|
||||
httpTargets: [
|
||||
{
|
||||
destAddr: siteResource.destination,
|
||||
destPort: siteResource.destinationPort,
|
||||
scheme: siteResource.scheme
|
||||
}
|
||||
],
|
||||
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
|
||||
});
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
if (targets.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const clientSite of clients) {
|
||||
if (!clientSite.subnet) {
|
||||
logger.debug(
|
||||
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
|
||||
);
|
||||
continue;
|
||||
for (const target of targets) {
|
||||
for (const clientSite of clients) {
|
||||
if (!clientSite.subnet) {
|
||||
logger.debug(
|
||||
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||
|
||||
// add client prefix to source prefixes
|
||||
target.sourcePrefixes.push(clientPrefix);
|
||||
}
|
||||
|
||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||
|
||||
// add client prefix to source prefixes
|
||||
target.sourcePrefixes.push(clientPrefix);
|
||||
}
|
||||
|
||||
// print a nice representation of the targets
|
||||
@@ -667,36 +741,34 @@ export function generateSubnetProxyTargetV2(
|
||||
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
|
||||
// );
|
||||
|
||||
return target;
|
||||
return targets;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
|
||||
* by expanding each source prefix into its own target entry.
|
||||
* @param targetV2 - The v2 target to convert
|
||||
* @returns Array of v1 SubnetProxyTarget objects
|
||||
*/
|
||||
export function convertSubnetProxyTargetsV2ToV1(
|
||||
targetsV2: SubnetProxyTargetV2[]
|
||||
): SubnetProxyTarget[] {
|
||||
return targetsV2.flatMap((targetV2) =>
|
||||
targetV2.sourcePrefixes.map((sourcePrefix) => ({
|
||||
sourcePrefix,
|
||||
destPrefix: targetV2.destPrefix,
|
||||
...(targetV2.disableIcmp !== undefined && {
|
||||
disableIcmp: targetV2.disableIcmp
|
||||
}),
|
||||
...(targetV2.rewriteTo !== undefined && {
|
||||
rewriteTo: targetV2.rewriteTo
|
||||
}),
|
||||
...(targetV2.portRange !== undefined && {
|
||||
portRange: targetV2.portRange
|
||||
})
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function convertSubnetProxyTargetsV2ToV1(
|
||||
targetsV2: SubnetProxyTargetV2[]
|
||||
): SubnetProxyTarget[] {
|
||||
return targetsV2.flatMap((targetV2) =>
|
||||
targetV2.sourcePrefixes.map((sourcePrefix) => ({
|
||||
sourcePrefix,
|
||||
destPrefix: targetV2.destPrefix,
|
||||
...(targetV2.disableIcmp !== undefined && {
|
||||
disableIcmp: targetV2.disableIcmp
|
||||
}),
|
||||
...(targetV2.rewriteTo !== undefined && {
|
||||
rewriteTo: targetV2.rewriteTo
|
||||
}),
|
||||
...(targetV2.portRange !== undefined && {
|
||||
portRange: targetV2.portRange
|
||||
})
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Custom schema for validating port range strings
|
||||
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
||||
|
||||
@@ -11,17 +11,16 @@ import {
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
|
||||
import {
|
||||
addPeer as newtAddPeer,
|
||||
deletePeer as newtDeletePeer
|
||||
} from "@server/routers/newt/peers";
|
||||
import {
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
generateRemoteSubnets,
|
||||
generateSubnetProxyTargetV2,
|
||||
parseEndpoint,
|
||||
formatEndpoint
|
||||
} from "@server/lib/ip";
|
||||
import {
|
||||
addPeerData,
|
||||
@@ -48,15 +46,27 @@ export async function getClientSiteResourceAccess(
|
||||
siteResource: SiteResource,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
// get the site
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
// get all sites associated with this siteResource via its network
|
||||
const sitesList = siteResource.networkId
|
||||
? await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.siteId, sites.siteId)
|
||||
)
|
||||
.where(eq(siteNetworks.networkId, siteResource.networkId))
|
||||
.then((rows) => rows.map((row) => row.sites))
|
||||
: [];
|
||||
|
||||
if (!site) {
|
||||
throw new Error(`Site with ID ${siteResource.siteId} not found`);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}]`
|
||||
);
|
||||
|
||||
if (sitesList.length === 0) {
|
||||
logger.warn(
|
||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||
);
|
||||
}
|
||||
|
||||
const roleIds = await trx
|
||||
@@ -136,8 +146,12 @@ export async function getClientSiteResourceAccess(
|
||||
const mergedAllClients = Array.from(allClientsMap.values());
|
||||
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})`
|
||||
);
|
||||
|
||||
return {
|
||||
site,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
mergedAllClientIds
|
||||
};
|
||||
@@ -153,40 +167,59 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
const siteId = siteResource.siteId;
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||
);
|
||||
|
||||
const { site, mergedAllClients, mergedAllClientIds } =
|
||||
const { sitesList, mergedAllClients, mergedAllClientIds } =
|
||||
await getClientSiteResourceAccess(siteResource, trx);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] access resolved siteResourceId=${siteResource.siteResourceId} siteCount=${sitesList.length} siteIds=[${sitesList.map((s) => s.siteId).join(", ")}] mergedClientCount=${mergedAllClients.length} clientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
/////////// process the client-siteResource associations ///////////
|
||||
|
||||
// get all of the clients associated with other resources on this site
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
ne(siteResources.siteResourceId, siteResource.siteResourceId)
|
||||
)
|
||||
);
|
||||
// get all of the clients associated with other resources in the same network,
|
||||
// joined through siteNetworks so we know which siteId each client belongs to
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.networkId, siteResource.networkId),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const allClientIdsFromOtherResourcesOnThisSite = Array.from(
|
||||
new Set(
|
||||
allUpdatedClientsFromOtherResourcesOnThisSite.map(
|
||||
(row) => row.clientId
|
||||
)
|
||||
)
|
||||
);
|
||||
// Build a per-site map so the loop below can check by siteId rather than
|
||||
// across the entire network.
|
||||
const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
|
||||
for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
|
||||
if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
|
||||
clientsFromOtherResourcesBySite.set(row.siteId, new Set());
|
||||
}
|
||||
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
|
||||
}
|
||||
|
||||
const existingClientSiteResources = await trx
|
||||
.select({
|
||||
@@ -204,6 +237,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
|
||||
);
|
||||
|
||||
// Get full client details for existing resource clients (needed for sending delete messages)
|
||||
const existingResourceClients =
|
||||
existingClientSiteResourceIds.length > 0
|
||||
@@ -223,6 +260,10 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
(clientId) => !existingClientSiteResourceIds.includes(clientId)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toAdd=[${clientSiteResourcesToAdd.join(", ")}]`
|
||||
);
|
||||
|
||||
const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map(
|
||||
(clientId) => ({
|
||||
clientId,
|
||||
@@ -231,17 +272,34 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
);
|
||||
|
||||
if (clientSiteResourcesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserting ${clientSiteResourcesToInsert.length} clientSiteResource association(s)`
|
||||
);
|
||||
await trx
|
||||
.insert(clientSiteResourcesAssociationsCache)
|
||||
.values(clientSiteResourcesToInsert)
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} no clientSiteResource associations to insert`
|
||||
);
|
||||
}
|
||||
|
||||
const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter(
|
||||
(clientId) => !mergedAllClientIds.includes(clientId)
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} resourceClients toRemove=[${clientSiteResourcesToRemove.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} deleting ${clientSiteResourcesToRemove.length} clientSiteResource association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSiteResourcesAssociationsCache)
|
||||
.where(
|
||||
@@ -260,82 +318,127 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
|
||||
/////////// process the client-site associations ///////////
|
||||
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
|
||||
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)`
|
||||
);
|
||||
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients = await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds));
|
||||
for (const site of sitesList) {
|
||||
const siteId = site.siteId;
|
||||
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
|
||||
);
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
}
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!mergedAllClientIds.includes(clientId) &&
|
||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} existingClientSiteIds=[${existingClientSiteIds.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients =
|
||||
existingClientSiteIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||
: [];
|
||||
|
||||
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
|
||||
);
|
||||
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||
);
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toAdd=[${clientSitesToAdd.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserting ${clientSitesToInsert.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} no clientSite associations to insert`
|
||||
);
|
||||
}
|
||||
|
||||
// Now remove any client-site associations that should no longer exist
|
||||
const clientSitesToRemove = existingClientSiteIds.filter(
|
||||
(clientId) =>
|
||||
!mergedAllClientIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} clientSites toRemove=[${clientSitesToRemove.join(", ")}]`
|
||||
);
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
logger.debug(
|
||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} deleting ${clientSitesToRemove.length} clientSite association(s)`
|
||||
);
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
/////////// send the messages ///////////
|
||||
|
||||
// Now handle the messages to add/remove peers on both the newt and olm sides
|
||||
await handleMessagesForSiteClients(
|
||||
site,
|
||||
siteId,
|
||||
mergedAllClients,
|
||||
existingClients,
|
||||
clientSitesToAdd,
|
||||
clientSitesToRemove,
|
||||
trx
|
||||
);
|
||||
|
||||
// Handle subnet proxy target updates for the resource associations
|
||||
await handleSubnetProxyTargetUpdates(
|
||||
siteResource,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
existingResourceClients,
|
||||
clientSiteResourcesToAdd,
|
||||
@@ -624,6 +727,7 @@ export async function updateClientSiteDestinations(
|
||||
|
||||
async function handleSubnetProxyTargetUpdates(
|
||||
siteResource: SiteResource,
|
||||
sitesList: Site[],
|
||||
allClients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
@@ -638,125 +742,138 @@ async function handleSubnetProxyTargetUpdates(
|
||||
clientSiteResourcesToRemove: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
const proxyJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const siteData of sitesList) {
|
||||
const siteId = siteData.siteId;
|
||||
|
||||
const proxyJobs = [];
|
||||
const olmJobs = [];
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (addedClients.length > 0) {
|
||||
const targetToAdd = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
|
||||
if (targetToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToAdd],
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
|
||||
|
||||
// Generate targets for removed associations
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetToRemove = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (targetToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToRemove],
|
||||
newt.version
|
||||
)
|
||||
if (addedClients.length > 0) {
|
||||
const targetsToAdd = await generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, siteResource.siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
siteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
if (targetsToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToAdd,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Only remove remote subnet if no other resource uses the same destination
|
||||
const remoteSubnetsToRemove =
|
||||
destinationStillInUse.length > 0
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
|
||||
|
||||
// Generate targets for removed associations
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetsToRemove = await generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
);
|
||||
|
||||
if (targetsToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
targetsToRemove,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
// removePeerData operates per-site — a resource on a different
|
||||
// site sharing the same network should not block removal here.
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
siteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Only remove remote subnet if no other resource uses the same destination
|
||||
const remoteSubnetsToRemove =
|
||||
destinationStillInUse.length > 0
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,10 +980,25 @@ export async function rebuildClientAssociationsFromClient(
|
||||
)
|
||||
: [];
|
||||
|
||||
// Group by siteId for site-level associations
|
||||
const newSiteIds = Array.from(
|
||||
new Set(newSiteResources.map((sr) => sr.siteId))
|
||||
// Group by siteId for site-level associations — look up via siteNetworks since
|
||||
// siteResources no longer carries a direct siteId column.
|
||||
const networkIds = Array.from(
|
||||
new Set(
|
||||
newSiteResources
|
||||
.map((sr) => sr.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const newSiteIds =
|
||||
networkIds.length > 0
|
||||
? await trx
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, networkIds))
|
||||
.then((rows) =>
|
||||
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||
)
|
||||
: [];
|
||||
|
||||
/////////// Process client-siteResource associations ///////////
|
||||
|
||||
@@ -1139,13 +1271,45 @@ async function handleMessagesForClientResources(
|
||||
resourcesToAdd.includes(r.siteResourceId)
|
||||
);
|
||||
|
||||
// Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
|
||||
const addedNetworkIds = Array.from(
|
||||
new Set(
|
||||
addedResources
|
||||
.map((r) => r.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const addedSiteNetworkRows =
|
||||
addedNetworkIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
networkId: siteNetworks.networkId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, addedNetworkIds))
|
||||
: [];
|
||||
const addedNetworkToSites = new Map<number, number[]>();
|
||||
for (const row of addedSiteNetworkRows) {
|
||||
if (!addedNetworkToSites.has(row.networkId)) {
|
||||
addedNetworkToSites.set(row.networkId, []);
|
||||
}
|
||||
addedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||
}
|
||||
|
||||
// Group by site for proxy updates
|
||||
const addedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of addedResources) {
|
||||
if (!addedBySite.has(resource.siteId)) {
|
||||
addedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (addedNetworkToSites.get(resource.networkId) ?? [])
|
||||
: [];
|
||||
for (const siteId of siteIds) {
|
||||
if (!addedBySite.has(siteId)) {
|
||||
addedBySite.set(siteId, []);
|
||||
}
|
||||
addedBySite.get(siteId)!.push(resource);
|
||||
}
|
||||
addedBySite.get(resource.siteId)!.push(resource);
|
||||
}
|
||||
|
||||
// Add subnet proxy targets for each site
|
||||
@@ -1164,7 +1328,7 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
const target = generateSubnetProxyTargetV2(resource, [
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
clientId: client.clientId,
|
||||
pubKey: client.pubKey,
|
||||
@@ -1172,11 +1336,11 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
]);
|
||||
|
||||
if (target) {
|
||||
if (targets) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[target],
|
||||
targets,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
@@ -1187,7 +1351,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
generateRemoteSubnets([resource]),
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1199,7 +1363,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -1216,13 +1380,45 @@ async function handleMessagesForClientResources(
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, resourcesToRemove));
|
||||
|
||||
// Build (resource, siteId) pairs via siteNetworks
|
||||
const removedNetworkIds = Array.from(
|
||||
new Set(
|
||||
removedResources
|
||||
.map((r) => r.networkId)
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
);
|
||||
const removedSiteNetworkRows =
|
||||
removedNetworkIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
networkId: siteNetworks.networkId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(siteNetworks)
|
||||
.where(inArray(siteNetworks.networkId, removedNetworkIds))
|
||||
: [];
|
||||
const removedNetworkToSites = new Map<number, number[]>();
|
||||
for (const row of removedSiteNetworkRows) {
|
||||
if (!removedNetworkToSites.has(row.networkId)) {
|
||||
removedNetworkToSites.set(row.networkId, []);
|
||||
}
|
||||
removedNetworkToSites.get(row.networkId)!.push(row.siteId);
|
||||
}
|
||||
|
||||
// Group by site for proxy updates
|
||||
const removedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of removedResources) {
|
||||
if (!removedBySite.has(resource.siteId)) {
|
||||
removedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (removedNetworkToSites.get(resource.networkId) ?? [])
|
||||
: [];
|
||||
for (const siteId of siteIds) {
|
||||
if (!removedBySite.has(siteId)) {
|
||||
removedBySite.set(siteId, []);
|
||||
}
|
||||
removedBySite.get(siteId)!.push(resource);
|
||||
}
|
||||
removedBySite.get(resource.siteId)!.push(resource);
|
||||
}
|
||||
|
||||
// Remove subnet proxy targets for each site
|
||||
@@ -1241,7 +1437,7 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
const target = generateSubnetProxyTargetV2(resource, [
|
||||
const targets = await generateSubnetProxyTargetV2(resource, [
|
||||
{
|
||||
clientId: client.clientId,
|
||||
pubKey: client.pubKey,
|
||||
@@ -1249,18 +1445,22 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
]);
|
||||
|
||||
if (target) {
|
||||
if (targets) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[target],
|
||||
targets,
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
// Check if this client still has access to another resource
|
||||
// on this specific site with the same destination. We scope
|
||||
// by siteId (via siteNetworks) rather than networkId because
|
||||
// removePeerData operates per-site — a resource on a different
|
||||
// site sharing the same network should not block removal here.
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -1271,13 +1471,17 @@ async function handleMessagesForClientResources(
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, resource.siteId),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
resource.destination
|
||||
@@ -1299,7 +1503,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1311,7 +1515,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
478
server/private/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* 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 fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
certificates,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
domains,
|
||||
newts,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
generateSubnetProxyTargetV2,
|
||||
SubnetProxyTargetV2
|
||||
} from "@server/lib/ip";
|
||||
import { updateTargets } from "@server/routers/client/targets";
|
||||
import cache from "#private/lib/cache";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface AcmeCert {
|
||||
domain: { main: string; sans?: string[] };
|
||||
certificate: string;
|
||||
key: string;
|
||||
Store: string;
|
||||
}
|
||||
|
||||
interface AcmeJson {
|
||||
[resolver: string]: {
|
||||
Certificates: AcmeCert[];
|
||||
};
|
||||
}
|
||||
|
||||
async function pushCertUpdateToAffectedNewts(
|
||||
domain: string,
|
||||
domainId: string | null,
|
||||
oldCertPem: string | null,
|
||||
oldKeyPem: string | null
|
||||
): Promise<void> {
|
||||
// Find all SSL-enabled HTTP site resources that use this cert's domain
|
||||
let affectedResources: SiteResource[] = [];
|
||||
|
||||
if (domainId) {
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.domainId, domainId),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Fallback: match by exact fullDomain when no domainId is available
|
||||
affectedResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.fullDomain, domain),
|
||||
eq(siteResources.ssl, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (affectedResources.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no affected site resources for cert domain "${domain}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: pushing cert update to ${affectedResources.length} affected site resource(s) for domain "${domain}"`
|
||||
);
|
||||
|
||||
for (const resource of affectedResources) {
|
||||
try {
|
||||
// Get all sites for this resource via siteNetworks
|
||||
const resourceSiteRows = resource.networkId
|
||||
? await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteRows.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no sites for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all clients with access to this resource
|
||||
const resourceClients = await db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSiteResourcesAssociationsCache.clientId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
);
|
||||
|
||||
if (resourceClients.length === 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no clients for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Invalidate the cert cache so generateSubnetProxyTargetV2 fetches fresh data
|
||||
if (resource.fullDomain) {
|
||||
await cache.del(`cert:${resource.fullDomain}`);
|
||||
}
|
||||
|
||||
// Generate target once — same cert applies to all sites for this resource
|
||||
const newTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
|
||||
if (!newTargets) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not generate target for resource ${resource.siteResourceId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Construct the old targets — same routing shape but with the previous cert/key.
|
||||
// The newt only uses destPrefix/sourcePrefixes for removal, but we keep the
|
||||
// semantics correct so the update message accurately reflects what changed.
|
||||
const oldTargets: SubnetProxyTargetV2[] = newTargets.map((t) => ({
|
||||
...t,
|
||||
tlsCert: oldCertPem ?? undefined,
|
||||
tlsKey: oldKeyPem ?? undefined
|
||||
}));
|
||||
|
||||
// Push update to each site's newt
|
||||
for (const { siteId } of resourceSiteRows) {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no newt found for site ${siteId}, skipping resource ${resource.siteResourceId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{ oldTargets: oldTargets, newTargets: newTargets },
|
||||
newt.version
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: pushed cert update to newt for site ${siteId}, resource ${resource.siteResourceId}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error pushing cert update for resource ${resource?.siteResourceId}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||
const lookupDomain = certDomain.startsWith("*.")
|
||||
? certDomain.slice(2)
|
||||
: certDomain;
|
||||
|
||||
// 1. Exact baseDomain match (any domain type)
|
||||
const exactMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(eq(domains.baseDomain, lookupDomain))
|
||||
.limit(1);
|
||||
|
||||
if (exactMatch.length > 0) {
|
||||
return exactMatch[0].domainId;
|
||||
}
|
||||
|
||||
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||
// matches a wildcard domain with baseDomain "example.com".
|
||||
const parts = lookupDomain.split(".");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const candidate = parts.slice(i).join(".");
|
||||
if (!candidate) continue;
|
||||
|
||||
const wildcardMatch = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(
|
||||
and(
|
||||
eq(domains.baseDomain, candidate),
|
||||
eq(domains.type, "wildcard")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (wildcardMatch.length > 0) {
|
||||
return wildcardMatch[0].domainId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractFirstCert(pemBundle: string): string | null {
|
||||
const match = pemBundle.match(
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||
);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
async function syncAcmeCerts(
|
||||
acmeJsonPath: string,
|
||||
resolver: string
|
||||
): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||
} catch (err) {
|
||||
logger.debug(`acmeCertSync: could not read ${acmeJsonPath}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let acmeJson: AcmeJson;
|
||||
try {
|
||||
acmeJson = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolverData = acmeJson[resolver];
|
||||
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||
logger.debug(
|
||||
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const cert of resolverData.Certificates) {
|
||||
const domain = cert.domain?.main;
|
||||
|
||||
if (!domain) {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||
"utf8"
|
||||
);
|
||||
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if cert already exists in DB
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
if (storedCertPem === certPem) {
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Cert has changed; capture old values so we can send a correct
|
||||
// update message to the newt after the DB write.
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Decryption failure means we should proceed with the update
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cert expiry from the first cert in the PEM bundle
|
||||
let expiresAt: number | null = null;
|
||||
const firstCertPem = extractFirstCert(certPem);
|
||||
if (firstCertPem) {
|
||||
try {
|
||||
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||
expiresAt = Math.floor(new Date(x509.validTo).getTime() / 1000);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const wildcard = domain.startsWith("*.");
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(
|
||||
keyPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
// For a brand-new cert, push to any SSL resources that were waiting for it
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initAcmeCertSync(): void {
|
||||
if (build == "saas") {
|
||||
logger.debug(`acmeCertSync: skipping ACME cert sync in SaaS build`);
|
||||
return;
|
||||
}
|
||||
|
||||
const privateConfigData = privateConfig.getRawPrivateConfig();
|
||||
|
||||
if (!privateConfigData.flags?.enable_acme_cert_sync) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync is disabled by config flag, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (privateConfigData.flags.use_pangolin_dns) {
|
||||
logger.debug(
|
||||
`acmeCertSync: ACME cert sync requires use_pangolin_dns flag to be disabled, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const acmeJsonPath =
|
||||
privateConfigData.acme?.acme_json_path ??
|
||||
"config/letsencrypt/acme.json";
|
||||
const resolver = privateConfigData.acme?.resolver ?? "letsencrypt";
|
||||
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
|
||||
|
||||
logger.info(
|
||||
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||
);
|
||||
|
||||
// Run immediately on init, then on the configured interval
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||
});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -11,23 +11,15 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import config from "./config";
|
||||
import privateConfig from "./config";
|
||||
import config from "@server/lib/config";
|
||||
import { certificates, db } from "@server/db";
|
||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import logger from "@server/logger";
|
||||
import cache from "#private/lib/cache";
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Define the return type for clarity and type safety
|
||||
export type CertificateResult = {
|
||||
@@ -45,7 +37,7 @@ export async function getValidCertificatesForDomains(
|
||||
domains: Set<string>,
|
||||
useCache: boolean = true
|
||||
): Promise<Array<CertificateResult>> {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
|
||||
const finalResults: CertificateResult[] = [];
|
||||
const domainsToQuery = new Set<string>();
|
||||
@@ -68,7 +60,7 @@ export async function getValidCertificatesForDomains(
|
||||
|
||||
// 2. If all domains were resolved from the cache, return early
|
||||
if (domainsToQuery.size === 0) {
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
@@ -173,22 +165,23 @@ export async function getValidCertificatesForDomains(
|
||||
}
|
||||
}
|
||||
|
||||
const decryptedResults = decryptFinalResults(finalResults);
|
||||
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
function decryptFinalResults(
|
||||
finalResults: CertificateResult[]
|
||||
finalResults: CertificateResult[],
|
||||
secret: string
|
||||
): CertificateResult[] {
|
||||
const validCertsDecrypted = finalResults.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
secret
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, secret);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
|
||||
@@ -34,10 +34,6 @@ export const privateConfigSchema = z.object({
|
||||
}),
|
||||
server: z
|
||||
.object({
|
||||
encryption_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
reo_client_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -95,10 +91,21 @@ export const privateConfigSchema = z.object({
|
||||
.object({
|
||||
enable_redis: z.boolean().optional().default(false),
|
||||
use_pangolin_dns: z.boolean().optional().default(false),
|
||||
use_org_only_idp: z.boolean().optional()
|
||||
use_org_only_idp: z.boolean().optional(),
|
||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
||||
})
|
||||
.optional()
|
||||
.prefault({}),
|
||||
acme: z
|
||||
.object({
|
||||
acme_json_path: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("config/letsencrypt/acme.json"),
|
||||
resolver: z.string().optional().default("letsencrypt"),
|
||||
sync_interval_ms: z.number().optional().default(5000)
|
||||
})
|
||||
.optional(),
|
||||
branding: z
|
||||
.object({
|
||||
app_name: z.string().optional(),
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
|
||||
import {
|
||||
sanitize,
|
||||
encodePath,
|
||||
@@ -267,6 +267,35 @@ export async function getTraefikConfig(
|
||||
});
|
||||
});
|
||||
|
||||
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||
const siteResourcesWithFullDomain = await db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
mode: siteResources.mode
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.enabled, true),
|
||||
isNotNull(siteResources.fullDomain),
|
||||
eq(siteResources.mode, "http"),
|
||||
eq(siteResources.ssl, true),
|
||||
or(
|
||||
eq(sites.exitNodeId, exitNodeId),
|
||||
and(
|
||||
isNull(sites.exitNodeId),
|
||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||
eq(sites.type, "local"),
|
||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||
)
|
||||
),
|
||||
inArray(sites.type, siteTypes)
|
||||
)
|
||||
);
|
||||
|
||||
let validCerts: CertificateResult[] = [];
|
||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||
// create a list of all domains to get certs for
|
||||
@@ -276,6 +305,12 @@ export async function getTraefikConfig(
|
||||
domains.add(resource.fullDomain);
|
||||
}
|
||||
}
|
||||
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (sr.fullDomain) {
|
||||
domains.add(sr.fullDomain);
|
||||
}
|
||||
}
|
||||
// get the valid certs for these domains
|
||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||
@@ -867,6 +902,139 @@ export async function getTraefikConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||
// Traefik generates TLS certificates for those domains even when no
|
||||
// matching resource exists yet.
|
||||
if (siteResourcesWithFullDomain.length > 0) {
|
||||
// Build a set of domains already covered by normal resources
|
||||
const existingFullDomains = new Set<string>();
|
||||
for (const resource of resourcesMap.values()) {
|
||||
if (resource.fullDomain) {
|
||||
existingFullDomains.add(resource.fullDomain);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sr of siteResourcesWithFullDomain) {
|
||||
if (!sr.fullDomain) continue;
|
||||
|
||||
// Skip if this alias is already handled by a resource router
|
||||
if (existingFullDomains.has(sr.fullDomain)) continue;
|
||||
|
||||
const fullDomain = sr.fullDomain;
|
||||
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||
const siteResourceServiceName = `${srKey}-service`;
|
||||
const siteResourceRouterName = `${srKey}-router`;
|
||||
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
||||
|
||||
const maintenancePort = config.getRawConfig().server.next_port;
|
||||
const maintenanceHost =
|
||||
config.getRawConfig().server.internal_hostname;
|
||||
|
||||
if (!config_output.http.routers) {
|
||||
config_output.http.routers = {};
|
||||
}
|
||||
if (!config_output.http.services) {
|
||||
config_output.http.services = {};
|
||||
}
|
||||
if (!config_output.http.middlewares) {
|
||||
config_output.http.middlewares = {};
|
||||
}
|
||||
|
||||
// Service pointing at the internal maintenance/Next.js page
|
||||
config_output.http.services[siteResourceServiceName] = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||
}
|
||||
],
|
||||
passHostHeader: true
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware that rewrites any path to /maintenance-screen
|
||||
config_output.http.middlewares[
|
||||
siteResourceRewriteMiddlewareName
|
||||
] = {
|
||||
replacePathRegex: {
|
||||
regex: "^/(.*)",
|
||||
replacement: "/private-maintenance-screen"
|
||||
}
|
||||
};
|
||||
|
||||
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||
config_output.http.routers[
|
||||
`${siteResourceRouterName}-redirect`
|
||||
] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.http_entrypoint
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100
|
||||
};
|
||||
|
||||
// Determine TLS / cert-resolver configuration
|
||||
let tls: any = {};
|
||||
if (
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
const domainParts = fullDomain.split(".");
|
||||
const wildCard =
|
||||
domainParts.length <= 2
|
||||
? `*.${domainParts.join(".")}`
|
||||
: `*.${domainParts.slice(1).join(".")}`;
|
||||
|
||||
const globalDefaultResolver =
|
||||
config.getRawConfig().traefik.cert_resolver;
|
||||
const globalDefaultPreferWildcard =
|
||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||
|
||||
tls = {
|
||||
certResolver: globalDefaultResolver,
|
||||
...(globalDefaultPreferWildcard
|
||||
? { domains: [{ main: wildCard }] }
|
||||
: {})
|
||||
};
|
||||
} else {
|
||||
// pangolin-dns: only add route if we already have a valid cert
|
||||
const matchingCert = validCerts.find(
|
||||
(cert) => cert.queriedDomain === fullDomain
|
||||
);
|
||||
if (!matchingCert) {
|
||||
logger.debug(
|
||||
`No matching certificate found for siteResource alias: ${fullDomain}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPS router — presence of this entry triggers cert generation
|
||||
config_output.http.routers[siteResourceRouterName] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
middlewares: [siteResourceRewriteMiddlewareName],
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
priority: 100,
|
||||
tls
|
||||
};
|
||||
|
||||
// Assets bypass router — lets Next.js static files load without rewrite
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [
|
||||
config.getRawConfig().traefik.https_entrypoint
|
||||
],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (generateLoginPageRouters) {
|
||||
const exitNodeLoginPages = await db
|
||||
.select({
|
||||
|
||||
@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db, domainNamespaces, resources } from "@server/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||
import { build } from "@server/build";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
subdomain: z.string()
|
||||
subdomain: z.string(),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
|
||||
}
|
||||
const { subdomain } = parsedQuery.data;
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// // return not available
|
||||
// return response<CheckDomainAvailabilityResponse>(res, {
|
||||
// data: {
|
||||
// available: false,
|
||||
// options: []
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const namespaces = await db.select().from(domainNamespaces);
|
||||
let possibleDomains = namespaces.map((ns) => {
|
||||
const desired = `${subdomain}.${ns.domainNamespaceId}`;
|
||||
|
||||
@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
async function query(limit: number, offset: number) {
|
||||
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
|
||||
);
|
||||
}
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// return response<ListDomainNamespacesResponse>(res, {
|
||||
// data: {
|
||||
// domainNamespaces: [],
|
||||
// pagination: {
|
||||
// total: 0,
|
||||
// limit,
|
||||
// offset
|
||||
// }
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const domainNamespacesList = await query(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
|
||||
@@ -24,14 +24,8 @@ import {
|
||||
User,
|
||||
certificates,
|
||||
exitNodeOrgs,
|
||||
RemoteExitNode,
|
||||
olms,
|
||||
newts,
|
||||
clients,
|
||||
sites,
|
||||
domains,
|
||||
orgDomains,
|
||||
targets,
|
||||
loginPage,
|
||||
loginPageOrg,
|
||||
LoginPage,
|
||||
@@ -70,12 +64,9 @@ import {
|
||||
updateAndGenerateEndpointDestinations,
|
||||
updateSiteBandwidth
|
||||
} from "@server/routers/gerbil";
|
||||
import * as gerbil from "@server/routers/gerbil";
|
||||
import logger from "@server/logger";
|
||||
import { decryptData } from "@server/lib/encryption";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import * as fs from "fs";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
@@ -298,25 +289,11 @@ hybridRouter.get(
|
||||
}
|
||||
);
|
||||
|
||||
let encryptionKeyHex = "";
|
||||
let encryptionKey: Buffer;
|
||||
function loadEncryptData() {
|
||||
if (encryptionKey) {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyHex =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key;
|
||||
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
|
||||
}
|
||||
|
||||
// Get valid certificates for given domains (supports wildcard certs)
|
||||
hybridRouter.get(
|
||||
"/certificates/domains",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
loadEncryptData(); // Ensure encryption key is loaded
|
||||
|
||||
const parsed = getCertificatesByDomainsQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
@@ -447,13 +424,13 @@ hybridRouter.get(
|
||||
|
||||
const result = filtered.map((cert) => {
|
||||
// Decrypt and save certificate file
|
||||
const decryptedCert = decryptData(
|
||||
const decryptedCert = decrypt(
|
||||
cert.certFile!, // is not null from query
|
||||
encryptionKey
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decryptData(cert.keyFile!, encryptionKey);
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -833,9 +810,12 @@ hybridRouter.get(
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
|
||||
logger.debug(
|
||||
`User ${userId} has roles in org ${orgId}:`,
|
||||
userOrgRoleRows
|
||||
);
|
||||
|
||||
return response<{ roleId: number, roleName: string }[]>(res, {
|
||||
return response<{ roleId: number; roleName: string }[]>(res, {
|
||||
data: userOrgRoleRows,
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -92,9 +92,14 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({ orgId: sites.orgId, orgSubnet: orgs.subnet })
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysConnection:
|
||||
orgs.settingsLogRetentionDaysConnection
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
@@ -108,6 +113,13 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysConnection === 0) {
|
||||
logger.debug(
|
||||
`Connection log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the CIDR suffix (e.g. "/16") from the org subnet so we can
|
||||
// reconstruct the exact subnet string stored on each client record.
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
|
||||
238
server/private/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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 { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { inflate } from "zlib";
|
||||
import { promisify } from "util";
|
||||
import { logRequestAudit } from "@server/routers/badger/logRequestAudit";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const zlibInflate = promisify(inflate);
|
||||
|
||||
interface HTTPRequestLogData {
|
||||
requestId: string;
|
||||
resourceId: number; // siteResourceId
|
||||
timestamp: string; // ISO 8601
|
||||
method: string;
|
||||
scheme: string; // "http" or "https"
|
||||
host: string;
|
||||
path: string;
|
||||
rawQuery?: string;
|
||||
userAgent?: string;
|
||||
sourceAddr: string; // ip:port
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress a base64-encoded zlib-compressed string into parsed JSON.
|
||||
*/
|
||||
async function decompressRequestLog(
|
||||
compressed: string
|
||||
): Promise<HTTPRequestLogData[]> {
|
||||
const compressedBuffer = Buffer.from(compressed, "base64");
|
||||
const decompressed = await zlibInflate(compressedBuffer);
|
||||
const jsonString = decompressed.toString("utf-8");
|
||||
const parsed = JSON.parse(jsonString);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Decompressed request log data is not an array");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
const { message, client } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Request log received but no newt client in context");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newt.siteId) {
|
||||
logger.warn("Request log received but newt has no siteId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.data?.compressed) {
|
||||
logger.warn("Request log message missing compressed data");
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the org for this site and check retention settings
|
||||
const [site] = await db
|
||||
.select({
|
||||
orgId: sites.orgId,
|
||||
orgSubnet: orgs.subnet,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(sites)
|
||||
.innerJoin(orgs, eq(sites.orgId, orgs.orgId))
|
||||
.where(eq(sites.siteId, newt.siteId));
|
||||
|
||||
if (!site) {
|
||||
logger.warn(
|
||||
`Request log received but site ${newt.siteId} not found in database`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = site.orgId;
|
||||
|
||||
if (site.settingsLogRetentionDaysRequest === 0) {
|
||||
logger.debug(
|
||||
`Request log retention is disabled for org ${orgId}, skipping`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: HTTPRequestLogData[];
|
||||
try {
|
||||
entries = await decompressRequestLog(message.data.compressed);
|
||||
} catch (error) {
|
||||
logger.error("Failed to decompress request log data:", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Request log entries: ${JSON.stringify(entries)}`);
|
||||
|
||||
// Build a map from sourceIp → external endpoint string by joining clients
|
||||
// with clientSitesAssociationsCache. The endpoint is the real-world IP:port
|
||||
// of the client device and is used for GeoIP lookup.
|
||||
const ipToEndpoint = new Map<string, string>();
|
||||
|
||||
const cidrSuffix = site.orgSubnet?.includes("/")
|
||||
? site.orgSubnet.substring(site.orgSubnet.indexOf("/"))
|
||||
: null;
|
||||
|
||||
if (cidrSuffix) {
|
||||
const uniqueSourceAddrs = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.sourceAddr) {
|
||||
uniqueSourceAddrs.add(entry.sourceAddr);
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueSourceAddrs.size > 0) {
|
||||
const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => {
|
||||
const ip = addr.includes(":") ? addr.split(":")[0] : addr;
|
||||
return `${ip}${cidrSuffix}`;
|
||||
});
|
||||
|
||||
const matchedClients = await db
|
||||
.select({
|
||||
subnet: clients.subnet,
|
||||
endpoint: clientSitesAssociationsCache.endpoint
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSitesAssociationsCache,
|
||||
and(
|
||||
eq(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clients.clientId
|
||||
),
|
||||
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.orgId, orgId),
|
||||
inArray(clients.subnet, subnetQueries)
|
||||
)
|
||||
);
|
||||
|
||||
for (const c of matchedClients) {
|
||||
if (c.endpoint) {
|
||||
const ip = c.subnet.split("/")[0];
|
||||
ipToEndpoint.set(ip, c.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
!entry.requestId ||
|
||||
!entry.resourceId ||
|
||||
!entry.method ||
|
||||
!entry.scheme ||
|
||||
!entry.host ||
|
||||
!entry.path ||
|
||||
!entry.sourceAddr
|
||||
) {
|
||||
logger.debug(
|
||||
`Skipping request log entry with missing required fields: ${JSON.stringify(entry)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalRequestURL =
|
||||
entry.scheme +
|
||||
"://" +
|
||||
entry.host +
|
||||
entry.path +
|
||||
(entry.rawQuery ? "?" + entry.rawQuery : "");
|
||||
|
||||
// Resolve the client's external endpoint for GeoIP lookup.
|
||||
// sourceAddr is the WireGuard IP (possibly ip:port), so strip the port.
|
||||
const sourceIp = entry.sourceAddr.includes(":")
|
||||
? entry.sourceAddr.split(":")[0]
|
||||
: entry.sourceAddr;
|
||||
const endpoint = ipToEndpoint.get(sourceIp);
|
||||
let location: string | undefined;
|
||||
if (endpoint) {
|
||||
const endpointIp = endpoint.includes(":")
|
||||
? endpoint.split(":")[0]
|
||||
: endpoint;
|
||||
location = await getCountryCodeForIp(endpointIp);
|
||||
}
|
||||
|
||||
await logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 108,
|
||||
siteResourceId: entry.resourceId,
|
||||
orgId,
|
||||
location
|
||||
},
|
||||
{
|
||||
path: entry.path,
|
||||
originalRequestURL,
|
||||
scheme: entry.scheme,
|
||||
host: entry.host,
|
||||
method: entry.method,
|
||||
tls: entry.tls,
|
||||
requestIp: entry.sourceAddr
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Buffered ${entries.length} request log entry/entries from newt ${newt.newtId} (site ${newt.siteId})`
|
||||
);
|
||||
};
|
||||
@@ -12,3 +12,4 @@
|
||||
*/
|
||||
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
roles,
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
sites,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
@@ -63,10 +63,12 @@ const bodySchema = z
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
@@ -260,10 +262,7 @@ export async function signSshKey(
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
@@ -395,21 +394,12 @@ export async function signSshKey(
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, resource.siteId))
|
||||
.limit(1);
|
||||
const sites = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
@@ -423,43 +413,64 @@ export async function signSshKey(
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
messageIds.push(message.messageId);
|
||||
|
||||
await sendToClient(newt.newtId, {
|
||||
type: `newt/pam/connection`,
|
||||
data: {
|
||||
messageId: message.messageId,
|
||||
orgId: orgId,
|
||||
agentPort: resource.authDaemonPort ?? 22123,
|
||||
externalAuthDaemon: resource.authDaemonMode === "remote",
|
||||
agentHost: resource.destination,
|
||||
caCert: caKeys.publicKeyOpenSSH,
|
||||
username: usernameToUse,
|
||||
niceId: resource.niceId,
|
||||
metadata: {
|
||||
sudoMode: sudoMode,
|
||||
sudoCommands: parsedSudoCommands,
|
||||
homedir: homedir,
|
||||
groups: parsedGroups
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
@@ -480,7 +491,7 @@ export async function signSshKey(
|
||||
metadata: JSON.stringify({
|
||||
resourceId: resource.siteResourceId,
|
||||
resource: resource.name,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds
|
||||
})
|
||||
});
|
||||
|
||||
@@ -494,7 +505,7 @@ export async function signSshKey(
|
||||
: undefined,
|
||||
metadata: {
|
||||
resourceName: resource.name,
|
||||
siteId: resource.siteId,
|
||||
siteId: siteIds[0],
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost
|
||||
},
|
||||
@@ -505,11 +516,13 @@ export async function signSshKey(
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
messageId: message.messageId,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
resourceId: resource.siteResourceId,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
import { handleConnectionLogMessage } from "#private/routers/newt";
|
||||
import { handleConnectionLogMessage, handleRequestLogMessage } from "#private/routers/newt";
|
||||
|
||||
export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
|
||||
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
|
||||
"newt/access-log": handleConnectionLogMessage,
|
||||
"newt/request-log": handleRequestLogMessage,
|
||||
};
|
||||
|
||||
if (build != "saas") {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db";
|
||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm";
|
||||
import { eq, gt, lt, and, count, desc, inArray, isNull, or } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -92,7 +92,10 @@ function getWhere(data: Q) {
|
||||
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||
eq(requestAuditLog.orgId, data.orgId),
|
||||
data.resourceId
|
||||
? eq(requestAuditLog.resourceId, data.resourceId)
|
||||
? or(
|
||||
eq(requestAuditLog.resourceId, data.resourceId),
|
||||
eq(requestAuditLog.siteResourceId, data.resourceId)
|
||||
)
|
||||
: undefined,
|
||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||
@@ -110,15 +113,16 @@ export function queryRequest(data: Q) {
|
||||
return primaryLogsDb
|
||||
.select({
|
||||
id: requestAuditLog.id,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
siteResourceId: requestAuditLog.siteResourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
location: requestAuditLog.location,
|
||||
userAgent: requestAuditLog.userAgent,
|
||||
metadata: requestAuditLog.metadata,
|
||||
@@ -137,37 +141,73 @@ export function queryRequest(data: Q) {
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
||||
// If logs database is the same as main database, we can do a join
|
||||
// Otherwise, we need to fetch resource details separately
|
||||
const resourceIds = logs
|
||||
.map(log => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0) {
|
||||
const siteResourceIds = logs
|
||||
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||
.map(log => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||
}
|
||||
|
||||
// Fetch resource details from main database
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
|
||||
// Create a map for quick lookup
|
||||
const resourceMap = new Map(
|
||||
resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }])
|
||||
);
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
niceId: resources.niceId
|
||||
})
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
for (const r of resourceDetails) {
|
||||
resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich logs with resource details
|
||||
return logs.map(log => ({
|
||||
...log,
|
||||
resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null,
|
||||
resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null
|
||||
}));
|
||||
return logs.map(log => {
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
return {
|
||||
...log,
|
||||
resourceName: details?.name ?? null,
|
||||
resourceNiceId: details?.niceId ?? null
|
||||
};
|
||||
} else if (log.siteResourceId != null) {
|
||||
const details = siteResourceMap.get(log.siteResourceId);
|
||||
return {
|
||||
...log,
|
||||
resourceId: log.siteResourceId,
|
||||
resourceName: details?.name ?? null,
|
||||
resourceNiceId: details?.niceId ?? null
|
||||
};
|
||||
}
|
||||
return { ...log, resourceName: null, resourceNiceId: null };
|
||||
});
|
||||
}
|
||||
|
||||
export function countRequestQuery(data: Q) {
|
||||
@@ -211,7 +251,8 @@ async function queryUniqueFilterAttributes(
|
||||
uniqueLocations,
|
||||
uniqueHosts,
|
||||
uniquePaths,
|
||||
uniqueResources
|
||||
uniqueResources,
|
||||
uniqueSiteResources
|
||||
] = await Promise.all([
|
||||
primaryLogsDb
|
||||
.selectDistinct({ actor: requestAuditLog.actor })
|
||||
@@ -239,6 +280,13 @@ async function queryUniqueFilterAttributes(
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions)
|
||||
.limit(DISTINCT_LIMIT + 1),
|
||||
primaryLogsDb
|
||||
.selectDistinct({
|
||||
id: requestAuditLog.siteResourceId
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(and(baseConditions, isNull(requestAuditLog.resourceId)))
|
||||
.limit(DISTINCT_LIMIT + 1)
|
||||
]);
|
||||
|
||||
@@ -259,6 +307,10 @@ async function queryUniqueFilterAttributes(
|
||||
.map(row => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
const siteResourceIds = uniqueSiteResources
|
||||
.map(row => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
@@ -270,10 +322,31 @@ async function queryUniqueFilterAttributes(
|
||||
.from(resources)
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
resourcesWithNames = resourceDetails.map(r => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}));
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map(r => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name
|
||||
})
|
||||
.from(siteResources)
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map(r => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.name
|
||||
}))
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -28,6 +28,7 @@ export type QueryRequestAuditLogResponse = {
|
||||
actor: string | null;
|
||||
actorId: string | null;
|
||||
resourceId: number | null;
|
||||
siteResourceId: number | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceName: string | null;
|
||||
ip: string | null;
|
||||
|
||||
@@ -18,6 +18,7 @@ Reasons:
|
||||
105 - Valid Password
|
||||
106 - Valid email
|
||||
107 - Valid SSO
|
||||
108 - Connected Client
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
@@ -38,6 +39,7 @@ const auditLogBuffer: Array<{
|
||||
metadata: any;
|
||||
action: boolean;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
reason: number;
|
||||
location?: string;
|
||||
originalRequestURL: string;
|
||||
@@ -186,6 +188,7 @@ export async function logRequestAudit(
|
||||
action: boolean;
|
||||
reason: number;
|
||||
resourceId?: number;
|
||||
siteResourceId?: number;
|
||||
orgId?: string;
|
||||
location?: string;
|
||||
user?: { username: string; userId: string };
|
||||
@@ -262,6 +265,7 @@ export async function logRequestAudit(
|
||||
metadata: sanitizeString(metadata),
|
||||
action: data.action,
|
||||
resourceId: data.resourceId,
|
||||
siteResourceId: data.siteResourceId,
|
||||
reason: data.reason,
|
||||
location: sanitizeString(data.location),
|
||||
originalRequestURL: sanitizeString(body.originalRequestURL) ?? "",
|
||||
|
||||
@@ -440,6 +440,12 @@ authenticated.get(
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-resource-aliases",
|
||||
verifyOrgAccess,
|
||||
resource.listUserResourceAliases
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
networks,
|
||||
resources,
|
||||
Site,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// Filter out any null values from peers that didn't have an olm
|
||||
const validPeers = peers.filter((peer) => peer !== null);
|
||||
|
||||
// Get all enabled site resources for this site
|
||||
// Get all enabled site resources for this site by joining through siteNetworks and networks
|
||||
const allSiteResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId));
|
||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.where(eq(siteNetworks.siteId, siteId))
|
||||
.then((rows) => rows.map((r) => r.siteResources));
|
||||
|
||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||
|
||||
@@ -168,13 +173,13 @@ export async function buildClientConfigurationForNewtClient(
|
||||
)
|
||||
);
|
||||
|
||||
const resourceTarget = generateSubnetProxyTargetV2(
|
||||
const resourceTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
);
|
||||
|
||||
if (resourceTarget) {
|
||||
targetsToSend.push(resourceTarget);
|
||||
if (resourceTargets) {
|
||||
targetsToSend.push(...resourceTargets);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { convertTargetsIfNessicary } from "../client/targets";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const newt = client as Newt;
|
||||
|
||||
@@ -56,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
|
||||
if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) {
|
||||
logger.warn(
|
||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
`Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the site reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -113,7 +113,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
exitNode
|
||||
);
|
||||
|
||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
|
||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
|
||||
return {
|
||||
message: {
|
||||
9
server/routers/newt/handleRequestLogMessage.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
|
||||
export async function flushRequestLogToDb(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
export const handleRequestLogMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
};
|
||||
@@ -2,11 +2,12 @@ export * from "./createNewt";
|
||||
export * from "./getNewtToken";
|
||||
export * from "./handleNewtRegisterMessage";
|
||||
export * from "./handleReceiveBandwidthMessage";
|
||||
export * from "./handleGetConfigMessage";
|
||||
export * from "./handleNewtGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
export * from "./handleNewtDisconnectingMessage";
|
||||
export * from "./handleConnectionLogMessage";
|
||||
export * from "./handleRequestLogMessage";
|
||||
export * from "./registerNewt";
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
exitNodes,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(networks.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(siteNetworks.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (jitMode) {
|
||||
// Add site configuration to the array
|
||||
siteConfigurations.push({
|
||||
|
||||
@@ -17,7 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
|
||||
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
|
||||
import { OlmErrorCodes, sendOlmError } from "./error";
|
||||
import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
||||
import { Alias } from "@server/lib/ip";
|
||||
import { build } from "@server/build";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
db,
|
||||
exitNodes,
|
||||
Site,
|
||||
siteResources
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Olm, sites } from "@server/db";
|
||||
import { clients, Olm } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { initPeerAddHandshake } from "./peers";
|
||||
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
const { siteId, resourceId, chainId } = message.data;
|
||||
|
||||
let site: Site | null = null;
|
||||
const sendCancel = async () => {
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: { chainId }
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
};
|
||||
|
||||
let sitesToProcess: Site[] = [];
|
||||
|
||||
if (siteId) {
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
if (siteRes) {
|
||||
site = siteRes;
|
||||
sitesToProcess = [siteRes];
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId && !site) {
|
||||
} else if (resourceId) {
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
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`
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
`handleOlmServerInitAddPeerHandshake: 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);
|
||||
});
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const siteIdFromResource = resource.siteId;
|
||||
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIdFromResource));
|
||||
if (!siteRes) {
|
||||
if (!resource.networkId) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
site = siteRes;
|
||||
// Get all sites associated with this resource's network via siteNetworks
|
||||
const siteRows = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId));
|
||||
|
||||
if (!siteRows || siteRows.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch full site objects for all network members
|
||||
const foundSites = await Promise.all(
|
||||
siteRows.map(async ({ siteId: sid }) => {
|
||||
const [s] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, sid))
|
||||
.limit(1);
|
||||
return s ?? null;
|
||||
})
|
||||
);
|
||||
|
||||
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
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)
|
||||
)
|
||||
);
|
||||
let handshakeInitiated = false;
|
||||
|
||||
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,
|
||||
for (const site of sitesToProcess) {
|
||||
// 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.warn(
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [exitNode] = await db
|
||||
.select()
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
|
||||
|
||||
if (!exitNode) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Trigger the peer add handshake — if the peer was already added this will be a no-op
|
||||
await initPeerAddHandshake(
|
||||
client.clientId,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
siteId: site.siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint
|
||||
}
|
||||
},
|
||||
{ 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`
|
||||
chainId
|
||||
);
|
||||
return;
|
||||
|
||||
handshakeInitiated = true;
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
if (!handshakeInitiated) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
};
|
||||
@@ -1,43 +1,25 @@
|
||||
import {
|
||||
Client,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
Org,
|
||||
orgs,
|
||||
roleClients,
|
||||
roles,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userClients,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
exitNodes,
|
||||
Olm,
|
||||
olms,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../newt/peers";
|
||||
import logger from "@server/logger";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
getNextAvailableClientSubnet
|
||||
} from "@server/lib/ip";
|
||||
import { generateRemoteSubnets } from "@server/lib/ip";
|
||||
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
addPeer as newtAddPeer,
|
||||
deletePeer as newtDeletePeer
|
||||
} from "@server/routers/newt/peers";
|
||||
|
||||
export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
eq(networks.networkId, siteNetworks.networkId),
|
||||
eq(siteNetworks.siteId, site.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
@@ -24,6 +24,8 @@ import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -112,7 +114,10 @@ export async function createResource(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
if (
|
||||
req.user &&
|
||||
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -193,6 +198,29 @@ async function createHttpResource(
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-13");
|
||||
const userCreatedDate = new Date(req.user?.dateCreated || new Date());
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
|
||||
@@ -142,9 +142,10 @@ export async function getUserResources(
|
||||
let siteResourcesData: Array<{
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
scheme: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
@@ -154,9 +155,10 @@ export async function getUserResources(
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId,
|
||||
destination: siteResources.destination,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
scheme: siteResources.scheme,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
aliasAddress: siteResources.aliasAddress
|
||||
@@ -240,7 +242,7 @@ export async function getUserResources(
|
||||
name: siteResource.name,
|
||||
destination: siteResource.destination,
|
||||
mode: siteResource.mode,
|
||||
protocol: siteResource.protocol,
|
||||
protocol: siteResource.scheme,
|
||||
enabled: siteResource.enabled,
|
||||
alias: siteResource.alias,
|
||||
aliasAddress: siteResource.aliasAddress,
|
||||
@@ -249,7 +251,7 @@ export async function getUserResources(
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
data: {
|
||||
resources: resourcesWithAuth,
|
||||
siteResources: siteResourcesFormatted
|
||||
},
|
||||
@@ -289,7 +291,7 @@ export type GetUserResourcesResponse = {
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
type: 'site';
|
||||
type: "site";
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
||||
export * from "./listUserResourceAliases";
|
||||
export * from "./setResourceHeaderAuth";
|
||||
export * from "./addEmailToResourceWhitelist";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
resourcePincode,
|
||||
resources,
|
||||
roleResources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets,
|
||||
userResources
|
||||
@@ -138,6 +139,7 @@ export type ResourceWithTargets = {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||
siteName: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -446,14 +448,16 @@ export async function listResources(
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
healthStatus: targetHealthCheck.hcHealth,
|
||||
hcEnabled: targetHealthCheck.hcEnabled
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
siteName: sites.name
|
||||
})
|
||||
.from(targets)
|
||||
.where(inArray(targets.resourceId, resourceIdList))
|
||||
.leftJoin(
|
||||
targetHealthCheck,
|
||||
eq(targetHealthCheck.targetId, targets.targetId)
|
||||
);
|
||||
)
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
|
||||
262
server/routers/resource/listUserResourceAliases.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
db,
|
||||
siteResources,
|
||||
userSiteResources,
|
||||
roleSiteResources,
|
||||
userOrgRoles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { localCache } from "#dynamic/lib/cache";
|
||||
|
||||
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
||||
|
||||
function userResourceAliasesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) {
|
||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`;
|
||||
}
|
||||
|
||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserResourceAliasesQuerySchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListUserResourceAliasesResponse = PaginatedResponse<{
|
||||
aliases: string[];
|
||||
}>;
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "get",
|
||||
// path: "/org/{orgId}/user-resource-aliases",
|
||||
// description:
|
||||
// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.",
|
||||
// tags: [OpenAPITags.PrivateResource],
|
||||
// request: {
|
||||
// params: z.object({
|
||||
// orgId: z.string()
|
||||
// }),
|
||||
// query: listUserResourceAliasesQuerySchema
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function listUserResourceAliases(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUserResourceAliasesQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = userResourceAliasesCacheKey(
|
||||
orgId,
|
||||
userId,
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||
localCache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data: cachedData,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.map((r) => r.roleId));
|
||||
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId));
|
||||
|
||||
const roleSiteResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
siteResourceId: roleSiteResources.siteResourceId
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [directSiteResourceResults, roleSiteResourceResults] =
|
||||
await Promise.all([
|
||||
directSiteResourcesQuery,
|
||||
roleSiteResourcesQuery
|
||||
]);
|
||||
|
||||
const accessibleSiteResourceIds = [
|
||||
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||
];
|
||||
|
||||
if (accessibleSiteResourceIds.length === 0) {
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.mode, "host"),
|
||||
isNotNull(siteResources.alias),
|
||||
ne(siteResources.alias, ""),
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||
);
|
||||
|
||||
const baseSelect = () =>
|
||||
db
|
||||
.select({ alias: siteResources.alias })
|
||||
.from(siteResources)
|
||||
.where(whereClause);
|
||||
|
||||
const countQuery = db.$count(baseSelect().as("filtered_aliases"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseSelect()
|
||||
.orderBy(asc(siteResources.alias))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const aliases = rows.map((r) => r.alias as string);
|
||||
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
|
||||
if (data.headers) {
|
||||
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
|
||||
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||
return data.headers.every((h) => validHeaderValue.test(h.value));
|
||||
return data.headers.every((h) =>
|
||||
validHeaderValue.test(h.value)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -318,6 +321,34 @@ async function updateHttpResource(
|
||||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
if (
|
||||
build == "saas" &&
|
||||
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
|
||||
) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-13");
|
||||
const userCreatedDate = new Date(
|
||||
req.user?.dateCreated || new Date()
|
||||
);
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
@@ -366,7 +397,7 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Site, siteResources } from "@server/db";
|
||||
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
||||
import { newts, newtSessions, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -71,18 +71,23 @@ export async function deleteSite(
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
}
|
||||
} else if (site.type == "newt") {
|
||||
// delete all of the site resources on this site
|
||||
const siteResourcesOnSite = trx
|
||||
.delete(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId))
|
||||
.returning();
|
||||
const networks = await trx
|
||||
.select({ networkId: siteNetworks.networkId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.siteId, siteId));
|
||||
|
||||
// loop through them
|
||||
for (const removedSiteResource of await siteResourcesOnSite) {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
trx
|
||||
);
|
||||
for (const network of await networks) {
|
||||
const [siteResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.networkId, network.networkId));
|
||||
if (siteResource) {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
siteResource,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// get the newt on the site by querying the newt table for siteId
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
orgs,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
siteNetworks,
|
||||
networks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
@@ -17,17 +19,18 @@ import {
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
|
||||
const createSiteResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -36,11 +39,12 @@ const createSiteResourceParamsSchema = z.strictObject({
|
||||
const createSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
siteId: z.int(),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
ssl: z.boolean().optional(), // only used for http mode
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
siteIds: z.array(z.int()),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
destinationPort: z.int().positive().optional(),
|
||||
destination: z.string().min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
alias: z
|
||||
@@ -57,20 +61,24 @@ const createSiteResourceSchema = z
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().optional(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional()
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||
domainId: z.string().optional(), // only used for http mode, we need this to verify the alias is unique within the org
|
||||
subdomain: z.string().optional() // only used for http mode, we need this to verify the alias is unique within the org
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode === "host") {
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||
.safeParse(data.destination).success;
|
||||
if (data.mode == "host") {
|
||||
// Check if it's a valid IP address using zod (v4 or v6)
|
||||
const isValidIP = z
|
||||
// .union([z.ipv4(), z.ipv6()])
|
||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||
.safeParse(data.destination).success;
|
||||
|
||||
if (isValidIP) {
|
||||
return true;
|
||||
if (isValidIP) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||
@@ -105,6 +113,21 @@ const createSiteResourceSchema = z
|
||||
{
|
||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "http") return true;
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||
}
|
||||
);
|
||||
|
||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||
@@ -159,13 +182,14 @@ export async function createSiteResource(
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
siteIds,
|
||||
mode,
|
||||
// protocol,
|
||||
scheme,
|
||||
// proxyPort,
|
||||
// destinationPort,
|
||||
destinationPort,
|
||||
destination,
|
||||
enabled,
|
||||
ssl,
|
||||
alias,
|
||||
userIds,
|
||||
roleIds,
|
||||
@@ -174,18 +198,36 @@ export async function createSiteResource(
|
||||
udpPortRangeString,
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode
|
||||
authDaemonMode,
|
||||
domainId,
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
|
||||
if (mode == "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
const sitesToAssign = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)));
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
if (sitesToAssign.length !== siteIds.length) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
@@ -226,29 +268,50 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
||||
// if (mode === "port" && protocol && proxyPort) {
|
||||
// const [existingResource] = await db
|
||||
// .select()
|
||||
// .from(siteResources)
|
||||
// .where(
|
||||
// and(
|
||||
// eq(siteResources.siteId, siteId),
|
||||
// eq(siteResources.orgId, orgId),
|
||||
// eq(siteResources.protocol, protocol),
|
||||
// eq(siteResources.proxyPort, proxyPort)
|
||||
// )
|
||||
// )
|
||||
// .limit(1);
|
||||
// if (existingResource && existingResource.siteResourceId) {
|
||||
// return next(
|
||||
// createHttpError(
|
||||
// HttpCode.CONFLICT,
|
||||
// "A resource with the same protocol and proxy port already exists"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
if (domainId && alias) {
|
||||
// throw an error because we can only have one or the other
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Alias and domain cannot both be set. Please choose one or the other."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let fullDomain: string | null = null;
|
||||
let finalSubdomain: string | null = null;
|
||||
if (domainId) {
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
orgId,
|
||||
subdomain
|
||||
);
|
||||
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
|
||||
);
|
||||
}
|
||||
|
||||
fullDomain = domainResult.fullDomain;
|
||||
finalSubdomain = domainResult.subdomain;
|
||||
|
||||
// make sure the full domain is unique
|
||||
const existingResource = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.fullDomain, fullDomain));
|
||||
|
||||
if (existingResource.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the alias is unique within the org if provided
|
||||
if (alias) {
|
||||
@@ -280,27 +343,49 @@ export async function createSiteResource(
|
||||
|
||||
const niceId = await getUniqueSiteResourceName(orgId);
|
||||
let aliasAddress: string | null = null;
|
||||
if (mode == "host") {
|
||||
// we can only have an alias on a host
|
||||
if (mode === "host" || mode === "http") {
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||
}
|
||||
|
||||
let newSiteResource: SiteResource | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!network) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Failed to create network`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Create the site resource
|
||||
const insertValues: typeof siteResources.$inferInsert = {
|
||||
siteId,
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
mode: mode as "host" | "cidr",
|
||||
mode,
|
||||
ssl,
|
||||
networkId: network.networkId,
|
||||
destination,
|
||||
scheme,
|
||||
destinationPort,
|
||||
enabled,
|
||||
alias,
|
||||
alias: alias ? alias.trim() : null,
|
||||
aliasAddress,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString,
|
||||
disableIcmp
|
||||
disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain
|
||||
};
|
||||
if (isLicensedSshPam) {
|
||||
if (authDaemonPort !== undefined)
|
||||
@@ -317,6 +402,13 @@ export async function createSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -359,16 +451,21 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
for (const siteToAssign of sitesToAssign) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteToAssign.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Newt not found for site ${siteToAssign.siteId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
@@ -387,7 +484,7 @@ export async function createSiteResource(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
|
||||
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
.returning();
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
.limit(1);
|
||||
// not sure why this is here...
|
||||
// const [newt] = await trx
|
||||
// .select()
|
||||
// .from(newts)
|
||||
// .where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
// .limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
}
|
||||
// if (!newt) {
|
||||
// return next(
|
||||
// createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
// );
|
||||
// }
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
|
||||
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
|
||||
.transform((val) => (val ? Number(val) : undefined))
|
||||
.pipe(z.int().positive().optional())
|
||||
.optional(),
|
||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
async function query(
|
||||
siteResourceId?: number,
|
||||
siteId?: number,
|
||||
niceId?: string,
|
||||
orgId?: string
|
||||
) {
|
||||
if (siteResourceId && siteId && orgId) {
|
||||
if (siteResourceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && siteId && orgId) {
|
||||
} else if (niceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.niceId, niceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
@@ -84,7 +80,6 @@ registry.registerPath({
|
||||
request: {
|
||||
params: z.object({
|
||||
niceId: z.string(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
@@ -107,10 +102,10 @@ export async function getSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
||||
const { siteResourceId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||
const siteResource = await query(siteResourceId, niceId, orgId);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
mode: z
|
||||
.enum(["host", "cidr"])
|
||||
.enum(["host", "cidr", "http"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["host", "cidr"],
|
||||
enum: ["host", "cidr", "http"],
|
||||
description: "Filter site resources by mode"
|
||||
}),
|
||||
sort_by: z
|
||||
@@ -73,22 +73,58 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteResources: (SiteResource & {
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
siteAddress: string | null;
|
||||
siteOnlines: boolean[];
|
||||
siteIds: number[];
|
||||
siteNames: string[];
|
||||
siteNiceIds: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
})[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Returns an aggregation expression compatible with both SQLite and PostgreSQL.
|
||||
* - SQLite: json_group_array(col) → returns a JSON array string, parsed after fetch
|
||||
* - PostgreSQL: array_agg(col) → returns a native array
|
||||
*/
|
||||
function aggCol<T>(column: any) {
|
||||
if (DB_TYPE === "sqlite") {
|
||||
return sql<T>`json_group_array(${column})`;
|
||||
}
|
||||
return sql<T>`array_agg(${column})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* For SQLite the aggregated columns come back as JSON strings; parse them into
|
||||
* proper arrays. For PostgreSQL the driver already returns native arrays, so
|
||||
* the row is returned unchanged.
|
||||
*/
|
||||
function transformSiteResourceRow(row: any) {
|
||||
if (DB_TYPE !== "sqlite") {
|
||||
return row;
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
siteNames: JSON.parse(row.siteNames) as string[],
|
||||
siteNiceIds: JSON.parse(row.siteNiceIds) as string[],
|
||||
siteIds: JSON.parse(row.siteIds) as number[],
|
||||
siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[],
|
||||
// SQLite stores booleans as 0/1 integers
|
||||
siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map(
|
||||
(v) => v === 1
|
||||
) as boolean[]
|
||||
};
|
||||
}
|
||||
|
||||
function querySiteResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
ssl: siteResources.ssl,
|
||||
scheme: siteResources.scheme,
|
||||
proxyPort: siteResources.proxyPort,
|
||||
destinationPort: siteResources.destinationPort,
|
||||
destination: siteResources.destination,
|
||||
@@ -100,12 +136,24 @@ function querySiteResourcesBase() {
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
authDaemonMode: siteResources.authDaemonMode,
|
||||
authDaemonPort: siteResources.authDaemonPort,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
subdomain: siteResources.subdomain,
|
||||
domainId: siteResources.domainId,
|
||||
fullDomain: siteResources.fullDomain,
|
||||
networkId: siteResources.networkId,
|
||||
defaultNetworkId: siteResources.defaultNetworkId,
|
||||
siteNames: aggCol<string[]>(sites.name),
|
||||
siteNiceIds: aggCol<string[]>(sites.niceId),
|
||||
siteIds: aggCol<number[]>(sites.siteId),
|
||||
siteAddresses: aggCol<(string | null)[]>(sites.address),
|
||||
siteOnlines: aggCol<boolean[]>(sites.online)
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteResources.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.groupBy(siteResources.siteResourceId);
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
@@ -193,10 +241,12 @@ export async function listAllSiteResourcesByOrg(
|
||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||
|
||||
const countQuery = db.$count(
|
||||
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
|
||||
querySiteResourcesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_site_resources")
|
||||
);
|
||||
|
||||
const [siteResourcesList, totalCount] = await Promise.all([
|
||||
const [siteResourcesRaw, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
@@ -210,6 +260,8 @@ export async function listAllSiteResourcesByOrg(
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||
|
||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||
data: {
|
||||
siteResources: siteResourcesList,
|
||||
@@ -233,4 +285,4 @@ export async function listAllSiteResourcesByOrg(
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, networks, siteNetworks } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -108,13 +108,21 @@ export async function listSiteResources(
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// Get site resources
|
||||
// Get site resources by joining networks to siteResources via siteNetworks
|
||||
const siteResourcesList = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.from(siteNetworks)
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteNetworks.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
@@ -128,6 +136,7 @@ export async function listSiteResources(
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
success: true,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import {
|
||||
clientSiteResources,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
@@ -7,13 +6,21 @@ import {
|
||||
orgs,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
networks,
|
||||
Transaction,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import response from "@server/lib/response";
|
||||
import { eq, and, ne, inArray } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
generateRemoteSubnets,
|
||||
@@ -22,12 +29,8 @@ import {
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -40,7 +43,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
|
||||
const updateSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
siteId: z.int(),
|
||||
siteIds: z.array(z.int()),
|
||||
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||
niceId: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -51,10 +55,11 @@ const updateSiteResourceSchema = z
|
||||
)
|
||||
.optional(),
|
||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||
mode: z.enum(["host", "cidr"]).optional(),
|
||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||
mode: z.enum(["host", "cidr", "http"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).nullish(),
|
||||
// proxyPort: z.int().positive().nullish(),
|
||||
// destinationPort: z.int().positive().nullish(),
|
||||
destinationPort: z.int().positive().nullish(),
|
||||
destination: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
alias: z
|
||||
@@ -71,7 +76,9 @@ const updateSiteResourceSchema = z
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
authDaemonPort: z.int().positive().nullish(),
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional()
|
||||
authDaemonMode: z.enum(["site", "remote"]).optional(),
|
||||
domainId: z.string().optional(),
|
||||
subdomain: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -118,6 +125,23 @@ const updateSiteResourceSchema = z
|
||||
{
|
||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.mode !== "http") return true;
|
||||
return (
|
||||
data.scheme !== undefined &&
|
||||
data.scheme !== null &&
|
||||
data.destinationPort !== undefined &&
|
||||
data.destinationPort !== null &&
|
||||
data.destinationPort >= 1 &&
|
||||
data.destinationPort <= 65535
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||
}
|
||||
);
|
||||
|
||||
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
||||
@@ -172,11 +196,14 @@ export async function updateSiteResource(
|
||||
const { siteResourceId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteId, // because it can change
|
||||
siteIds, // because it can change
|
||||
niceId,
|
||||
mode,
|
||||
scheme,
|
||||
destination,
|
||||
destinationPort,
|
||||
alias,
|
||||
ssl,
|
||||
enabled,
|
||||
userIds,
|
||||
roleIds,
|
||||
@@ -185,19 +212,11 @@ export async function updateSiteResource(
|
||||
udpPortRangeString,
|
||||
disableIcmp,
|
||||
authDaemonPort,
|
||||
authDaemonMode
|
||||
authDaemonMode,
|
||||
domainId,
|
||||
subdomain
|
||||
} = parsedBody.data;
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// Check if site resource exists
|
||||
const [existingSiteResource] = await db
|
||||
.select()
|
||||
@@ -211,6 +230,21 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (mode == "http") {
|
||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||
existingSiteResource.orgId,
|
||||
tierMatrix[TierFeature.HTTPPrivateResources]
|
||||
);
|
||||
if (!hasHttpFeature) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isLicensedSshPam = await isLicensedOrSubscribed(
|
||||
existingSiteResource.orgId,
|
||||
tierMatrix.sshPam
|
||||
@@ -237,6 +271,23 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const sitesToAssign = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
inArray(sites.siteId, siteIds),
|
||||
eq(sites.orgId, existingSiteResource.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (sitesToAssign.length !== siteIds.length) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||
);
|
||||
}
|
||||
|
||||
// Only check if destination is an IP address
|
||||
const isIp = z
|
||||
.union([z.ipv4(), z.ipv6()])
|
||||
@@ -254,22 +305,60 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
let existingSite = site;
|
||||
let siteChanged = false;
|
||||
if (existingSiteResource.siteId !== siteId) {
|
||||
siteChanged = true;
|
||||
// get the existing site
|
||||
[existingSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, existingSiteResource.siteId))
|
||||
.limit(1);
|
||||
let sitesChanged = false;
|
||||
const existingSiteIds = existingSiteResource.networkId
|
||||
? await db
|
||||
.select()
|
||||
.from(siteNetworks)
|
||||
.where(
|
||||
eq(siteNetworks.networkId, existingSiteResource.networkId)
|
||||
)
|
||||
: [];
|
||||
|
||||
if (!existingSite) {
|
||||
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
|
||||
const newSiteIdSet = new Set(siteIds);
|
||||
|
||||
if (
|
||||
existingSiteIdSet.size !== newSiteIdSet.size ||
|
||||
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
|
||||
) {
|
||||
sitesChanged = true;
|
||||
}
|
||||
|
||||
let fullDomain: string | null = null;
|
||||
let finalSubdomain: string | null = null;
|
||||
if (domainId) {
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
org.orgId,
|
||||
subdomain
|
||||
);
|
||||
|
||||
if (!domainResult.success) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, domainResult.error)
|
||||
);
|
||||
}
|
||||
|
||||
fullDomain = domainResult.fullDomain;
|
||||
finalSubdomain = domainResult.subdomain;
|
||||
|
||||
// make sure the full domain is unique
|
||||
const [existingDomain] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.fullDomain, fullDomain));
|
||||
|
||||
if (
|
||||
existingDomain &&
|
||||
existingDomain.siteResourceId !==
|
||||
existingSiteResource.siteResourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Existing site not found"
|
||||
HttpCode.CONFLICT,
|
||||
"Resource with that domain already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -302,7 +391,7 @@ export async function updateSiteResource(
|
||||
let updatedSiteResource: SiteResource | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
|
||||
if (siteChanged) {
|
||||
if (sitesChanged) {
|
||||
// delete the existing site resource
|
||||
await trx
|
||||
.delete(siteResources)
|
||||
@@ -343,15 +432,20 @@ export async function updateSiteResource(
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name,
|
||||
siteId,
|
||||
niceId,
|
||||
mode,
|
||||
scheme,
|
||||
ssl,
|
||||
destination,
|
||||
destinationPort,
|
||||
enabled,
|
||||
alias: alias && alias.trim() ? alias : null,
|
||||
alias: alias ? alias.trim() : null,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString,
|
||||
disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(
|
||||
@@ -372,6 +466,23 @@ export async function updateSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
// delete the site - site resources associations
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
updatedSiteResource.networkId!
|
||||
)
|
||||
);
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: updatedSiteResource.networkId!
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -447,14 +558,20 @@ export async function updateSiteResource(
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: name,
|
||||
siteId: siteId,
|
||||
niceId: niceId,
|
||||
mode: mode,
|
||||
scheme,
|
||||
ssl,
|
||||
destination: destination,
|
||||
destinationPort: destinationPort,
|
||||
enabled: enabled,
|
||||
alias: alias && alias.trim() ? alias : null,
|
||||
alias: alias ? alias.trim() : null,
|
||||
tcpPortRangeString: tcpPortRangeString,
|
||||
udpPortRangeString: udpPortRangeString,
|
||||
disableIcmp: disableIcmp,
|
||||
domainId,
|
||||
subdomain: finalSubdomain,
|
||||
fullDomain,
|
||||
...sshPamSet
|
||||
})
|
||||
.where(
|
||||
@@ -464,6 +581,23 @@ export async function updateSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
// delete the site - site resources associations
|
||||
await trx
|
||||
.delete(siteNetworks)
|
||||
.where(
|
||||
eq(
|
||||
siteNetworks.networkId,
|
||||
updatedSiteResource.networkId!
|
||||
)
|
||||
);
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: updatedSiteResource.networkId!
|
||||
});
|
||||
}
|
||||
|
||||
await trx
|
||||
.delete(clientSiteResources)
|
||||
.where(
|
||||
@@ -533,14 +667,15 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Updated site resource ${siteResourceId} for site ${siteId}`
|
||||
);
|
||||
logger.info(`Updated site resource ${siteResourceId}`);
|
||||
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
existingSiteResource,
|
||||
updatedSiteResource,
|
||||
{ siteId: site.siteId, orgId: site.orgId },
|
||||
siteIds.map((siteId) => ({
|
||||
siteId,
|
||||
orgId: existingSiteResource.orgId
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
@@ -567,7 +702,7 @@ export async function updateSiteResource(
|
||||
export async function handleMessagingForUpdatedSiteResource(
|
||||
existingSiteResource: SiteResource | undefined,
|
||||
updatedSiteResource: SiteResource,
|
||||
site: { siteId: number; orgId: string },
|
||||
sites: { siteId: number; orgId: string }[],
|
||||
trx: Transaction
|
||||
) {
|
||||
logger.debug(
|
||||
@@ -589,9 +724,14 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
const destinationChanged =
|
||||
existingSiteResource &&
|
||||
existingSiteResource.destination !== updatedSiteResource.destination;
|
||||
const destinationPortChanged =
|
||||
existingSiteResource &&
|
||||
existingSiteResource.destinationPort !==
|
||||
updatedSiteResource.destinationPort;
|
||||
const aliasChanged =
|
||||
existingSiteResource &&
|
||||
existingSiteResource.alias !== updatedSiteResource.alias;
|
||||
(existingSiteResource.alias !== updatedSiteResource.alias ||
|
||||
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias
|
||||
const portRangesChanged =
|
||||
existingSiteResource &&
|
||||
(existingSiteResource.tcpPortRangeString !==
|
||||
@@ -603,106 +743,122 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
|
||||
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
|
||||
|
||||
if (destinationChanged || aliasChanged || portRangesChanged) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
throw new Error(
|
||||
"Newt not found for site during site resource update"
|
||||
);
|
||||
}
|
||||
|
||||
// Only update targets on newt if destination changed
|
||||
if (destinationChanged || portRangesChanged) {
|
||||
const oldTarget = generateSubnetProxyTargetV2(
|
||||
existingSiteResource,
|
||||
mergedAllClients
|
||||
);
|
||||
const newTarget = generateSubnetProxyTargetV2(
|
||||
updatedSiteResource,
|
||||
mergedAllClients
|
||||
);
|
||||
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{
|
||||
oldTargets: oldTarget ? [oldTarget] : [],
|
||||
newTargets: newTarget ? [newTarget] : []
|
||||
},
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
|
||||
const olmJobs: Promise<void>[] = [];
|
||||
for (const client of mergedAllClients) {
|
||||
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
|
||||
// todo: optimize this query if needed
|
||||
const oldDestinationStillInUseSites = await trx
|
||||
if (
|
||||
destinationChanged ||
|
||||
aliasChanged ||
|
||||
portRangesChanged ||
|
||||
destinationPortChanged
|
||||
) {
|
||||
for (const site of sites) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
existingSiteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
existingSiteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
throw new Error(
|
||||
"Newt not found for site during site resource update"
|
||||
);
|
||||
}
|
||||
|
||||
// Only update targets on newt if destination changed
|
||||
if (
|
||||
destinationChanged ||
|
||||
portRangesChanged ||
|
||||
destinationPortChanged
|
||||
) {
|
||||
const oldTargets = await generateSubnetProxyTargetV2(
|
||||
existingSiteResource,
|
||||
mergedAllClients
|
||||
);
|
||||
const newTargets = await generateSubnetProxyTargetV2(
|
||||
updatedSiteResource,
|
||||
mergedAllClients
|
||||
);
|
||||
|
||||
const oldDestinationStillInUseByASite =
|
||||
oldDestinationStillInUseSites.length > 0;
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{
|
||||
oldTargets: oldTargets ? oldTargets : [],
|
||||
newTargets: newTargets ? newTargets : []
|
||||
},
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
|
||||
// we also need to update the remote subnets on the olms for each client that has access to this site
|
||||
olmJobs.push(
|
||||
updatePeerData(
|
||||
client.clientId,
|
||||
updatedSiteResource.siteId,
|
||||
destinationChanged
|
||||
? {
|
||||
oldRemoteSubnets: !oldDestinationStillInUseByASite
|
||||
? generateRemoteSubnets([
|
||||
existingSiteResource
|
||||
])
|
||||
: [],
|
||||
newRemoteSubnets: generateRemoteSubnets([
|
||||
updatedSiteResource
|
||||
])
|
||||
}
|
||||
: undefined,
|
||||
aliasChanged
|
||||
? {
|
||||
oldAliases: generateAliasConfig([
|
||||
existingSiteResource
|
||||
]),
|
||||
newAliases: generateAliasConfig([
|
||||
updatedSiteResource
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
const olmJobs: Promise<void>[] = [];
|
||||
for (const client of mergedAllClients) {
|
||||
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
|
||||
// todo: optimize this query if needed
|
||||
const oldDestinationStillInUseSites = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteNetworks.siteId, site.siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
existingSiteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
existingSiteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const oldDestinationStillInUseByASite =
|
||||
oldDestinationStillInUseSites.length > 0;
|
||||
|
||||
// we also need to update the remote subnets on the olms for each client that has access to this site
|
||||
olmJobs.push(
|
||||
updatePeerData(
|
||||
client.clientId,
|
||||
site.siteId,
|
||||
destinationChanged
|
||||
? {
|
||||
oldRemoteSubnets:
|
||||
!oldDestinationStillInUseByASite
|
||||
? generateRemoteSubnets([
|
||||
existingSiteResource
|
||||
])
|
||||
: [],
|
||||
newRemoteSubnets: generateRemoteSubnets([
|
||||
updatedSiteResource
|
||||
])
|
||||
}
|
||||
: undefined,
|
||||
aliasChanged
|
||||
? {
|
||||
oldAliases: generateAliasConfig([
|
||||
existingSiteResource
|
||||
]),
|
||||
newAliases: generateAliasConfig([
|
||||
updatedSiteResource
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(olmJobs);
|
||||
}
|
||||
|
||||
await Promise.all(olmJobs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ async function queryUser(userId: string) {
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
locale: users.locale,
|
||||
dateCreated: users.dateCreated
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
roles,
|
||||
userInviteRoles,
|
||||
userInvites,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
|
||||
regenerate: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
@@ -265,7 +271,7 @@ export async function inviteUser(
|
||||
)
|
||||
);
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
@@ -314,12 +320,12 @@ export async function inviteUser(
|
||||
expiresAt,
|
||||
tokenHash
|
||||
});
|
||||
await trx.insert(userInviteRoles).values(
|
||||
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
|
||||
);
|
||||
await trx
|
||||
.insert(userInviteRoles)
|
||||
.values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
|
||||
});
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
|
||||
@@ -64,7 +64,8 @@ export async function myDevice(
|
||||
serverAdmin: users.serverAdmin,
|
||||
idpName: idp.name,
|
||||
idpId: users.idpId,
|
||||
locale: users.locale
|
||||
locale: users.locale,
|
||||
dateCreated: users.dateCreated
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { build } from "@server/build";
|
||||
import {
|
||||
handleNewtRegisterMessage,
|
||||
handleReceiveBandwidthMessage,
|
||||
handleGetConfigMessage,
|
||||
handleNewtGetConfigMessage,
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtPingRequestMessage,
|
||||
@@ -37,7 +37,7 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"newt/disconnecting": handleNewtDisconnectingMessage,
|
||||
"newt/ping": handleNewtPingMessage,
|
||||
"newt/wg/register": handleNewtRegisterMessage,
|
||||
"newt/wg/get-config": handleGetConfigMessage,
|
||||
"newt/wg/get-config": handleNewtGetConfigMessage,
|
||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||
"newt/socket/status": handleDockerStatusMessage,
|
||||
"newt/socket/containers": handleDockerContainersMessage,
|
||||
|
||||
@@ -235,7 +235,9 @@ export default async function migration() {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||
VALUES (${row.inviteId}, ${row.roleId})
|
||||
SELECT ${row.inviteId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
@@ -258,7 +260,10 @@ export default async function migration() {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
||||
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default async function migration() {
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
||||
).run();
|
||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||
db.prepare(
|
||||
@@ -246,12 +246,15 @@ export default async function migration() {
|
||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||
if (existingUserInviteRoles.length > 0) {
|
||||
const insertUserInviteRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||
SELECT ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId);
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,12 +268,16 @@ export default async function migration() {
|
||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||
if (existingUserOrgRoles.length > 0) {
|
||||
const insertUserOrgRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||
SELECT ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -491,6 +491,10 @@ export default function BillingPage() {
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
|
||||
const visiblePlanOptions = planOptions.filter(
|
||||
(plan) => plan.id !== "home" || currentPlanId === "home"
|
||||
);
|
||||
|
||||
// Check if subscription is in a problematic state that requires attention
|
||||
const hasProblematicSubscription = (): boolean => {
|
||||
if (!tierSubscription?.subscription) return false;
|
||||
@@ -803,8 +807,8 @@ export default function BillingPage() {
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{/* Plan Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{planOptions.map((plan) => {
|
||||
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
|
||||
{visiblePlanOptions.map((plan) => {
|
||||
const isCurrentPlan = plan.id === currentPlanId;
|
||||
const planAction = getPlanAction(plan);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
||||
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
||||
import DomainCertForm from "@app/components/DomainCertForm";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface DomainSettingsPageProps {
|
||||
params: Promise<{ domainId: string; orgId: string }>;
|
||||
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||
|
||||
|
||||
@@ -471,11 +471,7 @@ export default function GeneralPage() {
|
||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
@@ -451,11 +451,7 @@ export default function ConnectionLogsPage() {
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/client/?query=${row.original.resourceNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
@@ -497,11 +493,7 @@ export default function ConnectionLogsPage() {
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Laptop className="mr-1 h-3 w-3" />
|
||||
{row.original.clientName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
@@ -675,9 +667,7 @@ export default function ConnectionLogsPage() {
|
||||
<div>
|
||||
<strong>Ended At:</strong>{" "}
|
||||
{row.endedAt
|
||||
? new Date(
|
||||
row.endedAt * 1000
|
||||
).toLocaleString()
|
||||
? new Date(row.endedAt * 1000).toLocaleString()
|
||||
: "Active"}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -360,6 +360,7 @@ export default function GeneralPage() {
|
||||
// 105 - Valid Password
|
||||
// 106 - Valid email
|
||||
// 107 - Valid SSO
|
||||
// 108 - Connected Client
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
@@ -377,6 +378,7 @@ export default function GeneralPage() {
|
||||
105: t("validPassword"),
|
||||
106: t("validEmail"),
|
||||
107: t("validSSO"),
|
||||
108: t("connectedClient"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
@@ -510,14 +512,14 @@ export default function GeneralPage() {
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
|
||||
href={
|
||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||
? `/${row.original.orgId}/settings/resources/client?query=${row.original.resourceNiceId}`
|
||||
: `/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
@@ -634,6 +636,7 @@ export default function GeneralPage() {
|
||||
{ value: "105", label: t("validPassword") },
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "108", label: t("connectedClient") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
|
||||
@@ -60,23 +60,34 @@ export default async function ClientResourcesPage(
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
siteAddress: siteResource.siteAddress || null,
|
||||
mode: siteResource.mode || ("port" as any),
|
||||
sites: siteResource.siteIds.map((siteId, idx) => ({
|
||||
siteId,
|
||||
siteName: siteResource.siteNames[idx],
|
||||
siteNiceId: siteResource.siteNiceIds[idx],
|
||||
online: siteResource.siteOnlines[idx]
|
||||
})),
|
||||
mode: siteResource.mode,
|
||||
scheme: siteResource.scheme,
|
||||
ssl: siteResource.ssl,
|
||||
siteNames: siteResource.siteNames,
|
||||
siteAddresses: siteResource.siteAddresses || null,
|
||||
// protocol: siteResource.protocol,
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
siteIds: siteResource.siteIds,
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
httpHttpsPort: siteResource.destinationPort ?? null,
|
||||
alias: siteResource.alias || null,
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceId: siteResource.siteNiceId,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
disableIcmp: siteResource.disableIcmp || false,
|
||||
authDaemonMode: siteResource.authDaemonMode ?? null,
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null
|
||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||
subdomain: siteResource.subdomain ?? null,
|
||||
domainId: siteResource.domainId ?? null,
|
||||
fullDomain: siteResource.fullDomain ?? null
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -133,8 +133,7 @@ export default function ResourceAuthenticationPage() {
|
||||
...orgQueries.identityProviders({
|
||||
orgId: org.org.orgId,
|
||||
useOrgOnlyIdp: env.app.identityProviderMode === "org"
|
||||
}),
|
||||
enabled: isPaidUser(tierMatrix.orgOidc)
|
||||
})
|
||||
});
|
||||
|
||||
const pageLoading =
|
||||
|
||||
@@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -999,6 +999,7 @@ export default function Page() {
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -95,7 +95,8 @@ export default async function ProxyResourcesPage(
|
||||
ip: target.ip,
|
||||
port: target.port,
|
||||
enabled: target.enabled,
|
||||
healthStatus: target.healthStatus
|
||||
healthStatus: target.healthStatus,
|
||||
siteName: target.siteName
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,7 +42,9 @@ import {
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { Check, Heart, InfoIcon } from "lucide-react";
|
||||
import { ArrowRight, Check, ExternalLink, Heart, InfoIcon, TicketCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import DismissableBanner from "@app/components/DismissableBanner";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { SitePriceCalculator } from "@app/components/SitePriceCalculator";
|
||||
@@ -51,6 +53,10 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
|
||||
|
||||
function obfuscateLicenseKey(key: string): string {
|
||||
if (key.length <= 8) return key;
|
||||
const firstPart = key.substring(0, 4);
|
||||
@@ -336,6 +342,47 @@ export default function LicensePage() {
|
||||
description={t("licenseTitleDescription")}
|
||||
/>
|
||||
|
||||
{!licenseStatus?.isLicenseValid && (
|
||||
<DismissableBanner
|
||||
storageKey="license-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBannerTitle")}
|
||||
titleIcon={
|
||||
<TicketCheck className="w-5 h-5 text-primary" />
|
||||
}
|
||||
description={t("licenseBannerDescription")}
|
||||
>
|
||||
<Link
|
||||
href={ENTERPRISE_PRICING_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
{t("licenseBannerGetLicense")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href={ENTERPRISE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("licenseBannerViewDocs")}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
)}
|
||||
|
||||
{/* <Alert variant="neutral" className="mb-6"> */}
|
||||
{/* <InfoIcon className="h-4 w-4" /> */}
|
||||
{/* <AlertTitle className="font-semibold"> */}
|
||||
|
||||
32
src/app/private-maintenance-screen/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Private Placeholder"
|
||||
};
|
||||
|
||||
export default async function MaintenanceScreen() {
|
||||
const t = await getTranslations();
|
||||
|
||||
let title = t("privateMaintenanceScreenTitle");
|
||||
let message = t("privateMaintenanceScreenMessage");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{message}</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
@@ -38,21 +39,32 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
export type InternalResourceSiteRow = {
|
||||
siteId: number;
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
online: boolean;
|
||||
};
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
siteAddress: string | null;
|
||||
sites: InternalResourceSiteRow[];
|
||||
siteNames: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr";
|
||||
mode: "host" | "cidr" | "http";
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destination: string;
|
||||
// destinationPort: number | null;
|
||||
httpHttpsPort: number | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
niceId: string;
|
||||
@@ -61,8 +73,147 @@ export type InternalResourceRow = {
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonPort?: number | null;
|
||||
subdomain?: string | null;
|
||||
domainId?: string | null;
|
||||
fullDomain?: string | null;
|
||||
};
|
||||
|
||||
function resolveHttpHttpsDisplayPort(
|
||||
mode: "http",
|
||||
httpHttpsPort: number | null
|
||||
): number {
|
||||
if (httpHttpsPort != null) {
|
||||
return httpHttpsPort;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||
const { mode, destination, httpHttpsPort, scheme } = row;
|
||||
if (mode !== "http") {
|
||||
return destination;
|
||||
}
|
||||
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
|
||||
const downstreamScheme = scheme ?? "http";
|
||||
const hostPart =
|
||||
destination.includes(":") && !destination.startsWith("[")
|
||||
? `[${destination}]`
|
||||
: destination;
|
||||
return `${downstreamScheme}://${hostPart}:${port}`;
|
||||
}
|
||||
|
||||
function isSafeUrlForLink(href: string): boolean {
|
||||
try {
|
||||
void new URL(href);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type AggregateSitesStatus = "allOnline" | "partial" | "allOffline";
|
||||
|
||||
function aggregateSitesStatus(
|
||||
resourceSites: InternalResourceSiteRow[]
|
||||
): AggregateSitesStatus {
|
||||
if (resourceSites.length === 0) {
|
||||
return "allOffline";
|
||||
}
|
||||
const onlineCount = resourceSites.filter((rs) => rs.online).length;
|
||||
if (onlineCount === resourceSites.length) return "allOnline";
|
||||
if (onlineCount > 0) return "partial";
|
||||
return "allOffline";
|
||||
}
|
||||
|
||||
function aggregateStatusDotClass(status: AggregateSitesStatus): string {
|
||||
switch (status) {
|
||||
case "allOnline":
|
||||
return "bg-green-500";
|
||||
case "partial":
|
||||
return "bg-yellow-500";
|
||||
case "allOffline":
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
function ClientResourceSitesStatusCell({
|
||||
orgId,
|
||||
resourceSites
|
||||
}: {
|
||||
orgId: string;
|
||||
resourceSites: InternalResourceSiteRow[];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
if (resourceSites.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const aggregate = aggregateSitesStatus(resourceSites);
|
||||
const countLabel = t("multiSitesSelectorSitesCount", {
|
||||
count: resourceSites.length
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
aggregateStatusDotClass(aggregate)
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm tabular-nums">{countLabel}</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{resourceSites.map((site) => {
|
||||
const isOnline = site.online;
|
||||
return (
|
||||
<DropdownMenuItem key={site.siteId} asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${site.siteNiceId}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 shrink-0 rounded-full",
|
||||
isOnline
|
||||
? "bg-green-500"
|
||||
: "bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{site.siteName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 capitalize",
|
||||
isOnline
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isOnline ? t("online") : t("offline")}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
internalResources: InternalResourceRow[];
|
||||
orgId: string;
|
||||
@@ -97,8 +248,6 @@ export default function ClientResourcesTable({
|
||||
useState<InternalResourceRow | null>();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
|
||||
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
|
||||
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -136,6 +285,60 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
};
|
||||
|
||||
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
|
||||
const { siteNames, siteNiceIds, orgId } = resourceRow;
|
||||
|
||||
if (!siteNames || siteNames.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
if (siteNames.length === 1) {
|
||||
return (
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{siteNames[0]}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span>
|
||||
{siteNames.length} {t("sites")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{siteNames.map((siteName, idx) => (
|
||||
<DropdownMenuItem
|
||||
key={siteNiceIds[idx]}
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
{siteName}
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -185,20 +388,17 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
friendlyName: t("site"),
|
||||
header: () => <span className="p-3">{t("site")}</span>,
|
||||
id: "sites",
|
||||
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||
friendlyName: t("sites"),
|
||||
header: () => <span className="p-3">{t("sites")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
<Link
|
||||
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{resourceRow.siteName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<ClientResourceSitesStatusCell
|
||||
orgId={resourceRow.orgId}
|
||||
resourceSites={resourceRow.sites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -215,6 +415,10 @@ export default function ClientResourcesTable({
|
||||
{
|
||||
value: "cidr",
|
||||
label: t("editInternalResourceDialogModeCidr")
|
||||
},
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
}
|
||||
]}
|
||||
selectedValue={searchParams.get("mode") ?? undefined}
|
||||
@@ -227,10 +431,14 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const modeLabels: Record<"host" | "cidr" | "port", string> = {
|
||||
const modeLabels: Record<
|
||||
"host" | "cidr" | "port" | "http",
|
||||
string
|
||||
> = {
|
||||
host: t("editInternalResourceDialogModeHost"),
|
||||
cidr: t("editInternalResourceDialogModeCidr"),
|
||||
port: t("editInternalResourceDialogModePort")
|
||||
port: t("editInternalResourceDialogModePort"),
|
||||
http: t("editInternalResourceDialogModeHttp")
|
||||
};
|
||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||
}
|
||||
@@ -243,11 +451,12 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.destination}
|
||||
text={display}
|
||||
isLink={false}
|
||||
displayText={resourceRow.destination}
|
||||
displayText={display}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -260,15 +469,26 @@ export default function ClientResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return resourceRow.mode === "host" && resourceRow.alias ? (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
/>
|
||||
) : (
|
||||
<span>-</span>
|
||||
);
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
isLink={false}
|
||||
displayText={resourceRow.alias}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resourceRow.mode === "http") {
|
||||
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.fullDomain}`;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
isLink={isSafeUrlForLink(url)}
|
||||
displayText={url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>-</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -399,7 +619,7 @@ export default function ClientResourcesTable({
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
selectedInternalResource!.siteIds[0]
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
@@ -435,7 +655,6 @@ export default function ClientResourcesTable({
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
@@ -450,7 +669,6 @@ export default function ClientResourcesTable({
|
||||
open={isCreateDialogOpen}
|
||||
setOpen={setIsCreateDialogOpen}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
// Delay refresh to allow modal to close smoothly
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!baseDomain) return "";
|
||||
const punycode = toPunycode(baseDomain);
|
||||
const punycode = toPunycode(baseDomain.toLowerCase());
|
||||
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
||||
}, [baseDomain]);
|
||||
|
||||
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Button } from "@app/components/ui/button";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
@@ -25,13 +24,10 @@ import {
|
||||
type InternalResourceFormValues
|
||||
} from "./InternalResourceForm";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type CreateInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -39,18 +35,21 @@ export default function CreateInternalResourceDialog({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: CreateInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
@@ -65,25 +64,56 @@ export default function CreateInternalResourceDialog({
|
||||
`/org/${orgId}/site-resource`,
|
||||
{
|
||||
name: data.name,
|
||||
siteId: data.siteId,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
destination: data.destination,
|
||||
enabled: true,
|
||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
|
||||
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
|
||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? undefined,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" &&
|
||||
data.authDaemonPort != null && {
|
||||
authDaemonPort: data.authDaemonPort
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode == "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
|
||||
clientIds: data.clients
|
||||
? data.clients.map((c) => parseInt(c.id))
|
||||
: []
|
||||
}
|
||||
);
|
||||
|
||||
toast({
|
||||
title: t("createInternalResourceDialogSuccess"),
|
||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
||||
description: t(
|
||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
setOpen(false);
|
||||
@@ -93,7 +123,9 @@ export default function CreateInternalResourceDialog({
|
||||
title: t("createInternalResourceDialogError"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
||||
t(
|
||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||
)
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -106,31 +138,39 @@ export default function CreateInternalResourceDialog({
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("createInternalResourceDialogCreateClientResource")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
||||
{t(
|
||||
"createInternalResourceDialogCreateClientResourceDescription"
|
||||
)}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<InternalResourceForm
|
||||
variant="create"
|
||||
open={open}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
formId="create-internal-resource-form"
|
||||
onSubmit={handleSubmit}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("createInternalResourceDialogCreateResource")}
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={9}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
{...field}
|
||||
value={field.value
|
||||
.replace(/-/g, "")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -40,11 +41,15 @@ import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
KeyRound,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { toUnicode } from "punycode";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
type AvailableOption = {
|
||||
domainNamespaceId: string;
|
||||
@@ -93,8 +98,15 @@ export default function DomainPicker({
|
||||
warnOnProvidedDomain = false
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
|
||||
const requiresPaywall =
|
||||
build === "saas" &&
|
||||
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
|
||||
new Date(user.dateCreated) > new Date("2026-04-13");
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
@@ -163,15 +175,18 @@ export default function DomainPicker({
|
||||
domainId: firstOrExistingDomain.domainId
|
||||
};
|
||||
|
||||
const base = firstOrExistingDomain.baseDomain;
|
||||
const sub =
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain?.trim() || undefined
|
||||
: undefined;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: firstOrExistingDomain.domainId,
|
||||
type: "organization",
|
||||
subdomain:
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain || undefined
|
||||
: undefined,
|
||||
fullDomain: firstOrExistingDomain.baseDomain,
|
||||
baseDomain: firstOrExistingDomain.baseDomain
|
||||
subdomain: sub,
|
||||
fullDomain: sub ? `${sub}.${base}` : base,
|
||||
baseDomain: base
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -509,9 +524,11 @@ export default function DomainPicker({
|
||||
<span className="truncate">
|
||||
{selectedBaseDomain.domain}
|
||||
</span>
|
||||
{selectedBaseDomain.verified && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
{selectedBaseDomain.verified &&
|
||||
selectedBaseDomain.domainType !==
|
||||
"wildcard" && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t("domainPickerSelectBaseDomain")
|
||||
@@ -574,14 +591,23 @@ export default function DomainPicker({
|
||||
}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
{orgDomain.type ===
|
||||
"wildcard"
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
"domainPickerManual"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
: (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -640,6 +666,7 @@ export default function DomainPicker({
|
||||
})
|
||||
}
|
||||
className="mx-2 rounded-md"
|
||||
disabled={requiresPaywall}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
@@ -680,6 +707,19 @@ export default function DomainPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requiresPaywall && !hideFreeDomain && (
|
||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("domainPickerFreeDomainsPaidFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*showProvidedDomainSearch && build === "saas" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useTransition } from "react";
|
||||
@@ -27,14 +26,11 @@ import {
|
||||
isHostname
|
||||
} from "./InternalResourceForm";
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (val: boolean) => void;
|
||||
resource: InternalResourceData;
|
||||
orgId: string;
|
||||
sites: Site[];
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
@@ -43,18 +39,21 @@ export default function EditInternalResourceDialog({
|
||||
setOpen,
|
||||
resource,
|
||||
orgId,
|
||||
sites,
|
||||
onSuccess
|
||||
}: EditInternalResourceDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||
|
||||
async function handleSubmit(values: InternalResourceFormValues) {
|
||||
try {
|
||||
let data = { ...values };
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
if (
|
||||
(data.mode === "host" || data.mode === "http") &&
|
||||
isHostname(data.destination)
|
||||
) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
if (!currentAlias) {
|
||||
let aliasValue = data.destination;
|
||||
@@ -67,24 +66,39 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
await api.post(`/site-resource/${resource.id}`, {
|
||||
name: data.name,
|
||||
siteId: data.siteId,
|
||||
siteIds: data.siteIds,
|
||||
mode: data.mode,
|
||||
niceId: data.niceId,
|
||||
destination: data.destination,
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
...(data.mode === "http" && {
|
||||
scheme: data.scheme,
|
||||
ssl: data.ssl ?? false,
|
||||
destinationPort: data.httpHttpsPort ?? null,
|
||||
domainId: data.httpConfigDomainId
|
||||
? data.httpConfigDomainId
|
||||
: undefined,
|
||||
subdomain: data.httpConfigSubdomain
|
||||
? data.httpConfigSubdomain
|
||||
: undefined
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
...(data.mode === "host" && {
|
||||
alias:
|
||||
data.alias &&
|
||||
typeof data.alias === "string" &&
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
...(data.authDaemonMode != null && {
|
||||
authDaemonMode: data.authDaemonMode
|
||||
}),
|
||||
...(data.authDaemonMode === "remote" && {
|
||||
authDaemonPort: data.authDaemonPort || null
|
||||
})
|
||||
}),
|
||||
...((data.mode === "host" || data.mode === "cidr") && {
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false
|
||||
}),
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
@@ -156,13 +170,13 @@ export default function EditInternalResourceDialog({
|
||||
variant="edit"
|
||||
open={open}
|
||||
resource={resource}
|
||||
sites={sites}
|
||||
orgId={orgId}
|
||||
siteResourceId={resource.id}
|
||||
formId="edit-internal-resource-form"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => handleSubmit(values))
|
||||
}
|
||||
onSubmitDisabledChange={setIsHttpModeDisabled}
|
||||
/>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
@@ -178,7 +192,7 @@ export default function EditInternalResourceDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-internal-resource-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isHttpModeDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("editInternalResourceDialogSaveResource")}
|
||||
|
||||
@@ -39,7 +39,11 @@ export default function InviteStatusCard({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [type, setType] = useState<
|
||||
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
|
||||
| "rejected"
|
||||
| "wrong_user"
|
||||
| "user_does_not_exist"
|
||||
| "not_logged_in"
|
||||
| "user_limit_exceeded"
|
||||
>("rejected");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
|
||||
|
||||
if (!user && type === "user_does_not_exist") {
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else if (!user && type === "not_logged_in") {
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else {
|
||||
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
|
||||
Cannot Accept Invite
|
||||
</p>
|
||||
<p className="text-center text-sm">
|
||||
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
|
||||
This organization has reached its user limit. Please
|
||||
contact the organization administrator to upgrade their
|
||||
plan before accepting this invite.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -405,7 +405,11 @@ export function LogDataTable<TData, TValue>({
|
||||
onClick={() =>
|
||||
!disabled && onExport()
|
||||
}
|
||||
disabled={isExporting || disabled || isExportDisabled}
|
||||
disabled={
|
||||
isExporting ||
|
||||
disabled ||
|
||||
isExportDisabled
|
||||
}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader className="mr-2 size-4 animate-spin" />
|
||||
|
||||