mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-29 15:31:25 +02:00
Compare commits
17 Commits
add9b8dfb0
...
13efa47db7
| Author | SHA1 | Date | |
|---|---|---|---|
| 13efa47db7 | |||
| 69bd61c308 | |||
| 7b7ff51289 | |||
| 772ac8af73 | |||
| 8ee520dbb5 | |||
| 8e5d9e94a9 | |||
| c9cb28af45 | |||
| ea8eaf9736 | |||
| b78db3daef | |||
| 7cf3f8df92 | |||
| f2b5cff3f9 | |||
| 6de9ab8f05 | |||
| ad0e800d8d | |||
| 65470fb64b | |||
| f23142336b | |||
| 2da4987cd3 | |||
| 253ba554a2 |
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Моля, изберете ресурс",
|
||||
"proxyResourceTitle": "Управление на обществени ресурси",
|
||||
"proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.",
|
||||
"proxyResourcesBannerTitle": "Публичен достъп чрез уеб.",
|
||||
"proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
|
||||
"publicResourcesBannerTitle": "Публичен достъп чрез уеб.",
|
||||
"publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
|
||||
"clientResourceTitle": "Управление на частни ресурси",
|
||||
"clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.",
|
||||
"privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Zvolte prosím zdroj",
|
||||
"proxyResourceTitle": "Spravovat veřejné zdroje",
|
||||
"proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče",
|
||||
"proxyResourcesBannerTitle": "Veřejný přístup založený na webu",
|
||||
"proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
|
||||
"publicResourcesBannerTitle": "Veřejný přístup založený na webu",
|
||||
"publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
|
||||
"clientResourceTitle": "Spravovat soukromé zdroje",
|
||||
"clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta",
|
||||
"privateResourcesBannerTitle": "Zero-Trust soukromý přístup",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Bitte wählen Sie eine Ressource",
|
||||
"proxyResourceTitle": "Öffentliche Ressourcen verwalten",
|
||||
"proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind",
|
||||
"proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
|
||||
"proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
|
||||
"publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
|
||||
"publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
|
||||
"clientResourceTitle": "Private Ressourcen verwalten",
|
||||
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
|
||||
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
|
||||
|
||||
+17
-11
@@ -202,8 +202,8 @@
|
||||
"shareErrorSelectResource": "Please select a resource",
|
||||
"proxyResourceTitle": "Manage Public Resources",
|
||||
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
||||
"proxyResourcesBannerTitle": "Web-based Public Access",
|
||||
"proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||
"publicResourcesBannerTitle": "Web-based Public Access",
|
||||
"publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||
"clientResourceTitle": "Manage Private Resources",
|
||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||
@@ -211,15 +211,16 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesTitle": "Manage Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
||||
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}",
|
||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your public resources",
|
||||
"resourcePoliciesSearch": "Search policies...",
|
||||
"resourcePoliciesAdd": "Add Policy",
|
||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||
"resourcePoliciesCreate": "Create Resource Policy",
|
||||
"resourcePoliciesCreate": "Create Public Resource Policy",
|
||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||
"resourcePolicyName": "Policy Name",
|
||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||
@@ -313,7 +314,7 @@
|
||||
"rules": "Rules",
|
||||
"resourceSettingDescription": "Configure the settings on the resource",
|
||||
"resourceSetting": "{resourceName} Settings",
|
||||
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||
"resourcePolicySettingDescription": "Configure the settings on this public resource policy",
|
||||
"resourcePolicySetting": "{policyName} Settings",
|
||||
"alwaysAllow": "Bypass Auth",
|
||||
"alwaysDeny": "Block Access",
|
||||
@@ -1466,7 +1467,7 @@
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarPolicies": "Policies",
|
||||
"sidebarResourcePolicies": "Resources",
|
||||
"sidebarResourcePolicies": "Public Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
@@ -1474,7 +1475,7 @@
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Invitations",
|
||||
"sidebarRoles": "Roles",
|
||||
"sidebarShareableLinks": "Links",
|
||||
"sidebarShareableLinks": "Share Links",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
@@ -2031,13 +2032,13 @@
|
||||
"healthCheckUnknown": "Unknown",
|
||||
"healthCheck": "Health Check",
|
||||
"configureHealthCheck": "Configure Health Check",
|
||||
"configureHealthCheckDescription": "Set up health monitoring for {target}",
|
||||
"configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available",
|
||||
"enableHealthChecks": "Enable Health Checks",
|
||||
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
|
||||
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
|
||||
"healthScheme": "Method",
|
||||
"healthSelectScheme": "Select Method",
|
||||
"healthCheckPortInvalid": "Health check port must be between 1 and 65535",
|
||||
"healthCheckPortInvalid": "Port must be between 1 and 65535",
|
||||
"healthCheckPath": "Path",
|
||||
"healthHostname": "IP / Host",
|
||||
"healthPort": "Port",
|
||||
@@ -2079,6 +2080,11 @@
|
||||
"sshServerDestination": "Server Destination",
|
||||
"sshServerDestinationDescription": "Configure the destination of the SSH server",
|
||||
"destination": "Destination",
|
||||
"destinationRequired": "Destination is required.",
|
||||
"domainRequired": "Domain is required.",
|
||||
"proxyPortRequired": "Port is required.",
|
||||
"invalidPathConfiguration": "Invalid path configuration.",
|
||||
"invalidRewritePathConfiguration": "Invalid rewrite path configuration.",
|
||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||
"roleAllowSsh": "Allow SSH",
|
||||
"roleAllowSshAllow": "Allow",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Por favor, seleccione un recurso",
|
||||
"proxyResourceTitle": "Administrar recursos públicos",
|
||||
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
|
||||
"proxyResourcesBannerTitle": "Acceso público basado en web",
|
||||
"proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
|
||||
"publicResourcesBannerTitle": "Acceso público basado en web",
|
||||
"publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
|
||||
"clientResourceTitle": "Administrar recursos privados",
|
||||
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
|
||||
"privateResourcesBannerTitle": "Acceso privado de confianza cero",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Veuillez sélectionner une ressource",
|
||||
"proxyResourceTitle": "Gérer les ressources publiques",
|
||||
"proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web",
|
||||
"proxyResourcesBannerTitle": "Accès public basé sur le Web",
|
||||
"proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
|
||||
"publicResourcesBannerTitle": "Accès public basé sur le Web",
|
||||
"publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
|
||||
"clientResourceTitle": "Gérer les ressources privées",
|
||||
"clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté",
|
||||
"privateResourcesBannerTitle": "Accès privé sans confiance",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Seleziona una risorsa",
|
||||
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
|
||||
"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 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.",
|
||||
"publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
|
||||
"publicResourcesBannerDescription": "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",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "리소스를 선택하세요",
|
||||
"proxyResourceTitle": "공개 리소스 관리",
|
||||
"proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
||||
"proxyResourcesBannerTitle": "웹 기반 공공 접근",
|
||||
"proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
|
||||
"publicResourcesBannerTitle": "웹 기반 공공 접근",
|
||||
"publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
|
||||
"clientResourceTitle": "개인 리소스 관리",
|
||||
"clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
||||
"privateResourcesBannerTitle": "제로 트러스트 개인 접근",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Vennligst velg en ressurs",
|
||||
"proxyResourceTitle": "Administrere offentlige ressurser",
|
||||
"proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser",
|
||||
"proxyResourcesBannerTitle": "Nettbasert offentlig tilgang",
|
||||
"proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
|
||||
"publicResourcesBannerTitle": "Nettbasert offentlig tilgang",
|
||||
"publicResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
|
||||
"clientResourceTitle": "Administrer private ressurser",
|
||||
"clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient",
|
||||
"privateResourcesBannerTitle": "Zero-Trust privat tilgang",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Selecteer een bron",
|
||||
"proxyResourceTitle": "Openbare bronnen beheren",
|
||||
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
|
||||
"proxyResourcesBannerTitle": "Webgebaseerde openbare toegang",
|
||||
"proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
|
||||
"publicResourcesBannerTitle": "Webgebaseerde openbare toegang",
|
||||
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
|
||||
"clientResourceTitle": "Privébronnen beheren",
|
||||
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
|
||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Wybierz zasób",
|
||||
"proxyResourceTitle": "Zarządzaj zasobami publicznymi",
|
||||
"proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej",
|
||||
"proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
|
||||
"proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
|
||||
"publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
|
||||
"publicResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
|
||||
"clientResourceTitle": "Zarządzaj zasobami prywatnymi",
|
||||
"clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta",
|
||||
"privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Por favor, selecione um recurso",
|
||||
"proxyResourceTitle": "Gerenciar Recursos Públicos",
|
||||
"proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web",
|
||||
"proxyResourcesBannerTitle": "Acesso Público via Web",
|
||||
"proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
|
||||
"publicResourcesBannerTitle": "Acesso Público via Web",
|
||||
"publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
|
||||
"clientResourceTitle": "Gerenciar recursos privados",
|
||||
"clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado",
|
||||
"privateResourcesBannerTitle": "Acesso Privado com Confiança Zero",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
|
||||
"proxyResourceTitle": "Управление публичными ресурсами",
|
||||
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
|
||||
"proxyResourcesBannerTitle": "Общедоступный доступ через веб",
|
||||
"proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
|
||||
"publicResourcesBannerTitle": "Общедоступный доступ через веб",
|
||||
"publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
|
||||
"clientResourceTitle": "Управление приватными ресурсами",
|
||||
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
|
||||
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "Lütfen bir kaynak seçin",
|
||||
"proxyResourceTitle": "Herkese Açık Kaynakları Yönet",
|
||||
"proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin",
|
||||
"proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim",
|
||||
"proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
|
||||
"publicResourcesBannerTitle": "Web Tabanlı Genel Erişim",
|
||||
"publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
|
||||
"clientResourceTitle": "Özel Kaynakları Yönet",
|
||||
"clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin",
|
||||
"privateResourcesBannerTitle": "Sıfır Güven Özel Erişim",
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@
|
||||
"shareErrorSelectResource": "请选择一个资源",
|
||||
"proxyResourceTitle": "管理公共资源",
|
||||
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
|
||||
"proxyResourcesBannerTitle": "基于Web的公共访问",
|
||||
"proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。",
|
||||
"publicResourcesBannerTitle": "基于Web的公共访问",
|
||||
"publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。",
|
||||
"clientResourceTitle": "管理私有资源",
|
||||
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
|
||||
"privateResourcesBannerTitle": "零信任的私人访问",
|
||||
|
||||
+2
-2
@@ -152,8 +152,8 @@
|
||||
"shareErrorSelectResource": "請選擇一個資源",
|
||||
"proxyResourceTitle": "管理公開資源",
|
||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
||||
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
||||
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||
"clientResourceTitle": "管理私有資源",
|
||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||
"privateResourcesBannerTitle": "零信任私有存取",
|
||||
|
||||
@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: varchar("authToken").notNull(),
|
||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: varchar("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
||||
>;
|
||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -290,7 +290,12 @@ export const targets = pgTable("targets", {
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100)
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: varchar("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.notNull()
|
||||
.default("http"),
|
||||
authToken: varchar("authToken")
|
||||
});
|
||||
|
||||
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
|
||||
@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
||||
sentAt: integer("sentAt").notNull()
|
||||
});
|
||||
|
||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
resourceId: integer("resourceId")
|
||||
.references(() => resources.resourceId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
authToken: text("authToken").notNull(),
|
||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||
destination: text("destination").notNull(),
|
||||
destinationPort: integer("destinationPort").notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||
export type BrowserGatewayTarget = InferSelectModel<
|
||||
typeof browserGatewayTarget
|
||||
>;
|
||||
|
||||
@@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", {
|
||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||
priority: integer("priority").notNull().default(100)
|
||||
priority: integer("priority").notNull().default(100),
|
||||
mode: text("mode")
|
||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||
.notNull()
|
||||
.default("http"),
|
||||
authToken: text("authToken")
|
||||
});
|
||||
|
||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
|
||||
@@ -48,6 +48,9 @@ import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||
import { tierMatrix } from "../billing/tierMatrix";
|
||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||
import { build } from "@server/build";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import serverConfig from "@server/lib/config";
|
||||
|
||||
export type ProxyResourcesResults = {
|
||||
proxyResource: Resource;
|
||||
@@ -80,7 +83,7 @@ export async function updateProxyResources(
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
@@ -92,7 +95,7 @@ export async function updateProxyResources(
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
@@ -119,6 +122,15 @@ export async function updateProxyResources(
|
||||
internalPortToCreate = targetData["internal-port"];
|
||||
}
|
||||
|
||||
let authToken: string | undefined;
|
||||
if (site.type !== "local") {
|
||||
const plainToken = generateId(48);
|
||||
authToken = encrypt(
|
||||
plainToken,
|
||||
serverConfig.getRawConfig().server.secret!
|
||||
);
|
||||
}
|
||||
|
||||
// Create target
|
||||
const [newTarget] = await trx
|
||||
.insert(targets)
|
||||
@@ -126,10 +138,12 @@ export async function updateProxyResources(
|
||||
resourceId: resourceId,
|
||||
siteId: site.siteId,
|
||||
ip: targetData.hostname,
|
||||
mode: resourceData.mode as Target["mode"],
|
||||
method: targetData.method,
|
||||
port: targetData.port,
|
||||
enabled: targetData.enabled,
|
||||
internalPort: internalPortToCreate,
|
||||
authToken: authToken,
|
||||
path: targetData.path,
|
||||
pathMatchType: targetData["path-match"],
|
||||
rewritePath:
|
||||
@@ -707,7 +721,8 @@ export async function updateProxyResources(
|
||||
? "/"
|
||||
: undefined),
|
||||
rewritePathType: targetData["rewrite-match"],
|
||||
priority: targetData.priority
|
||||
priority: targetData.priority,
|
||||
mode: resourceData.mode
|
||||
})
|
||||
.where(eq(targets.targetId, existingTarget.targetId))
|
||||
.returning();
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function updateResourcePolicies(
|
||||
const results: ResourcePoliciesResults = [];
|
||||
|
||||
for (const [policyNiceId, policyData] of Object.entries(
|
||||
config["resource-policies"]
|
||||
config["public-policies"]
|
||||
)) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
|
||||
@@ -573,7 +573,7 @@ export const ConfigSchema = z
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"resource-policies": z
|
||||
"public-policies": z
|
||||
.record(z.string(), ResourcePolicySchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
@@ -607,7 +607,7 @@ export const ConfigSchema = z
|
||||
string,
|
||||
z.infer<typeof PrivateResourceSchema>
|
||||
>;
|
||||
"resource-policies": Record<
|
||||
"public-policies": Record<
|
||||
string,
|
||||
z.infer<typeof ResourcePolicySchema>
|
||||
>;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
certificates,
|
||||
db,
|
||||
domainNamespaces,
|
||||
@@ -182,6 +181,9 @@ export async function getTraefikConfig(
|
||||
const resourcesMap = new Map();
|
||||
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||
return;
|
||||
}
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||
@@ -295,13 +297,12 @@ export async function getTraefikConfig(
|
||||
maintenanceMessage: string | null;
|
||||
maintenanceEstimatedTime: string | null;
|
||||
targets: {
|
||||
browserGatewayTargetId: number;
|
||||
targetId: number;
|
||||
bgType: string;
|
||||
siteId: number;
|
||||
siteType: string;
|
||||
siteOnline: boolean | null;
|
||||
subnet: string | null;
|
||||
siteExitNodeId: number | null;
|
||||
}[];
|
||||
};
|
||||
const browserGatewayResourcesMap = new Map<
|
||||
@@ -310,66 +311,10 @@ export async function getTraefikConfig(
|
||||
>();
|
||||
|
||||
if (allowBrowserGatewayResources) {
|
||||
// Query browser gateway targets for this exit node
|
||||
const browserGatewayRows = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
resourceName: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
subdomain: resources.subdomain,
|
||||
domainId: resources.domainId,
|
||||
enabled: resources.enabled,
|
||||
wildcard: resources.wildcard,
|
||||
domainCertResolver: domains.certResolver,
|
||||
preferWildcardCert: domains.preferWildcardCert,
|
||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
||||
// Maintenance fields
|
||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||
maintenanceModeType: resources.maintenanceModeType,
|
||||
maintenanceTitle: resources.maintenanceTitle,
|
||||
maintenanceMessage: resources.maintenanceMessage,
|
||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
||||
// Browser gateway target fields
|
||||
browserGatewayTargetId:
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
bgType: browserGatewayTarget.type,
|
||||
// Site fields
|
||||
siteId: sites.siteId,
|
||||
siteType: sites.type,
|
||||
siteOnline: sites.online,
|
||||
subnet: sites.subnet,
|
||||
siteExitNodeId: sites.exitNodeId
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
||||
)
|
||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
||||
.leftJoin(
|
||||
domainNamespaces,
|
||||
eq(domainNamespaces.domainId, resources.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.enabled, 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)
|
||||
)
|
||||
);
|
||||
|
||||
for (const row of browserGatewayRows) {
|
||||
for (const row of resourcesWithTargetsAndSites) {
|
||||
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
|
||||
return;
|
||||
}
|
||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||
continue;
|
||||
}
|
||||
@@ -394,13 +339,12 @@ export async function getTraefikConfig(
|
||||
});
|
||||
}
|
||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
||||
bgType: row.bgType,
|
||||
targetId: row.targetId,
|
||||
bgType: row.mode,
|
||||
siteId: row.siteId,
|
||||
siteType: row.siteType,
|
||||
siteOnline: row.siteOnline,
|
||||
subnet: row.subnet,
|
||||
siteExitNodeId: row.siteExitNodeId
|
||||
subnet: row.subnet
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]),
|
||||
destination: z.string().nonempty(),
|
||||
destinationPort: z.number().int().min(1).max(65535)
|
||||
});
|
||||
|
||||
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
||||
description: "Create a browser gateway target for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const plainToken = generateId(48);
|
||||
const encryptedToken = encrypt(
|
||||
plainToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
const [record] = await db
|
||||
.insert(browserGatewayTarget)
|
||||
.values({
|
||||
resourceId,
|
||||
siteId,
|
||||
type,
|
||||
destination,
|
||||
destinationPort,
|
||||
authToken: encryptedToken
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[record],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
||||
);
|
||||
|
||||
return response<CreateBrowserGatewayTargetResponse>(res, {
|
||||
data: record,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Delete a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(browserGatewayTarget)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
);
|
||||
|
||||
if (existing.site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, existing.bgt.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await removeBrowserGatewayTarget(
|
||||
newt.newtId,
|
||||
browserGatewayTargetId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Get a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const [result] = await db
|
||||
.select({ bgt: browserGatewayTarget })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetBrowserGatewayTargetResponse>(res, {
|
||||
data: result.bgt,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,8 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { browserGatewayTarget, db } from "@server/db";
|
||||
import { resources, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, resources, targets } from "@server/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -51,11 +50,11 @@ export async function getBrowserTarget(
|
||||
|
||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||
|
||||
const [browserTarget] = await db
|
||||
const [row] = await db
|
||||
.select({
|
||||
destination: browserGatewayTarget.destination,
|
||||
destinationPort: browserGatewayTarget.destinationPort,
|
||||
authToken: browserGatewayTarget.authToken,
|
||||
ip: targets.ip,
|
||||
port: targets.port,
|
||||
authToken: targets.authToken,
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
name: resources.name,
|
||||
@@ -63,20 +62,18 @@ export async function getBrowserTarget(
|
||||
pamMode: resources.pamMode,
|
||||
authDaemonMode: resources.authDaemonMode
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(resources.fullDomain, fullDomain),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||
)
|
||||
)
|
||||
.where(eq(resources.fullDomain, fullDomain))
|
||||
.limit(1);
|
||||
|
||||
const decryptedAuthToken = decrypt(
|
||||
browserTarget.authToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
if (!browserTarget) {
|
||||
if (!row) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -85,17 +82,21 @@ export async function getBrowserTarget(
|
||||
);
|
||||
}
|
||||
|
||||
const decryptedAuthToken = row.authToken
|
||||
? decrypt(row.authToken, config.getRawConfig().server.secret!)
|
||||
: "";
|
||||
|
||||
return response<GetBrowserTargetResponse>(res, {
|
||||
data: {
|
||||
ip: browserTarget.destination,
|
||||
port: browserTarget.destinationPort,
|
||||
ip: row.ip,
|
||||
port: row.port,
|
||||
authToken: decryptedAuthToken,
|
||||
pamMode: browserTarget.pamMode,
|
||||
authDaemonMode: browserTarget.authDaemonMode,
|
||||
orgId: browserTarget.orgId,
|
||||
resourceId: browserTarget.resourceId,
|
||||
niceId: browserTarget.niceId,
|
||||
name: browserTarget.name
|
||||
pamMode: row.pamMode,
|
||||
authDaemonMode: row.authDaemonMode,
|
||||
orgId: row.orgId,
|
||||
resourceId: row.resourceId,
|
||||
niceId: row.niceId,
|
||||
name: row.name ?? ""
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -11,9 +11,4 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createBrowserGatewayTarget";
|
||||
export * from "./updateBrowserGatewayTarget";
|
||||
export * from "./deleteBrowserGatewayTarget";
|
||||
export * from "./getBrowserGatewayTarget";
|
||||
export * from "./listBrowserGatewayTargets";
|
||||
export * from "./getBrowserTarget";
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
resources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export type ListBrowserGatewayTargetsResponse = {
|
||||
targets: BrowserGatewayTarget[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
||||
description: "List browser gateway targets for a resource.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
query: querySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listBrowserGatewayTargets(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
browserGatewayTargetId:
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
resourceId: browserGatewayTarget.resourceId,
|
||||
siteId: browserGatewayTarget.siteId,
|
||||
authToken: browserGatewayTarget.authToken,
|
||||
type: browserGatewayTarget.type,
|
||||
destination: browserGatewayTarget.destination,
|
||||
destinationPort: browserGatewayTarget.destinationPort,
|
||||
siteName: sites.name
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.leftJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return response<ListBrowserGatewayTargetsResponse>(res, {
|
||||
data: {
|
||||
targets: rows as any,
|
||||
total: rows.length,
|
||||
limit,
|
||||
offset
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway targets retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to list browser gateway targets"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
db,
|
||||
newts,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
browserGatewayTargetId: z
|
||||
.string()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
siteId: z.number().int().positive().optional(),
|
||||
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
||||
destination: z.string().nonempty().optional(),
|
||||
destinationPort: z.number().int().min(1).max(65535).optional()
|
||||
});
|
||||
|
||||
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
||||
description: "Update a browser gateway target.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateBrowserGatewayTarget(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ bgt: browserGatewayTarget, site: sites })
|
||||
.from(browserGatewayTarget)
|
||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateValues: Partial<BrowserGatewayTarget> = {};
|
||||
if (siteId !== undefined) updateValues.siteId = siteId;
|
||||
if (type !== undefined) updateValues.type = type;
|
||||
if (destination !== undefined) updateValues.destination = destination;
|
||||
if (destinationPort !== undefined)
|
||||
updateValues.destinationPort = destinationPort;
|
||||
|
||||
const [updated] = await db
|
||||
.update(browserGatewayTarget)
|
||||
.set(updateValues)
|
||||
.where(
|
||||
eq(
|
||||
browserGatewayTarget.browserGatewayTargetId,
|
||||
browserGatewayTargetId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
const targetSiteId = siteId ?? existing.bgt.siteId;
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (site && site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, targetSiteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await sendBrowserGatewayTargets(
|
||||
newt.newtId,
|
||||
[updated],
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
||||
|
||||
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
||||
data: updated,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Browser gateway target updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update browser gateway target"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
import * as labels from "#private/routers/labels";
|
||||
import * as client from "@server/routers/client";
|
||||
import * as resource from "#private/routers/resource";
|
||||
@@ -879,48 +878,3 @@ authenticated.post(
|
||||
verifyClientAccess,
|
||||
client.rebuildClientAssociationsCacheRoute
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||
browserGatewayTarget.listBrowserGatewayTargets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||
browserGatewayTarget.getBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||
browserGatewayTarget.updateBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
import * as certificates from "#private/routers/certificates";
|
||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -216,43 +215,3 @@ authenticated.delete(
|
||||
logActionAudit(ActionsEnum.removeUserRole),
|
||||
user.removeUserRole
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
||||
browserGatewayTarget.createBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
||||
browserGatewayTarget.listBrowserGatewayTargets
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
||||
browserGatewayTarget.getBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
||||
browserGatewayTarget.updateBrowserGatewayTarget
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
||||
);
|
||||
|
||||
@@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as billing from "#private/routers/billing";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
||||
import * as ssh from "#private/routers/ssh";
|
||||
import * as ws from "@server/routers/ws";
|
||||
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
||||
|
||||
import {
|
||||
verifySessionUserMiddleware,
|
||||
|
||||
@@ -216,6 +216,7 @@ export async function listResourcePolicies(
|
||||
: await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
niceId: resources.niceId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
|
||||
@@ -30,8 +30,7 @@ import {
|
||||
userOrgs,
|
||||
sites,
|
||||
Resource,
|
||||
SiteResource,
|
||||
browserGatewayTarget
|
||||
SiteResource
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||
@@ -291,16 +290,15 @@ export async function signSshKey(
|
||||
const publicResource = resource as Resource;
|
||||
const targetRows = await db
|
||||
.select({
|
||||
siteId: browserGatewayTarget.siteId,
|
||||
ip: browserGatewayTarget.destination
|
||||
siteId: targets.siteId,
|
||||
ip: targets.ip
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
browserGatewayTarget.resourceId,
|
||||
publicResource.resourceId
|
||||
)
|
||||
eq(targets.resourceId, publicResource.resourceId),
|
||||
eq(targets.enabled, true),
|
||||
eq(targets.mode, "ssh")
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
BrowserGatewayTarget,
|
||||
clients,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
clientSitesAssociationsCache,
|
||||
@@ -16,7 +14,7 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import {
|
||||
@@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
||||
.where(
|
||||
and(
|
||||
eq(targets.siteId, siteId),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["http", "udp", "tcp"])
|
||||
)
|
||||
);
|
||||
|
||||
const allHealthChecks = await db
|
||||
.select({
|
||||
@@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
.from(targetHealthCheck)
|
||||
.where(eq(targetHealthCheck.siteId, siteId));
|
||||
|
||||
// Get all enabled targets with their resource mode information
|
||||
const allBrowserGatewayTargets = await db
|
||||
.select()
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
mode: resources.mode,
|
||||
authToken: targets.authToken
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.where(
|
||||
and(
|
||||
eq(targets.siteId, siteId),
|
||||
eq(targets.enabled, true),
|
||||
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||
)
|
||||
);
|
||||
|
||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||
(acc, target) => {
|
||||
@@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient(
|
||||
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||
if (!t.ip || !t.port || !t.authToken) {
|
||||
return null;
|
||||
}
|
||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
id: t.targetId,
|
||||
type: t.mode,
|
||||
destination: t.ip,
|
||||
destinationPort: t.port,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
||||
import { Target, TargetHealthCheck } from "@server/db";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import logger from "@server/logger";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
@@ -244,23 +244,27 @@ export async function removeTargets(
|
||||
|
||||
export async function sendBrowserGatewayTargets(
|
||||
newtId: string,
|
||||
targets: BrowserGatewayTarget[],
|
||||
targets: Target[],
|
||||
version?: string | null
|
||||
) {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const payload = targets.map((t) => {
|
||||
// filter out the ones without auth tokens
|
||||
const filteredTargets = targets.filter((t) => t.authToken);
|
||||
if (filteredTargets.length === 0) return;
|
||||
|
||||
const payload = filteredTargets.map((t) => {
|
||||
const decryptAuthToken = decrypt(
|
||||
t.authToken,
|
||||
t.authToken!,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
return {
|
||||
id: t.browserGatewayTargetId,
|
||||
id: t.targetId,
|
||||
resourceId: t.resourceId,
|
||||
siteId: t.siteId,
|
||||
type: t.type,
|
||||
destination: t.destination,
|
||||
destinationPort: t.destinationPort,
|
||||
type: t.mode,
|
||||
destination: t.ip,
|
||||
destinationPort: t.port,
|
||||
authToken: decryptAuthToken
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
browserGatewayTarget,
|
||||
alias,
|
||||
db,
|
||||
labels,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourceLabels,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
@@ -187,16 +186,98 @@ export type ResourceWithTargets = {
|
||||
};
|
||||
|
||||
function queryResourcesBase() {
|
||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||
const sharedPolicyPincode = alias(
|
||||
resourcePolicyPincode,
|
||||
"sharedPolicyPincode"
|
||||
);
|
||||
const defaultPolicyPincode = alias(
|
||||
resourcePolicyPincode,
|
||||
"defaultPolicyPincode"
|
||||
);
|
||||
const sharedPolicyPassword = alias(
|
||||
resourcePolicyPassword,
|
||||
"sharedPolicyPassword"
|
||||
);
|
||||
const defaultPolicyPassword = alias(
|
||||
resourcePolicyPassword,
|
||||
"defaultPolicyPassword"
|
||||
);
|
||||
const sharedPolicyHeaderAuth = alias(
|
||||
resourcePolicyHeaderAuth,
|
||||
"sharedPolicyHeaderAuth"
|
||||
);
|
||||
const defaultPolicyHeaderAuth = alias(
|
||||
resourcePolicyHeaderAuth,
|
||||
"defaultPolicyHeaderAuth"
|
||||
);
|
||||
|
||||
const effectivePasswordId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
|
||||
ELSE ${defaultPolicyPassword.passwordId}
|
||||
END,
|
||||
${resourcePassword.passwordId}
|
||||
)
|
||||
`;
|
||||
const effectivePincodeId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
|
||||
ELSE ${defaultPolicyPincode.pincodeId}
|
||||
END,
|
||||
${resourcePincode.pincodeId}
|
||||
)
|
||||
`;
|
||||
const effectiveHeaderAuthId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
|
||||
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
|
||||
END,
|
||||
${resourceHeaderAuth.headerAuthId}
|
||||
)
|
||||
`;
|
||||
const effectiveSso = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
|
||||
ELSE ${defaultPolicy.sso}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
const effectiveWhitelist = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
|
||||
ELSE ${defaultPolicy.emailWhitelistEnabled}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
const effectiveHeaderAuthExtendedCompatibility = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.extendedCompatibility}
|
||||
ELSE ${defaultPolicyHeaderAuth.extendedCompatibility}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
|
||||
return db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
ssl: resources.ssl,
|
||||
fullDomain: resources.fullDomain,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
sso: resourcePolicies.sso,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
whitelist: resourcePolicies.emailWhitelistEnabled,
|
||||
passwordId: effectivePasswordId,
|
||||
sso: effectiveSso,
|
||||
pincodeId: effectivePincodeId,
|
||||
whitelist: effectiveWhitelist,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled,
|
||||
domainId: resources.domainId,
|
||||
@@ -204,44 +285,74 @@ function queryResourcesBase() {
|
||||
wildcard: resources.wildcard,
|
||||
mode: resources.mode,
|
||||
health: resources.health,
|
||||
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
|
||||
headerAuthId: effectiveHeaderAuthId,
|
||||
headerAuthExtendedCompatibility:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
effectiveHeaderAuthExtendedCompatibility
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
or(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.resourcePolicyId
|
||||
),
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(resourceHeaderAuth.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
sharedPolicy,
|
||||
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||
)
|
||||
.leftJoin(
|
||||
sharedPolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
sharedPolicyPincode.resourcePolicyId,
|
||||
sharedPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
sharedPolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
sharedPolicyPassword.resourcePolicyId,
|
||||
sharedPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
sharedPolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
sharedPolicyHeaderAuth.resourcePolicyId,
|
||||
sharedPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicy,
|
||||
eq(
|
||||
defaultPolicy.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicyPincode,
|
||||
eq(
|
||||
defaultPolicyPincode.resourcePolicyId,
|
||||
defaultPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicyPassword,
|
||||
eq(
|
||||
defaultPolicyPassword.resourcePolicyId,
|
||||
defaultPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
defaultPolicyHeaderAuth,
|
||||
eq(
|
||||
defaultPolicyHeaderAuth.resourcePolicyId,
|
||||
defaultPolicy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -251,10 +362,23 @@ function queryResourcesBase() {
|
||||
)
|
||||
.groupBy(
|
||||
resources.resourceId,
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resourcePolicyPassword.passwordId,
|
||||
resourcePolicyPincode.pincodeId,
|
||||
resourcePolicyHeaderAuth.headerAuthId
|
||||
resourcePincode.pincodeId,
|
||||
resourcePassword.passwordId,
|
||||
resourceHeaderAuth.headerAuthId,
|
||||
sharedPolicy.resourcePolicyId,
|
||||
sharedPolicy.sso,
|
||||
sharedPolicy.emailWhitelistEnabled,
|
||||
sharedPolicyPincode.pincodeId,
|
||||
sharedPolicyPassword.passwordId,
|
||||
sharedPolicyHeaderAuth.headerAuthId,
|
||||
sharedPolicyHeaderAuth.extendedCompatibility,
|
||||
defaultPolicy.resourcePolicyId,
|
||||
defaultPolicy.sso,
|
||||
defaultPolicy.emailWhitelistEnabled,
|
||||
defaultPolicyPincode.pincodeId,
|
||||
defaultPolicyPassword.passwordId,
|
||||
defaultPolicyHeaderAuth.headerAuthId,
|
||||
defaultPolicyHeaderAuth.extendedCompatibility
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,6 +520,80 @@ export async function listResources(
|
||||
}
|
||||
|
||||
if (typeof authState !== "undefined") {
|
||||
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||
const sharedPolicyPincode = alias(
|
||||
resourcePolicyPincode,
|
||||
"sharedPolicyPincode"
|
||||
);
|
||||
const defaultPolicyPincode = alias(
|
||||
resourcePolicyPincode,
|
||||
"defaultPolicyPincode"
|
||||
);
|
||||
const sharedPolicyPassword = alias(
|
||||
resourcePolicyPassword,
|
||||
"sharedPolicyPassword"
|
||||
);
|
||||
const defaultPolicyPassword = alias(
|
||||
resourcePolicyPassword,
|
||||
"defaultPolicyPassword"
|
||||
);
|
||||
const sharedPolicyHeaderAuth = alias(
|
||||
resourcePolicyHeaderAuth,
|
||||
"sharedPolicyHeaderAuth"
|
||||
);
|
||||
const defaultPolicyHeaderAuth = alias(
|
||||
resourcePolicyHeaderAuth,
|
||||
"defaultPolicyHeaderAuth"
|
||||
);
|
||||
|
||||
const effectiveSso = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
|
||||
ELSE ${defaultPolicy.sso}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
const effectiveWhitelist = sql<boolean>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
|
||||
ELSE ${defaultPolicy.emailWhitelistEnabled}
|
||||
END,
|
||||
false
|
||||
)
|
||||
`;
|
||||
const effectiveHeaderAuthId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
|
||||
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
|
||||
END,
|
||||
${resourceHeaderAuth.headerAuthId}
|
||||
)
|
||||
`;
|
||||
const effectivePincodeId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
|
||||
ELSE ${defaultPolicyPincode.pincodeId}
|
||||
END,
|
||||
${resourcePincode.pincodeId}
|
||||
)
|
||||
`;
|
||||
const effectivePasswordId = sql<number | null>`
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
|
||||
ELSE ${defaultPolicyPassword.passwordId}
|
||||
END,
|
||||
${resourcePassword.passwordId}
|
||||
)
|
||||
`;
|
||||
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
|
||||
|
||||
switch (authState) {
|
||||
case "none":
|
||||
conditions.push(
|
||||
@@ -404,22 +602,28 @@ export async function listResources(
|
||||
break;
|
||||
case "protected":
|
||||
conditions.push(
|
||||
or(
|
||||
eq(resourcePolicies.sso, true),
|
||||
eq(resourcePolicies.emailWhitelistEnabled, true),
|
||||
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePolicyPincode.pincodeId)),
|
||||
not(isNull(resourcePolicyPassword.passwordId))
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
or(
|
||||
eq(effectiveSso, true),
|
||||
eq(effectiveWhitelist, true),
|
||||
not(isNull(effectiveHeaderAuthId)),
|
||||
not(isNull(effectivePincodeId)),
|
||||
not(isNull(effectivePasswordId))
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not_protected":
|
||||
conditions.push(
|
||||
not(eq(resourcePolicies.sso, true)),
|
||||
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
|
||||
isNull(resourcePolicyHeaderAuth.headerAuthId),
|
||||
isNull(resourcePolicyPincode.pincodeId),
|
||||
isNull(resourcePolicyPassword.passwordId)
|
||||
and(
|
||||
inArray(resources.mode, browserGatewayModes),
|
||||
not(eq(effectiveSso, true)),
|
||||
not(eq(effectiveWhitelist, true)),
|
||||
isNull(effectiveHeaderAuthId),
|
||||
isNull(effectivePincodeId),
|
||||
isNull(effectivePasswordId)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -434,15 +638,8 @@ export async function listResources(
|
||||
.from(targets)
|
||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||
const resourcesWithBrowserGateway = db
|
||||
.select({ resourceId: browserGatewayTarget.resourceId })
|
||||
.from(browserGatewayTarget)
|
||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
||||
conditions.push(
|
||||
or(
|
||||
inArray(resources.resourceId, resourcesWithSite),
|
||||
inArray(resources.resourceId, resourcesWithBrowserGateway)
|
||||
)
|
||||
or(inArray(resources.resourceId, resourcesWithSite))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -565,30 +762,6 @@ export async function listResources(
|
||||
)
|
||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||
|
||||
const allBgTargetSites =
|
||||
resourceIdList.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
resourceId: browserGatewayTarget.resourceId,
|
||||
siteId: browserGatewayTarget.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteOnline: sites.online,
|
||||
siteType: sites.type
|
||||
})
|
||||
.from(browserGatewayTarget)
|
||||
.where(
|
||||
inArray(
|
||||
browserGatewayTarget.resourceId,
|
||||
resourceIdList
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(sites.siteId, browserGatewayTarget.siteId)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourceWithTargets>();
|
||||
|
||||
@@ -651,21 +824,6 @@ export async function listResources(
|
||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||
});
|
||||
}
|
||||
const bgRaw = allBgTargetSites.filter(
|
||||
(t) => t.resourceId === entry.resourceId
|
||||
);
|
||||
for (const t of bgRaw) {
|
||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
||||
continue;
|
||||
}
|
||||
const isLocal = t.siteType === "local";
|
||||
siteById.set(t.siteId, {
|
||||
siteId: t.siteId,
|
||||
siteName: t.siteName ?? "",
|
||||
siteNiceId: t.siteNiceId ?? "",
|
||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||
});
|
||||
}
|
||||
entry.sites = Array.from(siteById.values());
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export type GetMaintenanceInfoResponse = {
|
||||
|
||||
export type AttachedResource = Pick<
|
||||
Resource,
|
||||
"resourceId" | "name" | "fullDomain"
|
||||
"resourceId" | "niceId" | "name" | "fullDomain"
|
||||
>;
|
||||
|
||||
export type ResourcePolicyWithResources = Pick<
|
||||
|
||||
@@ -93,10 +93,9 @@ export async function deleteSite(
|
||||
// Clean up all client associations and send peer/proxy removal
|
||||
// messages in a single efficient pass before deleting the row.
|
||||
await cleanupSiteAssociations(site, trx);
|
||||
|
||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||
}
|
||||
|
||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
userSites,
|
||||
labels,
|
||||
siteLabels,
|
||||
browserGatewayTarget,
|
||||
type Label
|
||||
} from "@server/db";
|
||||
import cache from "#dynamic/lib/cache";
|
||||
@@ -241,10 +240,6 @@ function querySitesBase() {
|
||||
ON ${siteResources.networkId} = ${siteNetworks.networkId}
|
||||
WHERE ${siteNetworks.siteId} = ${sites.siteId}
|
||||
AND ${siteResources.orgId} = ${sites.orgId}
|
||||
) + (
|
||||
SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId})
|
||||
FROM ${browserGatewayTarget}
|
||||
WHERE ${browserGatewayTarget.siteId} = ${sites.siteId}
|
||||
)`,
|
||||
status: sites.status
|
||||
})
|
||||
|
||||
@@ -142,6 +142,7 @@ const createSiteResourceSchema = z
|
||||
data.destinationPort <= 65535)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
|
||||
@@ -24,6 +24,9 @@ import {
|
||||
fireHealthCheckUnhealthyAlert,
|
||||
fireHealthCheckUnknownAlert
|
||||
} from "@server/lib/alerts";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import { generateId } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const createTargetParamsSchema = z.strictObject({
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
@@ -32,6 +35,7 @@ const createTargetParamsSchema = z.strictObject({
|
||||
const createTargetSchema = z.strictObject({
|
||||
siteId: z.int().positive(),
|
||||
ip: z.string().refine(isTargetValid),
|
||||
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||
method: z.string().optional().nullable(),
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().default(true),
|
||||
@@ -161,6 +165,12 @@ export async function createTarget(
|
||||
);
|
||||
}
|
||||
|
||||
const plainToken = generateId(48);
|
||||
const encryptedToken = encrypt(
|
||||
plainToken,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
let newTarget: Target[] = [];
|
||||
let targetIps: string[] = [];
|
||||
let healthCheck: TargetHealthCheck[] = [];
|
||||
@@ -191,6 +201,9 @@ export async function createTarget(
|
||||
.values({
|
||||
resourceId,
|
||||
...targetData,
|
||||
mode: (targetData.mode ??
|
||||
resource.mode ??
|
||||
"http") as Target["mode"],
|
||||
priority: targetData.priority || 100
|
||||
})
|
||||
.returning();
|
||||
@@ -226,6 +239,10 @@ export async function createTarget(
|
||||
resourceId,
|
||||
siteId: site.siteId,
|
||||
ip: targetData.ip,
|
||||
mode: (targetData.mode ??
|
||||
resource.mode ??
|
||||
"http") as Target["mode"],
|
||||
authToken: encryptedToken,
|
||||
method: targetData.method,
|
||||
port: targetData.port,
|
||||
internalPort,
|
||||
|
||||
@@ -34,6 +34,7 @@ function queryTargets(resourceId: number) {
|
||||
.select({
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
mode: targets.mode,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
enabled: targets.enabled,
|
||||
|
||||
@@ -27,6 +27,10 @@ const updateTargetBodySchema = z
|
||||
.strictObject({
|
||||
siteId: z.int().positive(),
|
||||
ip: z.string().refine(isTargetValid),
|
||||
mode: z
|
||||
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
method: z.string().min(1).max(10).optional().nullable(),
|
||||
port: z.int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -184,6 +188,8 @@ export async function updateTarget(
|
||||
}
|
||||
|
||||
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
||||
const nextMode =
|
||||
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
|
||||
|
||||
let updatedTarget: any;
|
||||
let updatedHc: any;
|
||||
@@ -193,6 +199,7 @@ export async function updateTarget(
|
||||
.set({
|
||||
siteId: parsedBody.data.siteId,
|
||||
ip: parsedBody.data.ip,
|
||||
mode: nextMode,
|
||||
method: parsedBody.data.method,
|
||||
port: parsedBody.data.port,
|
||||
internalPort,
|
||||
|
||||
@@ -39,18 +39,6 @@ export default async function migration() {
|
||||
try {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "browserGatewayTarget" (
|
||||
"browserGatewayTargetId" serial PRIMARY KEY NOT NULL,
|
||||
"resourceId" integer NOT NULL,
|
||||
"siteId" integer NOT NULL,
|
||||
"authToken" varchar NOT NULL,
|
||||
"type" varchar NOT NULL,
|
||||
"destination" varchar NOT NULL,
|
||||
"destinationPort" integer NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "clientLabels" (
|
||||
"clientLabelId" serial PRIMARY KEY NOT NULL,
|
||||
@@ -215,12 +203,6 @@ export default async function migration() {
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
|
||||
);
|
||||
@@ -289,6 +271,10 @@ export default async function migration() {
|
||||
);
|
||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
|
||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
|
||||
);
|
||||
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
|
||||
@@ -40,22 +40,6 @@ export default async function migration() {
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'browserGatewayTarget' (
|
||||
'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'resourceId' integer NOT NULL,
|
||||
'siteId' integer NOT NULL,
|
||||
'authToken' text NOT NULL,
|
||||
'type' text NOT NULL,
|
||||
'destination' text NOT NULL,
|
||||
'destinationPort' integer NOT NULL,
|
||||
FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
CREATE TABLE 'clientLabels' (
|
||||
@@ -350,6 +334,16 @@ export default async function migration() {
|
||||
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'targets' ADD 'authToken' text;
|
||||
`
|
||||
).run();
|
||||
})();
|
||||
|
||||
const existingResources = db
|
||||
|
||||
+5
-3
@@ -28,11 +28,11 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||
);
|
||||
policyResponse = res.data.data;
|
||||
} catch {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
if (!policyResponse) {
|
||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
||||
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -46,7 +46,9 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
<Link
|
||||
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||
>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
+3
-1
@@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage(
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
||||
<Link
|
||||
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||
>
|
||||
{t("resourcePoliciesSeeAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
PathRewriteDisplay,
|
||||
PathRewriteModal
|
||||
} from "@app/components/PathMatchRenameModal";
|
||||
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
|
||||
import {
|
||||
ResourceTargetAddressItem,
|
||||
ResourceTargetSiteItem
|
||||
} from "@app/components/resource-target-address-item";
|
||||
import {
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
@@ -65,6 +68,7 @@ import {
|
||||
useMemo,
|
||||
useState
|
||||
} from "react";
|
||||
import { maxSize } from "zod";
|
||||
|
||||
export type LocalTarget = Omit<
|
||||
ArrayElement<ListTargetsResponse["targets"]> & {
|
||||
@@ -138,11 +142,6 @@ export function ProxyResourceTargetsForm({
|
||||
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
|
||||
useState<LocalTarget | null>(null);
|
||||
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("");
|
||||
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
|
||||
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
|
||||
|
||||
const initializeDockerForSite = async (siteId: number) => {
|
||||
if (dockerStates.has(siteId)) {
|
||||
return;
|
||||
@@ -207,42 +206,6 @@ export function ProxyResourceTargetsForm({
|
||||
})
|
||||
);
|
||||
|
||||
// Browser-gateway targets (edit mode only)
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
enabled: !!resource
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const bgt = bgTargetsResponse.targets[0];
|
||||
setBgDestination(bgt.destination);
|
||||
setBgDestinationPort(String(bgt.destinationPort));
|
||||
setBgSiteId(bgt.siteId);
|
||||
setBgTargetId(bgt.browserGatewayTargetId);
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sites.length > 0 && bgSiteId === null) {
|
||||
setBgSiteId(sites[0].siteId);
|
||||
}
|
||||
}, [sites, bgSiteId]);
|
||||
|
||||
const updateTarget = useCallback(
|
||||
(targetId: number, data: Partial<LocalTarget>) => {
|
||||
setTargets((prevTargets) => {
|
||||
@@ -269,7 +232,7 @@ export function ProxyResourceTargetsForm({
|
||||
const priorityColumn: ColumnDef<LocalTarget> = {
|
||||
id: "priority",
|
||||
header: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{t("priority")}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
@@ -285,7 +248,6 @@ export function ProxyResourceTargetsForm({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -303,7 +265,6 @@ export function ProxyResourceTargetsForm({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
size: 120,
|
||||
@@ -437,13 +398,12 @@ export function ProxyResourceTargetsForm({
|
||||
maxSize: 200
|
||||
};
|
||||
|
||||
const addressColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "address",
|
||||
header: () => <span className="p-3">{t("address")}</span>,
|
||||
const siteColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "site",
|
||||
header: () => <span className="p-3">{t("site")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
<ResourceTargetSiteItem
|
||||
orgId={orgId}
|
||||
getDockerStateForSite={getDockerStateForSite}
|
||||
proxyTarget={row.original}
|
||||
@@ -452,9 +412,26 @@ export function ProxyResourceTargetsForm({
|
||||
/>
|
||||
);
|
||||
},
|
||||
size: 400,
|
||||
minSize: 350,
|
||||
maxSize: 500
|
||||
size: 220,
|
||||
minSize: 180,
|
||||
maxSize: 280
|
||||
};
|
||||
|
||||
const addressColumn: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "address",
|
||||
header: () => <span className="p-3">{t("address")}</span>,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<ResourceTargetAddressItem
|
||||
isHttp={isHttp}
|
||||
proxyTarget={row.original}
|
||||
updateTarget={updateTarget}
|
||||
/>
|
||||
);
|
||||
},
|
||||
size: 350,
|
||||
minSize: 300,
|
||||
maxSize: 450
|
||||
};
|
||||
|
||||
const rewritePathColumn: ColumnDef<LocalTarget> = {
|
||||
@@ -567,6 +544,7 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
if (isAdvancedMode) {
|
||||
const cols = [
|
||||
siteColumn,
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
@@ -575,12 +553,13 @@ export function ProxyResourceTargetsForm({
|
||||
|
||||
if (isHttp) {
|
||||
cols.unshift(matchPathColumn);
|
||||
cols.splice(3, 0, rewritePathColumn, priorityColumn);
|
||||
cols.splice(4, 0, rewritePathColumn, priorityColumn);
|
||||
}
|
||||
|
||||
return cols;
|
||||
} else {
|
||||
return [
|
||||
siteColumn,
|
||||
addressColumn,
|
||||
healthCheckColumn,
|
||||
enabledColumn,
|
||||
@@ -603,6 +582,8 @@ export function ProxyResourceTargetsForm({
|
||||
const newTarget: LocalTarget = {
|
||||
targetId: -Date.now(),
|
||||
ip: "",
|
||||
mode: ((resource?.mode as LocalTarget["mode"]) ??
|
||||
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
|
||||
method: isHttp ? "http" : null,
|
||||
port: 0,
|
||||
siteId: sites.length > 0 ? sites[0].siteId : 0,
|
||||
@@ -818,6 +799,10 @@ export function ProxyResourceTargetsForm({
|
||||
header.column
|
||||
.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
header.column
|
||||
.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableHead
|
||||
key={
|
||||
@@ -826,7 +811,9 @@ export function ProxyResourceTargetsForm({
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
@@ -858,6 +845,10 @@ export function ProxyResourceTargetsForm({
|
||||
cell.column
|
||||
.id ===
|
||||
"actions";
|
||||
const isSiteColumn =
|
||||
cell.column
|
||||
.id ===
|
||||
"site";
|
||||
return (
|
||||
<TableCell
|
||||
key={
|
||||
@@ -866,7 +857,9 @@ export function ProxyResourceTargetsForm({
|
||||
className={
|
||||
isActionsColumn
|
||||
? "sticky right-0 z-10 w-auto min-w-fit bg-card"
|
||||
: ""
|
||||
: isSiteColumn
|
||||
? "w-45"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
|
||||
@@ -330,7 +330,7 @@ export default function ResourceAuthenticationPage() {
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/${org.org.orgId}/settings/policies/resource/${policies.sharedPolicy.niceId}`}
|
||||
href={`/${org.org.orgId}/settings/policies/resources/public/${policies.sharedPolicy.niceId}`}
|
||||
>
|
||||
{t("editSharedPolicy")}
|
||||
<ArrowRightIcon className="size-4" />
|
||||
|
||||
@@ -11,201 +11,184 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { type Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { use, useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
type TargetRow = {
|
||||
targetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
mode: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
type ResourceTargetsResponse = {
|
||||
targets: TargetRow[];
|
||||
};
|
||||
|
||||
export default function RdpSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||
return res.data.data as ResourceTargetsResponse;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
|
||||
/>
|
||||
<SshServerForm
|
||||
<RdpServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
function RdpServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource,
|
||||
disabled
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
|
||||
const firstTarget = targets[0];
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
const formSchema = useMemo(
|
||||
() => createBrowserGatewayTargetFormSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
const form = useForm<BrowserGatewayTargetFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
selectedSites: targets.map((target) => ({
|
||||
siteId: target.siteId,
|
||||
name: target.siteName ?? String(target.siteId),
|
||||
type: "newt" as const
|
||||
})),
|
||||
destination: firstTarget?.ip ?? "",
|
||||
destinationPort: firstTarget ? String(firstTarget.port) : "3389"
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
() =>
|
||||
targets.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
siteId: target.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}, [bgTargetsResponse]);
|
||||
);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const { selectedSites, destination, destinationPort } =
|
||||
form.getValues();
|
||||
|
||||
try {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "rdp",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(`/target/${t.targetId}`, {
|
||||
mode: "rdp",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
siteId: t.siteId,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "rdp",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(`/resource/${resource.resourceId}/target`, {
|
||||
siteId: s.siteId,
|
||||
mode: "rdp",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
@@ -237,31 +220,31 @@ function SshServerForm({
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import {
|
||||
SitesSelector,
|
||||
type Selectedsite
|
||||
} from "@app/components/site-selector";
|
||||
import { SitesSelector } from "@app/components/site-selector";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
@@ -41,33 +38,39 @@ import { Badge } from "@app/components/ui/badge";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createSshSettingsFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import type { SshSettingsFormValues } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { use, useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
authToken?: string | null;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
type TargetRow = {
|
||||
targetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
mode: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
authToken?: string | null;
|
||||
};
|
||||
|
||||
type ResourceTargetsResponse = {
|
||||
targets: TargetRow[];
|
||||
};
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -75,10 +78,23 @@ export default function SshSettingsPage(props: {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||
return res.data.data as ResourceTargetsResponse;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
@@ -89,6 +105,7 @@ export default function SshSettingsPage(props: {
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
@@ -98,250 +115,260 @@ function SshServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource,
|
||||
disabled
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
|
||||
const isNativeInitially = resource.authDaemonMode === "native";
|
||||
const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
|
||||
const firstTarget = targets[0];
|
||||
const initialPamMode =
|
||||
(resource.pamMode as "passthrough" | "push") || "passthrough";
|
||||
const initialStandardDaemonLocation = isNativeInitially
|
||||
? "site"
|
||||
: ((resource.authDaemonMode as "site" | "remote") || "site");
|
||||
const useSingleSiteOnLoad =
|
||||
!isNativeInitially &&
|
||||
initialPamMode === "push" &&
|
||||
initialStandardDaemonLocation === "site";
|
||||
|
||||
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
|
||||
const [sshServerMode] = useState<"standard" | "native">(
|
||||
isNativeInitially ? "native" : "standard"
|
||||
);
|
||||
const isNative = sshServerMode === "native";
|
||||
|
||||
const [pamMode, setPamMode] = useState<"passthrough" | "push">(
|
||||
(resource.pamMode as "passthrough" | "push") || "passthrough"
|
||||
const formSchema = useMemo(
|
||||
() => createSshSettingsFormSchema(t, { isNative }),
|
||||
[t, isNative]
|
||||
);
|
||||
|
||||
const [standardDaemonLocation, setStandardDaemonLocation] = useState<
|
||||
"site" | "remote"
|
||||
>(
|
||||
isNativeInitially
|
||||
? "site"
|
||||
: (resource.authDaemonMode as "site" | "remote") || "site"
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(sshFormSchema),
|
||||
const form = useForm<SshSettingsFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
authDaemonPort: (resource as any).authDaemonPort
|
||||
? String((resource as any).authDaemonPort)
|
||||
: "22123"
|
||||
pamMode: initialPamMode,
|
||||
standardDaemonLocation: initialStandardDaemonLocation,
|
||||
authDaemonPort: (resource as { authDaemonPort?: number })
|
||||
.authDaemonPort
|
||||
? String((resource as { authDaemonPort?: number }).authDaemonPort)
|
||||
: "22123",
|
||||
selectedSites:
|
||||
isNativeInitially || useSingleSiteOnLoad
|
||||
? []
|
||||
: targets.map((target) => ({
|
||||
siteId: target.siteId,
|
||||
name: target.siteName ?? String(target.siteId),
|
||||
type: "newt" as const
|
||||
})),
|
||||
selectedSite:
|
||||
useSingleSiteOnLoad && firstTarget
|
||||
? {
|
||||
siteId: firstTarget.siteId,
|
||||
name:
|
||||
firstTarget.siteName ??
|
||||
String(firstTarget.siteId),
|
||||
type: "newt" as const
|
||||
}
|
||||
: null,
|
||||
selectedNativeSite:
|
||||
isNativeInitially && firstTarget
|
||||
? {
|
||||
siteId: firstTarget.siteId,
|
||||
name:
|
||||
firstTarget.siteName ??
|
||||
String(firstTarget.siteId),
|
||||
type: "newt" as const
|
||||
}
|
||||
: null,
|
||||
destination: isNativeInitially
|
||||
? ""
|
||||
: (firstTarget?.ip ?? ""),
|
||||
destinationPort: isNativeInitially
|
||||
? "22"
|
||||
: firstTarget
|
||||
? String(firstTarget.port)
|
||||
: "22"
|
||||
}
|
||||
});
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [selectedSite, setSelectedSite] = useState<Selectedsite | null>(null);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
() =>
|
||||
isNativeInitially
|
||||
? []
|
||||
: targets.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
siteId: target.siteId
|
||||
}))
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
useState<ExistingTarget | null>(() =>
|
||||
isNativeInitially && firstTarget
|
||||
? {
|
||||
targetId: firstTarget.targetId,
|
||||
siteId: firstTarget.siteId,
|
||||
authToken: firstTarget.authToken
|
||||
}
|
||||
: null
|
||||
);
|
||||
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
if (isNativeInitially) {
|
||||
setSelectedNativeSite({
|
||||
siteId: first.siteId,
|
||||
name: first.siteName ?? String(first.siteId),
|
||||
type: "newt" as const
|
||||
});
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId: first.browserGatewayTargetId,
|
||||
siteId: first.siteId
|
||||
});
|
||||
} else {
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [bgTargetsResponse]);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
const pamMode = form.watch("pamMode");
|
||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||
const selectedNativeSite = form.watch("selectedNativeSite");
|
||||
|
||||
async function save() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const effectiveMode = isNative ? "native" : standardDaemonLocation;
|
||||
const portVal = form.getValues().authDaemonPort;
|
||||
const values = form.getValues();
|
||||
const effectiveMode = isNative ? "native" : values.standardDaemonLocation;
|
||||
const effectivePort =
|
||||
!isNative && standardDaemonLocation === "remote" && portVal
|
||||
? Number(portVal)
|
||||
!isNative &&
|
||||
values.standardDaemonLocation === "remote" &&
|
||||
values.authDaemonPort
|
||||
? Number(values.authDaemonPort)
|
||||
: null;
|
||||
|
||||
try {
|
||||
await api.post(`/resource/${resource.resourceId}`, {
|
||||
pamMode,
|
||||
pamMode: values.pamMode,
|
||||
authDaemonMode: effectiveMode,
|
||||
authDaemonPort: effectivePort
|
||||
});
|
||||
|
||||
updateResource({
|
||||
...resource,
|
||||
pamMode,
|
||||
pamMode: values.pamMode,
|
||||
authDaemonMode: effectiveMode
|
||||
});
|
||||
|
||||
if (isNative) {
|
||||
if (selectedNativeSite) {
|
||||
if (values.selectedNativeSite) {
|
||||
if (nativeExistingTarget) {
|
||||
await api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
|
||||
`/target/${nativeExistingTarget.targetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22,
|
||||
siteId: selectedNativeSite.siteId
|
||||
mode: "ssh",
|
||||
ip: "localhost",
|
||||
port: 22,
|
||||
siteId: selectedNativeSite.siteId,
|
||||
authToken: nativeExistingTarget.authToken,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const res = await api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
`/resource/${resource.resourceId}/target`,
|
||||
{
|
||||
siteId: selectedNativeSite.siteId,
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22
|
||||
mode: "ssh",
|
||||
ip: "localhost",
|
||||
port: 22,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
setNativeExistingTarget({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: selectedNativeSite.siteId
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: selectedNativeSite.siteId,
|
||||
authToken: res.data.data.authToken
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
const useMultiSite =
|
||||
values.standardDaemonLocation !== "site" ||
|
||||
values.pamMode === "passthrough";
|
||||
const activeSites = useMultiSite
|
||||
? values.selectedSites
|
||||
: values.selectedSite
|
||||
? [values.selectedSite]
|
||||
: [];
|
||||
const selectedSiteIds = new Set(
|
||||
activeSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
>>>>>>> 8ee520dbb58f6bd4009581c79322f77b17ff6757
|
||||
)
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
`/target/${t.targetId}`,
|
||||
{
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
mode: "ssh",
|
||||
ip: bgDestination,
|
||||
api.delete(`/target/${t.targetId}`)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
`/target/${t.targetId}`,
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
siteId: t.siteId,
|
||||
authToken: t.authToken,
|
||||
hcEnabled: false
|
||||
port: Number(bgDestinationPort),
|
||||
hcEnabled: false
|
||||
)
|
||||
);
|
||||
|
||||
)
|
||||
);
|
||||
|
||||
<<<<<<< HEAD
|
||||
const newTargets: ExistingTarget[] = created.map(
|
||||
(res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
})
|
||||
);
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
description: t("settingsUpdatedDescription")
|
||||
`/resource/${resource.resourceId}/target`,
|
||||
siteId: toCreate[i].siteId,
|
||||
authToken: res.data.data.authToken
|
||||
mode: "ssh",
|
||||
ip: values.destination,
|
||||
port: Number(values.destinationPort),
|
||||
hcEnabled: false
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
)
|
||||
);
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settingsErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("settingsErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
}
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: toCreate[i].siteId,
|
||||
authToken: res.data.data.authToken
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
|
||||
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
|
||||
@@ -373,6 +400,9 @@ function SshServerForm({
|
||||
const showDaemonLocation = !isNative && pamMode === "push";
|
||||
const showDaemonPort =
|
||||
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
|
||||
const useMultiSiteTargetForm =
|
||||
!isNative &&
|
||||
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
@@ -386,160 +416,189 @@ function SshServerForm({
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerMode")}
|
||||
</SettingsSubsectionTitle>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
|
||||
<Badge variant="secondary">
|
||||
{sshServerMode == "standard"
|
||||
? t("sshServerModeStandard")
|
||||
: t("sshServerModePangolin")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthenticationMethod")}
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={setPamMode}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
|
||||
<StrategySelect<"passthrough" | "push">
|
||||
value={pamMode}
|
||||
options={authMethodOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue("pamMode", value, {
|
||||
shouldValidate: true
|
||||
})
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</SettingsSubsectionTitle>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={setStandardDaemonLocation}
|
||||
cols={2}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<Form {...form}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
{isNative ? (
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
onOpenChange={setNativeSiteOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNativeSite?.name ??
|
||||
t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedNativeSite}
|
||||
onSelectSite={(site) => {
|
||||
setSelectedNativeSite(site);
|
||||
setNativeSiteOpen(false);
|
||||
}}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
|
||||
<StrategySelect<"site" | "remote">
|
||||
value={standardDaemonLocation}
|
||||
options={daemonLocationOptions}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
"standardDaemonLocation",
|
||||
value,
|
||||
{ shouldValidate: true }
|
||||
)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : standardDaemonLocation !== "site" ||
|
||||
pamMode === "passthrough" ? (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
) : (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={false}
|
||||
selectedSite={selectedSite}
|
||||
onSiteChange={setSelectedSite}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDaemonPort && (
|
||||
<div className="w-full md:w-1/2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("sshDaemonPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerDestination")}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t("sshServerDestinationDescription")}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
{isNative ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="selectedNativeSite"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Popover
|
||||
open={nativeSiteOpen}
|
||||
onOpenChange={
|
||||
setNativeSiteOpen
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full max-w-xs justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedNativeSite?.name ??
|
||||
t(
|
||||
"siteSelect"
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={
|
||||
selectedNativeSite
|
||||
}
|
||||
onSelectSite={(
|
||||
site
|
||||
) => {
|
||||
form.setValue(
|
||||
"selectedNativeSite",
|
||||
site,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
setNativeSiteOpen(
|
||||
false
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : useMultiSiteTargetForm ? (
|
||||
<BrowserGatewayTargetForm
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
) : (
|
||||
<BrowserGatewayTargetForm
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
multiSite={false}
|
||||
siteField="selectedSite"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -11,199 +11,184 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { type Selectedsite } from "@app/components/site-selector";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Form } from "@app/components/ui/form";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { createBrowserGatewayTargetFormSchema } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import type { BrowserGatewayTargetFormValues } from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { use, useActionState, useEffect, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { use, useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { GetResourceResponse } from "@server/routers/resource";
|
||||
import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
|
||||
type ExistingTarget = {
|
||||
browserGatewayTargetId: number;
|
||||
targetId: number;
|
||||
siteId: number;
|
||||
};
|
||||
|
||||
const sshFormSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
type TargetRow = {
|
||||
targetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
mode: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export default function SshSettingsPage(props: {
|
||||
type ResourceTargetsResponse = {
|
||||
targets: TargetRow[];
|
||||
};
|
||||
|
||||
export default function VncSettingsPage(props: {
|
||||
params: Promise<{ orgId: string }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const disabled = !isPaidUser(
|
||||
tierMatrix[TierFeature.AdvancedPublicResources]
|
||||
);
|
||||
|
||||
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
|
||||
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/resource/${resource.resourceId}/targets`);
|
||||
return res.data.data as ResourceTargetsResponse;
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoadingTargets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix[TierFeature.AdvancedPublicResources]}
|
||||
/>
|
||||
<SshServerForm
|
||||
<VncServerForm
|
||||
orgId={params.orgId}
|
||||
resource={resource}
|
||||
updateResource={updateResource}
|
||||
disabled={disabled}
|
||||
targetsResponse={targetsResponse ?? { targets: [] }}
|
||||
/>
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SshServerForm({
|
||||
function VncServerForm({
|
||||
orgId,
|
||||
resource,
|
||||
updateResource,
|
||||
disabled
|
||||
disabled,
|
||||
targetsResponse
|
||||
}: {
|
||||
orgId: string;
|
||||
resource: GetResourceResponse;
|
||||
updateResource: ResourceContextType["updateResource"];
|
||||
disabled: boolean;
|
||||
targetsResponse: ResourceTargetsResponse;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
|
||||
const firstTarget = targets[0];
|
||||
|
||||
// Standard mode: multi-site
|
||||
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
[]
|
||||
const formSchema = useMemo(
|
||||
() => createBrowserGatewayTargetFormSchema(t),
|
||||
[t]
|
||||
);
|
||||
|
||||
// Native mode: single site
|
||||
const [selectedNativeSite, setSelectedNativeSite] =
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeExistingTarget, setNativeExistingTarget] =
|
||||
useState<ExistingTarget | null>(null);
|
||||
|
||||
const { data: bgTargetsResponse } = useQuery({
|
||||
queryKey: ["browserGatewayTargets", resource.resourceId, orgId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-targets`
|
||||
);
|
||||
return res.data.data as {
|
||||
targets: Array<{
|
||||
browserGatewayTargetId: number;
|
||||
resourceId: number;
|
||||
siteId: number;
|
||||
siteName?: string;
|
||||
type: string;
|
||||
destination: string;
|
||||
destinationPort: number;
|
||||
}>;
|
||||
};
|
||||
const form = useForm<BrowserGatewayTargetFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
selectedSites: targets.map((target) => ({
|
||||
siteId: target.siteId,
|
||||
name: target.siteName ?? String(target.siteId),
|
||||
type: "newt" as const
|
||||
})),
|
||||
destination: firstTarget?.ip ?? "",
|
||||
destinationPort: firstTarget ? String(firstTarget.port) : "5900"
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!bgTargetsResponse?.targets?.length) return;
|
||||
const targets = bgTargetsResponse.targets;
|
||||
const first = targets[0];
|
||||
|
||||
setBgDestination(first.destination);
|
||||
setBgDestinationPort(String(first.destinationPort));
|
||||
setExistingTargets(
|
||||
targets.map((t) => ({
|
||||
browserGatewayTargetId: t.browserGatewayTargetId,
|
||||
siteId: t.siteId
|
||||
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
|
||||
() =>
|
||||
targets.map((target) => ({
|
||||
targetId: target.targetId,
|
||||
siteId: target.siteId
|
||||
}))
|
||||
);
|
||||
setSelectedSites(
|
||||
targets.map((t) => ({
|
||||
siteId: t.siteId,
|
||||
name: t.siteName ?? String(t.siteId),
|
||||
type: "newt" as const
|
||||
}))
|
||||
);
|
||||
}, [bgTargetsResponse]);
|
||||
);
|
||||
|
||||
const [, formAction, isSubmitting] = useActionState(save, null);
|
||||
|
||||
async function save() {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const { selectedSites, destination, destinationPort } =
|
||||
form.getValues();
|
||||
|
||||
try {
|
||||
if (bgDestination && bgDestinationPort) {
|
||||
const selectedSiteIds = new Set(
|
||||
selectedSites.map((s) => s.siteId)
|
||||
);
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
const selectedSiteIds = new Set(selectedSites.map((s) => s.siteId));
|
||||
const existingSiteIds = new Set(
|
||||
existingTargets.map((t) => t.siteId)
|
||||
);
|
||||
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toDelete.map((t) =>
|
||||
api.delete(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
|
||||
)
|
||||
)
|
||||
);
|
||||
const toDelete = existingTargets.filter(
|
||||
(t) => !selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
|
||||
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(
|
||||
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
|
||||
{
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort),
|
||||
siteId: t.siteId
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const toUpdate = existingTargets.filter((t) =>
|
||||
selectedSiteIds.has(t.siteId)
|
||||
);
|
||||
await Promise.all(
|
||||
toUpdate.map((t) =>
|
||||
api.post(`/target/${t.targetId}`, {
|
||||
mode: "vnc",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
siteId: t.siteId,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(
|
||||
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
|
||||
{
|
||||
siteId: s.siteId,
|
||||
type: "vnc",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
const toCreate = selectedSites.filter(
|
||||
(s) => !existingSiteIds.has(s.siteId)
|
||||
);
|
||||
const created = await Promise.all(
|
||||
toCreate.map((s) =>
|
||||
api.put(`/resource/${resource.resourceId}/target`, {
|
||||
siteId: s.siteId,
|
||||
mode: "vnc",
|
||||
ip: destination,
|
||||
port: Number(destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
browserGatewayTargetId:
|
||||
res.data.data.browserGatewayTargetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
}
|
||||
const newTargets: ExistingTarget[] = created.map((res, i) => ({
|
||||
targetId: res.data.data.targetId,
|
||||
siteId: toCreate[i].siteId
|
||||
}));
|
||||
setExistingTargets([...toUpdate, ...newTargets]);
|
||||
|
||||
toast({
|
||||
title: t("settingsUpdated"),
|
||||
@@ -235,31 +220,31 @@ function SshServerForm({
|
||||
disabled={disabled}
|
||||
className={disabled ? "opacity-50 pointer-events-none" : ""}
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
selectedSites={selectedSites}
|
||||
onSitesChange={setSelectedSites}
|
||||
destination={bgDestination}
|
||||
destinationPort={bgDestinationPort}
|
||||
onDestinationChange={setBgDestination}
|
||||
onDestinationPortChange={setBgDestinationPort}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
<Form {...form}>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
control={form.control}
|
||||
orgId={orgId}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
<form action={formAction} className="flex justify-end mt-4">
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</fieldset>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,12 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
createBrowserGatewayTargetFormSchema,
|
||||
createSshSettingsFormSchema,
|
||||
selectedSiteSchema,
|
||||
type SshSettingsFormValues
|
||||
} from "@app/lib/browserGatewayTargetFormSchema";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
@@ -79,100 +85,134 @@ import {
|
||||
useTransition,
|
||||
useEffect
|
||||
} from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
http: z.boolean()
|
||||
});
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
const httpResourceFormSchema = z.object({
|
||||
domainId: z.string().nonempty(),
|
||||
subdomain: z.string().optional()
|
||||
});
|
||||
function createBaseResourceFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t("nameRequired") })
|
||||
.max(255, {
|
||||
message: t("createInternalResourceDialogNameMaxLength")
|
||||
}),
|
||||
http: z.boolean()
|
||||
});
|
||||
}
|
||||
|
||||
const tcpUdpResourceFormSchema = z.object({
|
||||
protocol: z.string(),
|
||||
proxyPort: z.int().min(1).max(65535)
|
||||
});
|
||||
function createHttpResourceFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
domainId: z.string().min(1, { message: t("domainRequired") }),
|
||||
subdomain: z.string().optional()
|
||||
});
|
||||
}
|
||||
|
||||
const sshDaemonPortSchema = z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: "Port must be between 1 and 65535" }
|
||||
)
|
||||
});
|
||||
function createTcpUdpResourceFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
protocol: z.string(),
|
||||
proxyPort: z
|
||||
.number({ error: t("proxyPortRequired") })
|
||||
.int({ error: t("healthCheckPortInvalid") })
|
||||
.min(1, { message: t("healthCheckPortInvalid") })
|
||||
.max(65535, { message: t("healthCheckPortInvalid") })
|
||||
});
|
||||
}
|
||||
|
||||
const addTargetSchema = z
|
||||
.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
method: z.string().nullable(),
|
||||
port: z.coerce.number<number>().int().positive(),
|
||||
siteId: z.int().positive(),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z
|
||||
.enum(["exact", "prefix", "regex"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
rewritePath: z.string().optional().nullable(),
|
||||
rewritePathType: z
|
||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
priority: z.int().min(1).max(1000).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.path && !data.pathMatchType) {
|
||||
return false;
|
||||
}
|
||||
if (data.pathMatchType && !data.path) {
|
||||
return false;
|
||||
}
|
||||
if (data.path && data.pathMatchType) {
|
||||
switch (data.pathMatchType) {
|
||||
case "exact":
|
||||
case "prefix":
|
||||
return data.path.startsWith("/");
|
||||
case "regex":
|
||||
try {
|
||||
new RegExp(data.path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
error: "Invalid path configuration"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.rewritePath && !data.rewritePathType) {
|
||||
return false;
|
||||
}
|
||||
if (data.rewritePathType && !data.rewritePath) {
|
||||
if (data.rewritePathType !== "stripPrefix") {
|
||||
function createSshDaemonPortSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
authDaemonPort: z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: t("healthCheckPortInvalid") }
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
function createAddTargetSchema(t: TranslateFn) {
|
||||
return z
|
||||
.object({
|
||||
ip: z.string().refine(isTargetValid, {
|
||||
message: t("targetErrorInvalidIpDescription")
|
||||
}),
|
||||
method: z.string().nullable(),
|
||||
port: z.coerce
|
||||
.number<number>({ error: t("targetErrorInvalidPortDescription") })
|
||||
.int({ error: t("targetErrorInvalidPortDescription") })
|
||||
.positive({ error: t("targetErrorInvalidPortDescription") }),
|
||||
siteId: z
|
||||
.int({ error: t("siteRequired") })
|
||||
.positive({ error: t("siteRequired") }),
|
||||
path: z.string().optional().nullable(),
|
||||
pathMatchType: z
|
||||
.enum(["exact", "prefix", "regex"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
rewritePath: z.string().optional().nullable(),
|
||||
rewritePathType: z
|
||||
.enum(["exact", "prefix", "regex", "stripPrefix"])
|
||||
.optional()
|
||||
.nullable(),
|
||||
priority: z
|
||||
.int()
|
||||
.min(1, { message: t("healthCheckPortInvalid") })
|
||||
.max(1000, { message: t("healthCheckPortInvalid") })
|
||||
.optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.path && !data.pathMatchType) {
|
||||
return false;
|
||||
}
|
||||
if (data.pathMatchType && !data.path) {
|
||||
return false;
|
||||
}
|
||||
if (data.path && data.pathMatchType) {
|
||||
switch (data.pathMatchType) {
|
||||
case "exact":
|
||||
case "prefix":
|
||||
return data.path.startsWith("/");
|
||||
case "regex":
|
||||
try {
|
||||
new RegExp(data.path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("invalidPathConfiguration")
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
error: "Invalid rewrite path configuration"
|
||||
}
|
||||
);
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.rewritePath && !data.rewritePathType) {
|
||||
return false;
|
||||
}
|
||||
if (data.rewritePathType && !data.rewritePath) {
|
||||
if (data.rewritePathType !== "stripPrefix") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("invalidRewritePathConfiguration")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type NewResourceType = "http" | "ssh" | "rdp" | "vnc" | "tcp" | "udp";
|
||||
|
||||
type CreateBgTargetFormValues = SshSettingsFormValues;
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -223,29 +263,6 @@ export default function Page() {
|
||||
useState<Selectedsite | null>(null);
|
||||
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
|
||||
|
||||
// Browser-gateway targets state (SSH standard, RDP, VNC)
|
||||
const [bgSelectedSites, setBgSelectedSites] = useState<Selectedsite[]>([]);
|
||||
const [bgSelectedSite, setBgSelectedSite] = useState<Selectedsite | null>(
|
||||
null
|
||||
);
|
||||
const [bgDestination, setBgDestination] = useState("");
|
||||
const [bgDestinationPort, setBgDestinationPort] = useState("22");
|
||||
|
||||
// Reset BG state when resource type changes
|
||||
useEffect(() => {
|
||||
if (resourceType === "rdp") {
|
||||
setBgDestinationPort("3389");
|
||||
} else if (resourceType === "vnc") {
|
||||
setBgDestinationPort("5900");
|
||||
} else if (resourceType === "ssh") {
|
||||
setBgDestinationPort("22");
|
||||
}
|
||||
setBgDestination("");
|
||||
setBgSelectedSites([]);
|
||||
setBgSelectedSite(null);
|
||||
setNativeSelectedSite(null);
|
||||
}, [resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (build !== "saas") return;
|
||||
|
||||
@@ -278,6 +295,39 @@ export default function Page() {
|
||||
pamMode === "push" &&
|
||||
standardDaemonLocation === "remote";
|
||||
|
||||
const bgTargetFormSchema = useMemo(() => {
|
||||
if (resourceType === "ssh" && !isNative) {
|
||||
return createSshSettingsFormSchema(t, { isNative: false });
|
||||
}
|
||||
if (resourceType === "rdp" || resourceType === "vnc") {
|
||||
return createBrowserGatewayTargetFormSchema(t);
|
||||
}
|
||||
return z.object({
|
||||
selectedSites: z.array(selectedSiteSchema),
|
||||
selectedSite: selectedSiteSchema.nullable(),
|
||||
destination: z.string(),
|
||||
destinationPort: z.string(),
|
||||
pamMode: z.enum(["passthrough", "push"]),
|
||||
standardDaemonLocation: z.enum(["site", "remote"])
|
||||
});
|
||||
}, [resourceType, isNative, t]);
|
||||
|
||||
const bgTargetForm = useForm<CreateBgTargetFormValues>({
|
||||
resolver: zodResolver(
|
||||
bgTargetFormSchema
|
||||
) as unknown as Resolver<CreateBgTargetFormValues>,
|
||||
defaultValues: {
|
||||
selectedSites: [],
|
||||
selectedSite: null,
|
||||
selectedNativeSite: null,
|
||||
destination: "",
|
||||
destinationPort: "22",
|
||||
pamMode: "passthrough",
|
||||
standardDaemonLocation: "site",
|
||||
authDaemonPort: "22123"
|
||||
}
|
||||
});
|
||||
|
||||
// Whether raw (TCP/UDP) resources are available
|
||||
const rawResourcesAllowed =
|
||||
env.flags.allowRawResources &&
|
||||
@@ -302,6 +352,24 @@ export default function Page() {
|
||||
}
|
||||
}, [availableTypes, resourceType]);
|
||||
|
||||
const baseResourceFormSchema = useMemo(
|
||||
() => createBaseResourceFormSchema(t),
|
||||
[t]
|
||||
);
|
||||
const httpResourceFormSchema = useMemo(
|
||||
() => createHttpResourceFormSchema(t),
|
||||
[t]
|
||||
);
|
||||
const tcpUdpResourceFormSchema = useMemo(
|
||||
() => createTcpUdpResourceFormSchema(t),
|
||||
[t]
|
||||
);
|
||||
const sshDaemonPortSchema = useMemo(
|
||||
() => createSshDaemonPortSchema(t),
|
||||
[t]
|
||||
);
|
||||
const addTargetSchema = useMemo(() => createAddTargetSchema(t), [t]);
|
||||
|
||||
const baseForm = useForm({
|
||||
resolver: zodResolver(baseResourceFormSchema),
|
||||
defaultValues: {
|
||||
@@ -330,6 +398,31 @@ export default function Page() {
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const defaultPort =
|
||||
resourceType === "rdp"
|
||||
? "3389"
|
||||
: resourceType === "vnc"
|
||||
? "5900"
|
||||
: "22";
|
||||
bgTargetForm.reset({
|
||||
selectedSites: [],
|
||||
selectedSite: null,
|
||||
selectedNativeSite: null,
|
||||
destination: "",
|
||||
destinationPort: defaultPort,
|
||||
pamMode,
|
||||
standardDaemonLocation,
|
||||
authDaemonPort: sshDaemonPortForm.getValues().authDaemonPort
|
||||
});
|
||||
setNativeSelectedSite(null);
|
||||
}, [resourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
bgTargetForm.setValue("pamMode", pamMode);
|
||||
bgTargetForm.setValue("standardDaemonLocation", standardDaemonLocation);
|
||||
}, [pamMode, standardDaemonLocation]);
|
||||
|
||||
// Sync form http field with resourceType
|
||||
useEffect(() => {
|
||||
baseForm.setValue("http", isHttpResource);
|
||||
@@ -498,30 +591,35 @@ export default function Page() {
|
||||
if (isNative) {
|
||||
if (nativeSelectedSite) {
|
||||
await api.put(
|
||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
||||
`/resource/${id}/target`,
|
||||
{
|
||||
siteId: nativeSelectedSite.siteId,
|
||||
type: "ssh",
|
||||
destination: "localhost",
|
||||
destinationPort: 22
|
||||
mode: "ssh",
|
||||
ip: "localhost",
|
||||
port: 22,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const sitesToCreate =
|
||||
standardDaemonLocation !== "site"
|
||||
? bgSelectedSites
|
||||
: bgSelectedSite
|
||||
? [bgSelectedSite]
|
||||
: [];
|
||||
const bgValues = bgTargetForm.getValues();
|
||||
const useMultiSite =
|
||||
standardDaemonLocation !== "site" ||
|
||||
pamMode === "passthrough";
|
||||
const sitesToCreate = useMultiSite
|
||||
? bgValues.selectedSites
|
||||
: bgValues.selectedSite
|
||||
? [bgValues.selectedSite]
|
||||
: [];
|
||||
for (const site of sitesToCreate) {
|
||||
await api.put(
|
||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
||||
`/resource/${id}/target`,
|
||||
{
|
||||
siteId: site.siteId,
|
||||
type: "ssh",
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
mode: "ssh",
|
||||
ip: bgValues.destination,
|
||||
port: Number(bgValues.destinationPort),
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -531,16 +629,19 @@ export default function Page() {
|
||||
`/${orgId}/settings/resources/public/${newNiceId}`
|
||||
);
|
||||
} else if (resourceType === "rdp" || resourceType === "vnc") {
|
||||
for (const site of bgSelectedSites) {
|
||||
const bgValues = bgTargetForm.getValues();
|
||||
for (const site of bgValues.selectedSites) {
|
||||
await api.put(
|
||||
`/org/${orgId}/resource/${id}/browser-gateway-target`,
|
||||
`/resource/${id}/target`,
|
||||
{
|
||||
siteId: site.siteId,
|
||||
type: resourceType,
|
||||
destination: bgDestination,
|
||||
destinationPort: Number(bgDestinationPort)
|
||||
mode: resourceType,
|
||||
ip: bgValues.destination,
|
||||
port: Number(bgValues.destinationPort),
|
||||
authToken: null,
|
||||
hcEnabled: false
|
||||
}
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
router.push(
|
||||
@@ -760,32 +861,56 @@ export default function Page() {
|
||||
|
||||
{/* Domain/Subdomain (HTTP-based types) */}
|
||||
{isHttpResource && (
|
||||
<div className="space-y-2">
|
||||
<DomainPicker
|
||||
allowWildcard={true}
|
||||
orgId={orgId as string}
|
||||
warnOnProvidedDomain={
|
||||
remoteExitNodes.length >=
|
||||
1
|
||||
}
|
||||
onDomainChange={(res) => {
|
||||
if (!res) return;
|
||||
httpForm.setValue(
|
||||
"subdomain",
|
||||
res.subdomain
|
||||
);
|
||||
httpForm.setValue(
|
||||
"domainId",
|
||||
res.domainId
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
<Form {...httpForm}>
|
||||
<FormField
|
||||
control={httpForm.control}
|
||||
name="domainId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<DomainPicker
|
||||
allowWildcard={
|
||||
true
|
||||
}
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
warnOnProvidedDomain={
|
||||
remoteExitNodes.length >=
|
||||
1
|
||||
}
|
||||
onDomainChange={(
|
||||
res
|
||||
) => {
|
||||
if (!res)
|
||||
return;
|
||||
httpForm.setValue(
|
||||
"subdomain",
|
||||
res.subdomain,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
httpForm.setValue(
|
||||
"domainId",
|
||||
res.domainId,
|
||||
{
|
||||
shouldValidate:
|
||||
true
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{t(
|
||||
"resourceDomainDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Proxy Port (TCP/UDP types) */}
|
||||
@@ -883,9 +1008,7 @@ export default function Page() {
|
||||
<SettingsSectionForm variant="half">
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t("sshServerMode")}
|
||||
</SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">{t("sshServerMode")}</p>
|
||||
<StrategySelect<
|
||||
"standard" | "native"
|
||||
>
|
||||
@@ -897,11 +1020,7 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"sshAuthenticationMethod"
|
||||
)}
|
||||
</SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
|
||||
<StrategySelect<
|
||||
"passthrough" | "push"
|
||||
>
|
||||
@@ -917,11 +1036,7 @@ export default function Page() {
|
||||
{/* Daemon Location (standard + push) */}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"sshAuthDaemonLocation"
|
||||
)}
|
||||
</SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
|
||||
<StrategySelect<
|
||||
"site" | "remote"
|
||||
>
|
||||
@@ -1052,55 +1167,39 @@ export default function Page() {
|
||||
"site" ||
|
||||
pamMode ===
|
||||
"passthrough" ? (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId as string}
|
||||
multiSite={true}
|
||||
selectedSites={
|
||||
bgSelectedSites
|
||||
}
|
||||
onSitesChange={
|
||||
setBgSelectedSites
|
||||
}
|
||||
destination={
|
||||
bgDestination
|
||||
}
|
||||
destinationPort={
|
||||
bgDestinationPort
|
||||
}
|
||||
onDestinationChange={
|
||||
setBgDestination
|
||||
}
|
||||
onDestinationPortChange={
|
||||
setBgDestinationPort
|
||||
}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
<Form {...bgTargetForm}>
|
||||
<BrowserGatewayTargetForm
|
||||
control={
|
||||
bgTargetForm.control
|
||||
}
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</Form>
|
||||
) : (
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId as string}
|
||||
multiSite={false}
|
||||
selectedSite={
|
||||
bgSelectedSite
|
||||
}
|
||||
onSiteChange={
|
||||
setBgSelectedSite
|
||||
}
|
||||
destination={
|
||||
bgDestination
|
||||
}
|
||||
destinationPort={
|
||||
bgDestinationPort
|
||||
}
|
||||
onDestinationChange={
|
||||
setBgDestination
|
||||
}
|
||||
onDestinationPortChange={
|
||||
setBgDestinationPort
|
||||
}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
<Form {...bgTargetForm}>
|
||||
<BrowserGatewayTargetForm
|
||||
control={
|
||||
bgTargetForm.control
|
||||
}
|
||||
orgId={
|
||||
orgId as string
|
||||
}
|
||||
multiSite={false}
|
||||
siteField="selectedSite"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSectionForm>
|
||||
@@ -1138,26 +1237,18 @@ export default function Page() {
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId as string}
|
||||
multiSite={true}
|
||||
selectedSites={bgSelectedSites}
|
||||
onSitesChange={
|
||||
setBgSelectedSites
|
||||
}
|
||||
destination={bgDestination}
|
||||
destinationPort={
|
||||
bgDestinationPort
|
||||
}
|
||||
onDestinationChange={
|
||||
setBgDestination
|
||||
}
|
||||
onDestinationPortChange={
|
||||
setBgDestinationPort
|
||||
}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
<Form {...bgTargetForm}>
|
||||
<BrowserGatewayTargetForm
|
||||
control={bgTargetForm.control}
|
||||
orgId={orgId as string}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
@@ -1193,26 +1284,18 @@ export default function Page() {
|
||||
>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm variant="half">
|
||||
<BrowserGatewayTargetForm
|
||||
orgId={orgId as string}
|
||||
multiSite={true}
|
||||
selectedSites={bgSelectedSites}
|
||||
onSitesChange={
|
||||
setBgSelectedSites
|
||||
}
|
||||
destination={bgDestination}
|
||||
destinationPort={
|
||||
bgDestinationPort
|
||||
}
|
||||
onDestinationChange={
|
||||
setBgDestination
|
||||
}
|
||||
onDestinationPortChange={
|
||||
setBgDestinationPort
|
||||
}
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
<Form {...bgTargetForm}>
|
||||
<BrowserGatewayTargetForm
|
||||
control={bgTargetForm.control}
|
||||
orgId={orgId as string}
|
||||
multiSite={true}
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</fieldset>
|
||||
@@ -1253,15 +1336,31 @@ export default function Page() {
|
||||
const tcpValid = !isHttpResource
|
||||
? await tcpUdpForm.trigger()
|
||||
: true;
|
||||
const sshPortValid = showDaemonPort
|
||||
? await sshDaemonPortForm.trigger()
|
||||
: true;
|
||||
|
||||
if (
|
||||
resourceType === "ssh" &&
|
||||
!isNative
|
||||
) {
|
||||
bgTargetForm.setValue(
|
||||
"authDaemonPort",
|
||||
sshDaemonPortForm.getValues()
|
||||
.authDaemonPort
|
||||
);
|
||||
}
|
||||
|
||||
const bgValid =
|
||||
resourceType === "rdp" ||
|
||||
resourceType === "vnc" ||
|
||||
(resourceType === "ssh" &&
|
||||
!isNative)
|
||||
? await bgTargetForm.trigger()
|
||||
: true;
|
||||
|
||||
if (
|
||||
baseValid &&
|
||||
domainValid &&
|
||||
tcpValid &&
|
||||
sshPortValid
|
||||
bgValid
|
||||
) {
|
||||
onSubmit();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
|
||||
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
|
||||
import type { ResourceRow } from "@app/components/PublicResourcesTable";
|
||||
import PublicResourcesTable from "@app/components/PublicResourcesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
|
||||
import PublicResourcesBanner from "@app/components/PublicResourcesBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
@@ -146,10 +146,10 @@ export default async function ProxyResourcesPage(
|
||||
description={t("proxyResourceDescription")}
|
||||
/>
|
||||
|
||||
<ProxyResourcesBanner />
|
||||
<PublicResourcesBanner />
|
||||
|
||||
<OrgProvider org={org}>
|
||||
<ProxyResourcesTable
|
||||
<PublicResourcesTable
|
||||
resources={resourceRows}
|
||||
orgId={params.orgId}
|
||||
rowCount={pagination.total}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--border: oklch(0.91 0.004 286.32);
|
||||
--input: oklch(0.88 0.004 286.32);
|
||||
--ring: oklch(0.705 0.213 47.604);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
|
||||
@@ -146,7 +146,7 @@ export const orgNavSections = (
|
||||
items: [
|
||||
{
|
||||
title: "sidebarResourcePolicies",
|
||||
href: "/{orgId}/settings/policies/resource",
|
||||
href: "/{orgId}/settings/policies/resources/public",
|
||||
icon: (
|
||||
<GlobeIcon className="size-4 flex-none" />
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
declare module "react" {
|
||||
@@ -443,6 +444,7 @@ export default function RdpClient({
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AuthPageFooterNotices />
|
||||
</BrandedAuthSurface>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { SignSshKeyResponse } from "@server/routers/ssh/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
|
||||
type AuthTab = "password" | "privateKey";
|
||||
|
||||
@@ -618,6 +619,7 @@ export default function SshClient({
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AuthPageFooterNotices />
|
||||
</BrandedAuthSurface>
|
||||
)}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type VncCredentialsForm = {
|
||||
@@ -242,6 +243,7 @@ export default function VncClient({
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AuthPageFooterNotices />
|
||||
</BrandedAuthSurface>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function AuthPageFooterNotices() {
|
||||
const t = useTranslations();
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
return (
|
||||
<>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { useWatch } from "react-hook-form";
|
||||
import {
|
||||
MultiSitesSelector,
|
||||
formatMultiSitesSelectorLabel
|
||||
} from "./multi-site-selector";
|
||||
import { SitesSelector, type Selectedsite } from "./site-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
|
||||
type SingleSiteProps = {
|
||||
multiSite?: false;
|
||||
selectedSite: Selectedsite | null;
|
||||
onSiteChange: (site: Selectedsite | null) => void;
|
||||
};
|
||||
|
||||
type MultiSiteProps = {
|
||||
multiSite: true;
|
||||
selectedSites: Selectedsite[];
|
||||
onSitesChange: (sites: Selectedsite[]) => void;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps = {
|
||||
type BaseProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
orgId: string;
|
||||
destination: string;
|
||||
defaultPort: number;
|
||||
destinationPort: string;
|
||||
onDestinationChange: (v: string) => void;
|
||||
onDestinationPortChange: (v: string) => void;
|
||||
destinationField: Path<T>;
|
||||
destinationPortField: Path<T>;
|
||||
learnMoreHref?: string;
|
||||
} & (SingleSiteProps | MultiSiteProps);
|
||||
defaultPort: number;
|
||||
};
|
||||
|
||||
export function BrowserGatewayTargetForm(props: BrowserGatewayTargetFormProps) {
|
||||
type MultiSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite: true;
|
||||
sitesField: Path<T>;
|
||||
};
|
||||
|
||||
type SingleSiteFormProps<T extends FieldValues> = BaseProps<T> & {
|
||||
multiSite?: false;
|
||||
siteField: Path<T>;
|
||||
};
|
||||
|
||||
export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
|
||||
| MultiSiteFormProps<T>
|
||||
| SingleSiteFormProps<T>;
|
||||
|
||||
export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
props: BrowserGatewayTargetFormProps<T>
|
||||
) {
|
||||
const t = useTranslations();
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
|
||||
const siteSelector =
|
||||
props.multiSite === true ? (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{formatMultiSitesSelectorLabel(
|
||||
props.selectedSites,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={props.selectedSites}
|
||||
onSelectionChange={props.onSitesChange}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.selectedSite?.name ?? t("siteSelect")}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={props.selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
props.onSiteChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
const sitesFieldName =
|
||||
props.multiSite === true ? props.sitesField : props.siteField;
|
||||
|
||||
const watchedSites = useWatch({
|
||||
control: props.control,
|
||||
name: sitesFieldName
|
||||
});
|
||||
|
||||
const showMultiSiteDisclaimer =
|
||||
props.multiSite === true &&
|
||||
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("sites")}
|
||||
</label>
|
||||
{siteSelector}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">
|
||||
{t("destination")}
|
||||
</label>
|
||||
<Input
|
||||
value={props.destination}
|
||||
onChange={(e) =>
|
||||
props.onDestinationChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold">{t("port")}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={props.destinationPort}
|
||||
onChange={(e) =>
|
||||
props.onDestinationPortChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-start">
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={sitesFieldName}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("sites")}</FormLabel>
|
||||
<Popover open={siteOpen} onOpenChange={setSiteOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
||||
props.multiSite === true
|
||||
? (
|
||||
field.value as Selectedsite[]
|
||||
)?.length === 0 &&
|
||||
"text-muted-foreground"
|
||||
: !field.value &&
|
||||
"text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{props.multiSite === true
|
||||
? formatMultiSitesSelectorLabel(
|
||||
(field.value as Selectedsite[]) ??
|
||||
[],
|
||||
t
|
||||
)
|
||||
: ((
|
||||
field.value as Selectedsite | null
|
||||
)?.name ??
|
||||
t("siteSelect"))}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
||||
{props.multiSite === true ? (
|
||||
<MultiSitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSites={
|
||||
(field.value as Selectedsite[]) ??
|
||||
[]
|
||||
}
|
||||
onSelectionChange={field.onChange}
|
||||
/>
|
||||
) : (
|
||||
<SitesSelector
|
||||
orgId={props.orgId}
|
||||
selectedSite={
|
||||
field.value as Selectedsite | null
|
||||
}
|
||||
onSelectSite={(site) => {
|
||||
field.onChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("destination")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={props.control}
|
||||
name={props.destinationPortField}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("port")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{props.multiSite === true && props.selectedSites.length > 1 && (
|
||||
{showMultiSiteDisclaimer && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("bgTargetMultiSiteDisclaimer")}{" "}
|
||||
<a
|
||||
|
||||
@@ -408,12 +408,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
||||
? t("standaloneHcEditTitle")
|
||||
: t("standaloneHcCreateTitle");
|
||||
|
||||
const description =
|
||||
mode === "autoSave"
|
||||
? t("configureHealthCheckDescription", {
|
||||
target: (props as any).targetAddress
|
||||
})
|
||||
: t("standaloneHcDescription");
|
||||
const description = t("configureHealthCheckDescription");
|
||||
|
||||
const disableTabInputs = mode === "autoSave" && !watchedEnabled;
|
||||
const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp";
|
||||
|
||||
@@ -1813,9 +1813,9 @@ export function PrivateResourceForm({
|
||||
|
||||
{/* Mode */}
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshServerMode")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<StrategySelect<"standard" | "native">
|
||||
value={sshServerMode}
|
||||
options={[
|
||||
@@ -1870,9 +1870,9 @@ export function PrivateResourceForm({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthenticationMethod")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pamMode"
|
||||
@@ -1965,9 +1965,9 @@ export function PrivateResourceForm({
|
||||
{/* Daemon Location (standard + push) */}
|
||||
{showDaemonLocation && (
|
||||
<div className="space-y-2">
|
||||
<SettingsSubsectionTitle>
|
||||
<p className="font-semibold text-sm">
|
||||
{t("sshAuthDaemonLocation")}
|
||||
</SettingsSubsectionTitle>
|
||||
</p>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="authDaemonMode"
|
||||
|
||||
@@ -426,7 +426,7 @@ export default function PrivateResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||
if (resourceRow.alias) {
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={resourceRow.alias}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ProxyResourcesBanner = () => {
|
||||
export const PublicResourcesBanner = () => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="proxy-resources-banner-dismissed"
|
||||
version={1}
|
||||
title={t("proxyResourcesBannerTitle")}
|
||||
title={t("publicResourcesBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("proxyResourcesBannerDescription")}
|
||||
description={t("publicResourcesBannerDescription")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyResourcesBanner;
|
||||
export default PublicResourcesBanner;
|
||||
@@ -126,7 +126,7 @@ const booleanSearchFilterSchema = z
|
||||
.optional()
|
||||
.catch(undefined);
|
||||
|
||||
export default function ProxyResourcesTable({
|
||||
export default function PublicResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
pagination,
|
||||
@@ -44,7 +44,7 @@ import { toast } from "@app/hooks/useToast";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
|
||||
import PoweredByPangolin from "@app/components/PoweredByPangolin";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
@@ -124,8 +124,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
@@ -727,29 +725,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<AuthPageFooterNotices />
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span className="inline-flex items-center">
|
||||
{resource.mode!.toUpperCase()}
|
||||
{resource.ssl ? "HTTPS" : "HTTP"}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
@@ -8,12 +8,7 @@ import type {
|
||||
ListResourcePoliciesResponse
|
||||
} from "@server/routers/resource/types";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Waypoints
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, ChevronDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -98,55 +93,50 @@ export function ResourcePoliciesTable({
|
||||
};
|
||||
|
||||
function ResourceListCell({
|
||||
orgId,
|
||||
resources
|
||||
}: {
|
||||
orgId: string;
|
||||
resources?: AttachedResource[];
|
||||
}) {
|
||||
if (!resources || resources.length === 0) {
|
||||
return (
|
||||
<div
|
||||
id="LOOK_FOR_ME"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResourcesEmpty")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
const countLabel = t("resourcePoliciesAttachedResourcesCount", {
|
||||
count: resources.length
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8 px-0 font-normal"
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<Waypoints className="size-4 flex-none" />
|
||||
<span className="text-sm">
|
||||
{t("resourcePoliciesAttachedResources", {
|
||||
count: resources.length
|
||||
})}
|
||||
<span className="text-sm tabular-nums">
|
||||
{countLabel}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-70">
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
{resources.map((resource) => (
|
||||
<DropdownMenuItem
|
||||
key={resource.resourceId}
|
||||
className="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.name}
|
||||
</div>
|
||||
<span
|
||||
className={`capitalize text-muted-foreground`}
|
||||
<DropdownMenuItem key={resource.resourceId} asChild>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/public/${resource.niceId}`}
|
||||
className="flex cursor-pointer items-center justify-between gap-4"
|
||||
>
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{resource.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{resource.fullDomain}
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -182,7 +172,12 @@ export function ResourcePoliciesTable({
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <ResourceListCell resources={row.original.resources} />;
|
||||
return (
|
||||
<ResourceListCell
|
||||
orgId={row.original.orgId}
|
||||
resources={row.original.resources}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -205,7 +200,7 @@ export function ResourcePoliciesTable({
|
||||
<DropdownMenuContent align="end">
|
||||
<Link
|
||||
className="block w-full"
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
{t("viewSettings")}
|
||||
@@ -224,7 +219,7 @@ export function ResourcePoliciesTable({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${policyRow.orgId}/settings/policies/resource/${policyRow.niceId}`}
|
||||
href={`/${policyRow.orgId}/settings/policies/resources/public/${policyRow.niceId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
@@ -293,7 +288,7 @@ export function ResourcePoliciesTable({
|
||||
onAdd={() =>
|
||||
startNavigation(() =>
|
||||
router.push(
|
||||
`/${orgId}/settings/policies/resource/create`
|
||||
`/${orgId}/settings/policies/resources/public/create`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function SettingsSubsectionHeader({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-0.5", className)}>{children}</div>;
|
||||
return <div className={cn("py-3 space-y-0.5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionTitle({
|
||||
@@ -80,9 +80,7 @@ export function SettingsSubsectionTitle({
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3 className={cn("text-sm font-semibold", className)}>{children}</h3>
|
||||
);
|
||||
return <h3 className={cn("font-semibold", className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function SettingsSubsectionDescription({
|
||||
|
||||
@@ -514,6 +514,16 @@ export default function SitesTable({
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
|
||||
@@ -157,7 +157,7 @@ export function LabelsSelector({
|
||||
/>
|
||||
|
||||
<Select defaultValue={randomColor} name="color">
|
||||
<SelectTrigger className="w-18 [&_[data-name]]:hidden [&_[svg]]:hidden!">
|
||||
<SelectTrigger className="w-auto min-w-24">
|
||||
<SelectValue
|
||||
placeholder={t("selectColor")}
|
||||
/>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function CreatePolicyForm({}: CreatePolicyFormProps) {
|
||||
if (res && res.status === 201) {
|
||||
const niceId = res.data.data.niceId;
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/policies/resource/${niceId}`
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${niceId}`
|
||||
);
|
||||
toast({
|
||||
title: t("success"),
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -40,21 +41,28 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
// ─── PolicyNameSection ──────────────────────────────────────────────────
|
||||
|
||||
export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean }) {
|
||||
const PolicyNameFormSchema = z.object({
|
||||
name: z.string(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
});
|
||||
|
||||
export function EditPolicyNameSectionForm({
|
||||
readonly
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const { policy } = useResourcePolicyContext();
|
||||
const { policy, updatePolicy } = useResourcePolicyContext();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string()
|
||||
})
|
||||
),
|
||||
resolver: zodResolver(PolicyNameFormSchema),
|
||||
defaultValues: {
|
||||
name: policy.name
|
||||
name: policy.name,
|
||||
niceId: policy.niceId || ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,7 +81,8 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
.put<AxiosResponse<ResourcePolicy>>(
|
||||
`/resource-policy/${policy.resourcePolicyId}`,
|
||||
{
|
||||
name: payload.name
|
||||
name: payload.name,
|
||||
niceId: payload.niceId
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
@@ -88,10 +97,22 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
updatePolicy({
|
||||
name: payload.name,
|
||||
niceId: payload.niceId
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("policyUpdatedSuccess")
|
||||
});
|
||||
|
||||
if (payload.niceId && payload.niceId !== policy.niceId) {
|
||||
router.replace(
|
||||
`/${org.org.orgId}/settings/policies/resources/public/${payload.niceId}`
|
||||
);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -136,6 +157,26 @@ export function EditPolicyNameSectionForm({ readonly }: { readonly?: boolean })
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="niceId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("identifier")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={readonly}
|
||||
placeholder={t(
|
||||
"enterIdentifier"
|
||||
)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { DockerState } from "@app/lib/docker";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { CaretSortIcon } from "@radix-ui/react-icons";
|
||||
import type { ListSitesResponse } from "@server/routers/site";
|
||||
import { type ListTargetsResponse } from "@server/routers/target";
|
||||
import type { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { ContainersSelector } from "./ContainersSelector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -28,23 +25,21 @@ export type LocalTarget = Omit<
|
||||
"protocol"
|
||||
>;
|
||||
|
||||
export type ResourceTargetAddressItemProps = {
|
||||
export type ResourceTargetSiteItemProps = {
|
||||
getDockerStateForSite: (siteId: number) => DockerState;
|
||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||
orgId: string;
|
||||
proxyTarget: LocalTarget;
|
||||
isHttp: boolean;
|
||||
refreshContainersForSite: (siteId: number) => void;
|
||||
};
|
||||
|
||||
export function ResourceTargetAddressItem({
|
||||
export function ResourceTargetSiteItem({
|
||||
orgId,
|
||||
getDockerStateForSite,
|
||||
updateTarget,
|
||||
proxyTarget,
|
||||
isHttp,
|
||||
refreshContainersForSite
|
||||
}: ResourceTargetAddressItemProps) {
|
||||
}: ResourceTargetSiteItemProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const [selectedSite, setSelectedSite] = useState<Pick<
|
||||
@@ -76,62 +71,78 @@ export function ResourceTargetAddressItem({
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full min-w-0 items-center h-9 border border-input rounded-md"
|
||||
key={proxyTarget.targetId}
|
||||
>
|
||||
{selectedSite && selectedSite.type === "newt" && (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={
|
||||
getDockerStateForSite(selectedSite.siteId).containers
|
||||
}
|
||||
isAvailable={
|
||||
getDockerStateForSite(selectedSite.siteId).isAvailable
|
||||
}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(selectedSite.siteId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"h-9 min-w-0 flex-1 justify-between px-3 rounded-none hover:bg-transparent",
|
||||
!proxyTarget.siteId && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{proxyTarget.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
updateTarget(proxyTarget.targetId, {
|
||||
siteId: site.siteId,
|
||||
siteType: site.type,
|
||||
siteName: site.name
|
||||
});
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ResourceTargetAddressItemProps = {
|
||||
updateTarget: (targetId: number, data: Partial<LocalTarget>) => void;
|
||||
proxyTarget: LocalTarget;
|
||||
isHttp: boolean;
|
||||
};
|
||||
|
||||
export function ResourceTargetAddressItem({
|
||||
updateTarget,
|
||||
proxyTarget,
|
||||
isHttp
|
||||
}: ResourceTargetAddressItemProps) {
|
||||
return (
|
||||
<div className="flex items-center w-full" key={proxyTarget.targetId}>
|
||||
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
|
||||
{selectedSite && selectedSite.type === "newt" && (
|
||||
<ContainersSelector
|
||||
site={selectedSite}
|
||||
containers={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.containers
|
||||
}
|
||||
isAvailable={
|
||||
getDockerStateForSite(selectedSite.siteId)
|
||||
.isAvailable
|
||||
}
|
||||
onContainerSelect={handleContainerSelectForTarget}
|
||||
onRefresh={() =>
|
||||
refreshContainersForSite(selectedSite.siteId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-45 justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
|
||||
"",
|
||||
!proxyTarget.siteId && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate max-w-37.5">
|
||||
{proxyTarget.siteId
|
||||
? selectedSite?.name
|
||||
: t("siteSelect")}
|
||||
</span>
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<SitesSelector
|
||||
orgId={orgId}
|
||||
selectedSite={selectedSite}
|
||||
onSelectSite={(site) => {
|
||||
updateTarget(proxyTarget.targetId, {
|
||||
siteId: site.siteId,
|
||||
siteType: site.type,
|
||||
siteName: site.name
|
||||
});
|
||||
setSelectedSite(site);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isHttp && (
|
||||
<Select
|
||||
defaultValue={proxyTarget.method ?? "http"}
|
||||
@@ -142,7 +153,7 @@ export function ResourceTargetAddressItem({
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 px-2 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none">
|
||||
<SelectTrigger className="h-9 w-17.5 border-none bg-transparent shadow-none data-[state=open]:bg-transparent rounded-none mr-0 pr-0">
|
||||
{proxyTarget.method || "http"}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -154,7 +165,7 @@ export function ResourceTargetAddressItem({
|
||||
)}
|
||||
|
||||
{isHttp && (
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
<div className="flex items-center justify-center h-9 mr-0 pl-1">
|
||||
{"://"}
|
||||
</div>
|
||||
)}
|
||||
@@ -162,7 +173,7 @@ export function ResourceTargetAddressItem({
|
||||
<Input
|
||||
defaultValue={proxyTarget.ip}
|
||||
placeholder="Host"
|
||||
className="flex-1 min-w-30 px-2 border-none placeholder-gray-400 rounded-xs"
|
||||
className="flex-1 min-w-30 border-none placeholder-gray-400 rounded-xs"
|
||||
onBlur={(e) => {
|
||||
const input = e.target.value.trim();
|
||||
const hasProtocol = /^(https?|h2c):\/\//.test(input);
|
||||
@@ -195,7 +206,7 @@ export function ResourceTargetAddressItem({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-2 h-9">
|
||||
<div className="flex items-center justify-center h-9 mr-0">
|
||||
{":"}
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from "zod";
|
||||
|
||||
type TranslateFn = (key: string) => string;
|
||||
|
||||
export const selectedSiteSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
});
|
||||
|
||||
export type SelectedSiteFormValue = z.infer<typeof selectedSiteSchema>;
|
||||
|
||||
export function createPortStringSchema(t: TranslateFn) {
|
||||
return z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return false;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: t("healthCheckPortInvalid") }
|
||||
);
|
||||
}
|
||||
|
||||
function createOptionalAuthDaemonPortSchema(t: TranslateFn) {
|
||||
return z.string().refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
const n = Number(val);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
},
|
||||
{ message: t("healthCheckPortInvalid") }
|
||||
);
|
||||
}
|
||||
|
||||
export function createBrowserGatewayTargetFormSchema(t: TranslateFn) {
|
||||
return z.object({
|
||||
selectedSites: z.array(selectedSiteSchema).min(1, {
|
||||
message: t("siteRequired")
|
||||
}),
|
||||
destination: z.string().min(1, {
|
||||
message: t("destinationRequired")
|
||||
}),
|
||||
destinationPort: createPortStringSchema(t)
|
||||
});
|
||||
}
|
||||
|
||||
export type BrowserGatewayTargetFormValues = z.infer<
|
||||
ReturnType<typeof createBrowserGatewayTargetFormSchema>
|
||||
>;
|
||||
|
||||
export function createSshSettingsFormSchema(
|
||||
t: TranslateFn,
|
||||
options: { isNative: boolean }
|
||||
) {
|
||||
const { isNative } = options;
|
||||
const portSchema = createPortStringSchema(t);
|
||||
const optionalAuthDaemonPortSchema = createOptionalAuthDaemonPortSchema(t);
|
||||
|
||||
return z
|
||||
.object({
|
||||
pamMode: z.enum(["passthrough", "push"]),
|
||||
standardDaemonLocation: z.enum(["site", "remote"]),
|
||||
authDaemonPort: z.string(),
|
||||
selectedSites: z.array(selectedSiteSchema),
|
||||
selectedSite: selectedSiteSchema.nullable(),
|
||||
selectedNativeSite: selectedSiteSchema.nullable(),
|
||||
destination: z.string(),
|
||||
destinationPort: z.string()
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (isNative) {
|
||||
if (!data.selectedNativeSite) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["selectedNativeSite"],
|
||||
message: t("siteRequired")
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const useMultiSite =
|
||||
data.standardDaemonLocation !== "site" ||
|
||||
data.pamMode === "passthrough";
|
||||
|
||||
if (useMultiSite) {
|
||||
if (data.selectedSites.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["selectedSites"],
|
||||
message: t("siteRequired")
|
||||
});
|
||||
}
|
||||
} else if (!data.selectedSite) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["selectedSite"],
|
||||
message: t("siteRequired")
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.destination.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["destination"],
|
||||
message: t("destinationRequired")
|
||||
});
|
||||
}
|
||||
|
||||
const portResult = portSchema.safeParse(data.destinationPort);
|
||||
if (!portResult.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["destinationPort"],
|
||||
message: t("healthCheckPortInvalid")
|
||||
});
|
||||
}
|
||||
|
||||
const showDaemonPort =
|
||||
data.pamMode === "push" &&
|
||||
data.standardDaemonLocation === "remote";
|
||||
|
||||
if (showDaemonPort) {
|
||||
const authPortResult = optionalAuthDaemonPortSchema.safeParse(
|
||||
data.authDaemonPort
|
||||
);
|
||||
if (!data.authDaemonPort.trim() || !authPortResult.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["authDaemonPort"],
|
||||
message: t("healthCheckPortInvalid")
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type SshSettingsFormValues = z.infer<
|
||||
ReturnType<typeof createSshSettingsFormSchema>
|
||||
>;
|
||||
Reference in New Issue
Block a user