From a4327f107156982a4fe4c750ac871e9c172d87dd Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:12:51 +0930 Subject: [PATCH 01/77] Update handleOlmServerInitAddPeerHandshake.ts --- server/routers/olm/handleOlmServerInitAddPeerHandshake.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts index b3aaffb93..3b7ce15fa 100644 --- a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -97,6 +97,7 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( logger.error( `handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` ); + await sendCancel(); return; } From dc62d2e908e65fb7fbb90196234e513a99835e74 Mon Sep 17 00:00:00 2001 From: Shlee Date: Sun, 16 Aug 2026 22:14:55 +0930 Subject: [PATCH 02/77] Update page.tsx --- src/app/auth/signup/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 42829acfd..ce0757f2e 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -44,7 +44,7 @@ export default async function Page(props: { let inviteToken; if (searchParams.redirect && isInvite) { const parts = searchParams.redirect.split("token="); - if (parts.length) { + if (parts.length > 1) { const token = parts[1]; const tokenParts = token.split("-"); if (tokenParts.length === 2) { From 0b3329939bc849e5e55b0fd4c9c79dbd47d4d8a7 Mon Sep 17 00:00:00 2001 From: Dhananjay Maurya Date: Mon, 21 Sep 2026 23:32:01 +0530 Subject: [PATCH 03/77] fix(ai-gateway): return JSON auth error instead of 302 for API clients on inference resources --- server/routers/badger/verifySession.ts | 76 ++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index b8ecf6b9e..4b6818d0e 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -162,17 +162,16 @@ export async function verifyResourceSession( originalRequestURL, requestIp, path, + method, headers, query, - method, badgerVersion } = parsedBody.data; // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); - const clientUserAgent = - headers?.["user-agent"] || headers?.["User-Agent"]; + const clientUserAgent = getClientHeader(headers, "user-agent"); const clientIsBrowser = isBrowserUserAgent(clientUserAgent); const clientIp = requestIp @@ -456,7 +455,15 @@ export async function verifyResourceSession( // Browsers go to the resource auth / API key page. API clients get // a capability-shaped JSON auth error instead of a redirect. - if (clientIsBrowser) { + // Never redirect programmatic API calls (non-GET, or a known AI + // capability path): HTTP clients such as the OpenAI Python SDK + // (httpx) don't follow 302s on POST, so a redirect surfaces as + // an opaque failure with nothing logged in aiSessionLog since + // the request never reaches the gateway. + if ( + clientIsBrowser && + !isProgrammaticApiRequest(path, method, headers) + ) { return notAllowed(res, redirectPath, resource.orgId); } @@ -1677,14 +1684,29 @@ const NON_BROWSER_USER_AGENT_PATTERNS = [ /wget/, /python-requests/, /python-urllib/, + /python-httpx/, + /httpx/, + /httpcore/, + /aiohttp/, + /urllib3/, + /openai\//, + /anthropic/, /go-http-client/, /okhttp/, /axios/, /node-fetch/, + /undici/, /postmanruntime/, /insomnia/, /libwww-perl/, /java\//, + /\bjava\b/, + /jakarta/, + /jersey/, + /netty/, + /jetty/, + /eclipse/, + /dbeaver/, /ruby/, /php/, /bot/, @@ -1709,6 +1731,52 @@ function isBrowserUserAgent(userAgent: string | undefined): boolean { return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua)); } +function getClientHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) { + return value; + } + } + return undefined; +} + +// True for programmatic API calls that can't complete an interactive login, +// even if the User-Agent looks like a browser (some SDKs reuse browser-ish +// strings or omit a distinctive token). Badger turns a redirectUrl into a +// 302, which HTTP clients don't follow on POST, so these must get a JSON +// auth error instead. +function isProgrammaticApiRequest( + path: string | undefined, + method: string | undefined, + headers: Record | undefined +): boolean { + if (method && method.toUpperCase() !== "GET") { + return true; + } + if (path && resolveAiCapabilityFromPath(path) !== null) { + return true; + } + const accept = getClientHeader(headers, "accept"); + if (accept && !accept.toLowerCase().includes("text/html")) { + return true; + } + const secFetchMode = getClientHeader(headers, "sec-fetch-mode"); + if ( + secFetchMode && + !["navigate", "document"].includes(secFetchMode.toLowerCase()) + ) { + return true; + } + return false; +} + function extractBasicAuth( headers: Record | undefined ): string | undefined { From 6ec37220d6db884f0a4ef872d9941b90c3b89dd5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 10:33:49 -0400 Subject: [PATCH 04/77] Remove default from server Fixes #3804 --- server/lib/readConfigFile.ts | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 9f433054d..734d960f9 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -175,35 +175,7 @@ export const configSchema = z maxmind_asn_path: z.string().optional() }) .optional() - .default({ - integration_port: 3003, - external_port: 3000, - internal_port: 3001, - ai_gateway_port: 3005, - next_port: 3002, - internal_hostname: "pangolin", - session_cookie_name: "p_session_token", - resource_access_token_param: "p_token", - resource_access_token_headers: { - id: "P-Access-Token-Id", - token: "P-Access-Token" - }, - remote_headers: { - user_id: "Remote-User-Id", - virtual_api_key_id: "Remote-Virtual-Api-Key-Id", - user: "Remote-User", - email: "Remote-Email", - name: "Remote-Name", - role: "Remote-Role" - }, - resource_session_request_param: - "resource_session_request_param", - dashboard_session_length_hours: 720, - resource_session_length_hours: 720, - trust_proxy: 1, - enable_ai_gateway_client_ip_header: false, - secret: undefined - }), + .prefault({}), postgres: z .object({ connection_string: z.string().optional(), From 537649af186f1b9820c7627eddf84063d06f1803 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 10:39:37 -0400 Subject: [PATCH 05/77] Update install advantech link --- messages/bg-BG.json | 2 +- messages/cs-CZ.json | 2 +- messages/da-DK.json | 2 +- messages/de-DE.json | 2 +- messages/en-US.json | 2 +- messages/es-ES.json | 2 +- messages/fr-FR.json | 2 +- messages/it-IT.json | 2 +- messages/ko-KR.json | 2 +- messages/nb-NO.json | 2 +- messages/nl-NL.json | 2 +- messages/pl-PL.json | 2 +- messages/pt-PT.json | 2 +- messages/ru-RU.json | 2 +- messages/tr-TR.json | 2 +- messages/zh-CN.json | 2 +- messages/zh-TW.json | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 5901fe797..63358e2e3 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Инсталирайте Newt", "siteInstallNewtDescription": "Пуснете Newt на вашата система", "siteInstallKubernetesDocsDescription": "За повече и актуална информация относно инсталацията на Kubernetes, вижте docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "За инструкции за инсталиране на Advantech модем, вижте docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "За инструкции за инсталиране на Advantech модем, вижте docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard конфигурация", "WgConfigurationDescription": "Използвайте следната конфигурация, за да се свържете с мрежата", "operatingSystem": "Операционна система", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index ab5e4bf2e..22f34ceb9 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Nainstalovat Newt", "siteInstallNewtDescription": "Spustit Newt na vašem systému", "siteInstallKubernetesDocsDescription": "Pro více aktuálních informací o instalaci Kubernetes navštivte docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Pro pokyny k instalaci modemu Advantech navštivte docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Pro pokyny k instalaci modemu Advantech navštivte docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Konfigurace WireGuard", "WgConfigurationDescription": "K připojení k síti použijte následující konfiguraci", "operatingSystem": "Operační systém", diff --git a/messages/da-DK.json b/messages/da-DK.json index 90acf2eb0..866853040 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til at køre på dit system", "siteInstallKubernetesDocsDescription": "For mere og opdateret information om Kubernetes-installation, se docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "For installationsvejledning til Advantech-modemmer, se docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "For installationsvejledning til Advantech-modemmer, se docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard Konfiguration", "WgConfigurationDescription": "Brug følgende konfiguration til at oprette forbindelse til netværket.", "operatingSystem": "Operativsystem", diff --git a/messages/de-DE.json b/messages/de-DE.json index 8c393d288..cf7c2ae6b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", "siteInstallKubernetesDocsDescription": "Für aktuelle Installationsinformationen zu Kubernetes, siehe docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Für Installationsanweisungen für Advantech-Modems siehe docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Für Installationsanweisungen für Advantech-Modems siehe docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard Konfiguration", "WgConfigurationDescription": "Verwenden Sie folgende Konfiguration, um sich mit dem Netzwerk zu verbinden", "operatingSystem": "Betriebssystem", diff --git a/messages/en-US.json b/messages/en-US.json index 38df95476..bfb07ef74 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", "siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", diff --git a/messages/es-ES.json b/messages/es-ES.json index 911b6c888..0407ad5bd 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Instalar Newt", "siteInstallNewtDescription": "Recibe Newt corriendo en tu sistema", "siteInstallKubernetesDocsDescription": "Para información de instalación de Kubernetes más reciente, consulta docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Para instrucciones de instalación del módem Advantech, consulta docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Para instrucciones de instalación del módem Advantech, consulta docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Configuración de Wirex Guard", "WgConfigurationDescription": "Utilice la siguiente configuración para conectarse a la red", "operatingSystem": "Sistema operativo", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 9204a0981..20ce80a58 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Faites fonctionner Newt sur votre système", "siteInstallKubernetesDocsDescription": "Pour plus d'informations à jour sur l'installation de Kubernetes, consultez docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Pour les instructions d'installation du modem Advantech, voir docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Pour les instructions d'installation du modem Advantech, voir docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Configuration WireGuard", "WgConfigurationDescription": "Utilisez la configuration suivante pour vous connecter au réseau", "operatingSystem": "Système d'exploitation", diff --git a/messages/it-IT.json b/messages/it-IT.json index 5bb856c68..990552f71 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Installa Newt", "siteInstallNewtDescription": "Esegui Newt sul tuo sistema", "siteInstallKubernetesDocsDescription": "Per ulteriori informazioni aggiornate sull'installazione di Kubernetes, consulta docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Per le istruzioni sull'installazione del modem Advantech, consulta docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Per le istruzioni sull'installazione del modem Advantech, consulta docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Configurazione WireGuard", "WgConfigurationDescription": "Utilizzare la seguente configurazione per connettersi alla rete", "operatingSystem": "Sistema Operativo", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index ce2208228..4981759ad 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Newt 설치", "siteInstallNewtDescription": "시스템에서 Newt 실행하기", "siteInstallKubernetesDocsDescription": "더 많은 정보와 최신의 쿠버네티스 설치 정보를 보려면 docs.pangolin.net/manage/sites/install-kubernetes를 참조하세요.", - "siteInstallAdvantechDocsDescription": "Advantech 모뎀 설치 지침은 docs.pangolin.net/manage/sites/install-advantech을 참조하세요.", + "siteInstallAdvantechDocsDescription": "Advantech 모뎀 설치 지침은 docs.pangolin.net/manage/sites/install-newt#advantech-router-app을 참조하세요.", "WgConfiguration": "WireGuard 구성", "WgConfigurationDescription": "네트워크에 연결하기 위한 다음 구성을 사용하세요.", "operatingSystem": "운영 체제", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index dd06df0db..d3ed06b97 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til å kjøre på systemet ditt", "siteInstallKubernetesDocsDescription": "For mer og oppdatert informasjon om Kubernetes-installasjon, se docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "For installasjonsinstruksjoner for Advantech-modem, se docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "For installasjonsinstruksjoner for Advantech-modem, se docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard Konfigurasjon", "WgConfigurationDescription": "Bruk følgende konfigurasjon for å koble til nettverket", "operatingSystem": "Operativsystem", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 0320ba870..2e4b51096 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Installeer Newt", "siteInstallNewtDescription": "Laat Newt draaien op uw systeem", "siteInstallKubernetesDocsDescription": "Voor meer informatie over de installatie van Kubernetes, zie docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Voor instructies voor de installatie van Advantech modems, zie docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Voor instructies voor de installatie van Advantech modems, zie docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "WireGuard Configuratie", "WgConfigurationDescription": "Gebruik de volgende configuratie om verbinding te maken met het netwerk", "operatingSystem": "Operating systeem", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index a5b816e1f..4ada01cc9 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Zainstaluj Newt", "siteInstallNewtDescription": "Uruchom Newt w swoim systemie", "siteInstallKubernetesDocsDescription": "Aby uzyskać więcej aktualnych informacji o instalacji Kubernetes, zobacz docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Aby uzyskać instrukcje dotyczące instalacji modemu Advantech, zobacz: docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Aby uzyskać instrukcje dotyczące instalacji modemu Advantech, zobacz: docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Konfiguracja WireGuard", "WgConfigurationDescription": "Użyj następującej konfiguracji, aby połączyć się z siecią", "operatingSystem": "System operacyjny", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index fcdbc0b72..701c9a33e 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Instalar Novo", "siteInstallNewtDescription": "Novo item em execução no seu sistema", "siteInstallKubernetesDocsDescription": "Para mais informações atualizadas sobre a instalação do Kubernetes, veja docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Para instruções de instalação do modem da Advantech, veja docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Para instruções de instalação do modem da Advantech, veja docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Configuração do WireGuard", "WgConfigurationDescription": "Use a seguinte configuração para conectar-se à rede", "operatingSystem": "Sistema operacional", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 6f625c89c..423778ccf 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Установить Newt", "siteInstallNewtDescription": "Запустите Newt в вашей системе", "siteInstallKubernetesDocsDescription": "Для получения дополнительной информации об установке Kubernetes, см. docs.pangolin.net/manage/sites/install-kubernetes.", - "siteInstallAdvantechDocsDescription": "Для инструкций по установке модема Advantech, см. docs.pangolin.net/manage/sites/install-advantech.", + "siteInstallAdvantechDocsDescription": "Для инструкций по установке модема Advantech, см. docs.pangolin.net/manage/sites/install-newt#advantech-router-app.", "WgConfiguration": "Конфигурация WireGuard", "WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети", "operatingSystem": "Операционная система", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index efa8a9eb7..8f18bbf6c 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -110,7 +110,7 @@ "siteInstallNewt": "Newt Yükle", "siteInstallNewtDescription": "Newt'i sisteminizde çalıştırma", "siteInstallKubernetesDocsDescription": "Daha fazla ve güncel Kubernetes kurulum bilgileri için docs.pangolin.net/manage/sites/install-kubernetes adresini inceleyin.", - "siteInstallAdvantechDocsDescription": "Advantech modem kurulum talimatları için docs.pangolin.net/manage/sites/install-advantech adresini inceleyin.", + "siteInstallAdvantechDocsDescription": "Advantech modem kurulum talimatları için docs.pangolin.net/manage/sites/install-newt#advantech-router-app adresini inceleyin.", "WgConfiguration": "WireGuard Yapılandırması", "WgConfigurationDescription": "Ağınıza bağlanmak için aşağıdaki yapılandırmayı kullanın", "operatingSystem": "İşletim Sistemi", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index dc4467d6f..2c7161ca9 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -110,7 +110,7 @@ "siteInstallNewt": "安装 Newt", "siteInstallNewtDescription": "在您的系统中运行 Newt", "siteInstallKubernetesDocsDescription": "有关最新的 Kubernetes 安装信息,请参阅docs.pangolin.net/manage/sites/install-kubernetes。", - "siteInstallAdvantechDocsDescription": "有关 Advantech 调制解调器安装说明,请参阅docs.pangolin.net/manage/sites/install-advantech。", + "siteInstallAdvantechDocsDescription": "有关 Advantech 调制解调器安装说明,请参阅docs.pangolin.net/manage/sites/install-newt#advantech-router-app。", "WgConfiguration": "WireGuard 配置", "WgConfigurationDescription": "使用以下配置连接到网络", "operatingSystem": "操作系统", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 8f25e307d..730e4dc66 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -2426,7 +2426,7 @@ "sitesTableViewPublicResources": "檢視公共資源", "sitesTableViewPrivateResources": "檢視私有資源", "siteInstallKubernetesDocsDescription": "有關最新的 Kubernetes 安裝資訊,請參閱docs.pangolin.net/manage/sites/install-kubernetes。", - "siteInstallAdvantechDocsDescription": "有關 Advantech 數據機安裝說明,請參閱docs.pangolin.net/manage/sites/install-advantech。", + "siteInstallAdvantechDocsDescription": "有關 Advantech 數據機安裝說明,請參閱docs.pangolin.net/manage/sites/install-newt#advantech-router-app。", "siteRestartTitle": "重新啟動站台", "siteRestartDescription": "重新啟動此站台的WireGuard隧道。此操作將暫時中斷連線。", "siteRestartBody": "如果站台隧道無法正常工作,並且您希望在不重新啟動主機的情況下強制重新連線,請使用此選項。", From d406cc61b5c96090f0bc98a2258a520033864992 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 10:00:47 -0400 Subject: [PATCH 06/77] Pull jit limit from org --- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/lib/rebuildClientAssociations.ts | 64 ++++++++++++++++--- server/routers/gerbil/updateHolePunch.ts | 23 ++++++- server/routers/olm/getOlmToken.ts | 21 +++++- .../routers/olm/handleOlmRegisterMessage.ts | 2 +- server/routers/olm/sync.ts | 16 ++++- 7 files changed, 113 insertions(+), 19 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 9ae0e76fa..145b35756 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -78,7 +78,8 @@ export const orgs = pgTable("orgs", { "settingsEnableGlobalNewtAutoUpdate" ) .notNull() - .default(false) + .default(false), + settingsJitModeLimit: integer("settingsJitModeLimit").notNull().default(250) }); export const orgDomains = pgTable("orgDomains", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f14e9d8b1..a72e15556 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -78,7 +78,8 @@ export const orgs = sqliteTable("orgs", { { mode: "boolean" } ) .notNull() - .default(false) + .default(false), + settingsJitModeLimit: integer("settingsJitModeLimit").notNull().default(250) }); export const userDomains = sqliteTable("userDomains", { diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 045ca3927..012c391bf 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -17,7 +17,8 @@ import { sites, Transaction, userOrgRoles, - userSiteResources + userSiteResources, + orgs } from "@server/db"; import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm"; @@ -234,7 +235,8 @@ export async function getClientSiteResourceAccess( .select({ clientId: clients.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clients) .where( @@ -260,7 +262,8 @@ export async function getClientSiteResourceAccess( .select({ clientId: clients.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clients) .where( @@ -463,7 +466,8 @@ async function rebuildClientAssociationsFromSiteResourceImpl( .select({ clientId: clients.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clients) .where( @@ -568,7 +572,8 @@ async function rebuildClientAssociationsFromSiteResourceImpl( .select({ clientId: clients.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clients) .where( @@ -720,11 +725,13 @@ async function handleMessagesForSiteClients( clientId: number; pubKey: string | null; subnet: string | null; + orgId: string; }[], existingClients: { clientId: number; pubKey: string | null; subnet: string | null; + orgId: string; }[], clientSitesToAdd: number[], clientSitesToRemove: number[], @@ -805,6 +812,7 @@ async function handleMessagesForSiteClients( clientId: number; pubKey: string | null; subnet: string | null; + orgId: string; } >(); @@ -860,6 +868,22 @@ async function handleMessagesForSiteClients( .map((r) => [r.clientId as number, r.olmId]) ); + // Batch-fetch the orgs for all clients we need to process so we don't + // issue a redundant query per client in the loop below + const orgIdsToProcess = Array.from( + new Set( + Array.from(clientsToProcess.values()).map((client) => client.orgId) + ) + ); + const orgRows = + orgIdsToProcess.length > 0 + ? await trx + .select() + .from(orgs) + .where(inArray(orgs.orgId, orgIdsToProcess)) + : []; + const orgByOrgId = new Map(orgRows.map((org) => [org.orgId, org])); + for (const client of clientsToProcess.values()) { // UPDATE THE NEWT if (!client.subnet || !client.pubKey) { @@ -899,7 +923,14 @@ async function handleMessagesForSiteClients( } if (isAdd) { - if (clientSiteCounts[client.clientId] > 250) { + const org = orgByOrgId.get(client.orgId); + + if (!org) { + logger.warn(`Client ${client.clientId} org not found`); + continue; + } + + if (clientSiteCounts[client.clientId] > org.settingsJitModeLimit) { // skip adding the peer if we have more than 250 sites because we are in jit mode anyway logger.info( `rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode` @@ -1570,7 +1601,8 @@ export async function handleMessagingForUpdatedSiteResource( .select({ clientId: clientSiteResourcesAssociationsCache.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clientSiteResourcesAssociationsCache) .innerJoin( @@ -2391,6 +2423,19 @@ async function handleMessagesForClientSites( .where(eq(clientSitesAssociationsCache.clientId, client.clientId)) .then((rows) => Number(rows[0].count)); + // client.orgId is constant for this call, so fetch the org once + // instead of re-querying it for every site in the loop below + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, client.orgId)) + .limit(1); + + if (!org) { + logger.warn(`Client ${client.clientId} org not found`); + return; + } + for (const siteData of sitesData) { const site = siteData.sites; const exitNode = siteData.exitNodes; @@ -2451,7 +2496,7 @@ async function handleMessagesForClientSites( continue; } - if (totalSitesOnClient > 250) { + if (totalSitesOnClient > org.settingsJitModeLimit) { // skip adding the site if we have more than 250 because we are in jit mode anyway logger.info( `rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode` @@ -3061,7 +3106,8 @@ export async function cleanupSiteAssociations( .select({ clientId: clients.clientId, pubKey: clients.pubKey, - subnet: clients.subnet + subnet: clients.subnet, + orgId: clients.orgId }) .from(clients) .where(inArray(clients.clientId, cachedClientIds)) diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 54906e240..e6f6fbd95 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -8,7 +8,8 @@ import { sites, clientSitesAssociationsCache, exitNodes, - ExitNode + ExitNode, + orgs } from "@server/db"; import { db } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; @@ -112,7 +113,12 @@ export async function updateHolePunch( destinations: destinations }); } catch (error) { - if (!(error instanceof Error && error.message === "Exit node not allowed")) { + if ( + !( + error instanceof Error && + error.message === "Exit node not allowed" + ) + ) { logger.error(error); } return next( @@ -460,7 +466,18 @@ async function handleClientEndpointChange( return; } - if (sitesWithNewtsToUpdate.length > 250) { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, client.orgId)) + .limit(1); + + if (!org) { + logger.warn(`Client ${clientId} org not found`); + return; + } + + if (sitesWithNewtsToUpdate.length > org.settingsJitModeLimit) { logger.warn( `Client ${clientId} has ${sitesWithNewtsToUpdate.length} connected sites so the client will be in jit mode anyway, skipping endpoint updates` ); diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index f7fdb81a8..c7c0c127b 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -8,7 +8,8 @@ import { ExitNode, exitNodes, sites, - clientSitesAssociationsCache + clientSitesAssociationsCache, + orgs } from "@server/db"; import { olms } from "@server/db"; import HttpCode from "@server/types/HttpCode"; @@ -225,7 +226,23 @@ export async function getOlmToken( ) .where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!)); - if (clientSites.length > 250 && build == "saas") { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgIdToUse)) + .limit(1); + + if (!org) { + logger.warn(`Client ${clientIdToUse} org not found`); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Client's org not found" + ) + ); + } + + if (clientSites.length > org.settingsJitModeLimit && build == "saas") { // set all of the cache rows isJitMode to true await db .update(clientSitesAssociationsCache) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 988e68afd..b65a78a70 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -277,7 +277,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ); let jitMode = false; - if (sitesCount > 250 && build == "saas") { + if (sitesCount > org.settingsJitModeLimit && build == "saas") { // THIS IS THE MAX ON THE BUSINESS TIER // we have too many sites // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts index 19d759769..5a681fef7 100644 --- a/server/routers/olm/sync.ts +++ b/server/routers/olm/sync.ts @@ -5,7 +5,8 @@ import { exitNodes, Olm, sites, - clientSitesAssociationsCache + clientSitesAssociationsCache, + orgs } from "@server/db"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { sendToClient } from "#dynamic/routers/ws"; @@ -36,8 +37,19 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) { { orgId: client.orgId } ); + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, client.orgId)) + .limit(1); + + if (!org) { + logger.warn(`Client ${client.clientId} org not found`); + return; + } + let jitMode = false; - if (sitesCount > 250 && build == "saas") { + if (sitesCount > org.settingsJitModeLimit && build == "saas") { // THIS IS THE MAX ON THE BUSINESS TIER // we have too many sites // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites From 29d93a574b2989311b777502d3885b500f93a0c7 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 14:48:05 -0400 Subject: [PATCH 07/77] Fix optional() type errors --- server/lib/blueprints/types.ts | 7 +------ server/lib/readConfigFile.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 9049e4dc6..ff3996417 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -816,25 +816,20 @@ export const ConfigSchema = z .object({ "proxy-resources": z .record(z.string(), PublicResourceSchema) - .optional() .prefault({}), "public-resources": z .record(z.string(), PublicResourceSchema) - .optional() .prefault({}), "client-resources": z .record(z.string(), PrivateResourceSchema) - .optional() .prefault({}), "private-resources": z .record(z.string(), PrivateResourceSchema) - .optional() .prefault({}), "public-policies": z .record(z.string(), ResourcePolicySchema) - .optional() .prefault({}), - sites: z.record(z.string(), SiteSchema).optional().prefault({}) + sites: z.record(z.string(), SiteSchema).prefault({}) }) .transform((data) => { // Merge public-resources into proxy-resources diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 734d960f9..6d95fe26a 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -174,7 +174,6 @@ export const configSchema = z maxmind_db_path: z.string().optional(), maxmind_asn_path: z.string().optional() }) - .optional() .prefault({}), postgres: z .object({ From 7ad56a8baee9c53868e114b5a4757e31705ee9b9 Mon Sep 17 00:00:00 2001 From: Fra146 Date: Thu, 24 Sep 2026 11:42:05 +0200 Subject: [PATCH 08/77] Add config options and badger options injection --- server/lib/readConfigFile.ts | 2 ++ server/routers/traefik/traefikConfigProvider.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 6d95fe26a..0d8786f89 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -149,6 +149,8 @@ export const configSchema = z }) .optional(), trust_proxy: z.int().gte(0).optional().default(1), + trust_ips: z.array(z.string()).optional().default([]), + custom_ip_header: z.string().optional().default(""), // Opt-in: have Traefik/Badger stamp the resolved client IP // into a dedicated header (X-Pangolin-Client-Ip) on the // site-resource AI gateway route, so it survives an diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 02e05f5e0..9643aa0b1 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -51,6 +51,10 @@ export async function traefikConfigProvider( .internal_hostname }:${config.getRawConfig().server.internal_port}` ).href, + disableDefaultCFIPs: config.getRawConfig().server.trust_ips.length > 0, + customIPHeader: config.getRawConfig().server.custom_ip_header, + trustip: config.getRawConfig().server.trust_ips, + userSessionCookieName: config.getRawConfig().server.session_cookie_name, From 1dace9c9f7094ca7c24012c05df8be94c3a1ff25 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 09:47:26 -0400 Subject: [PATCH 09/77] Fix type in SelectedSite type and filter newts only on ssh creation --- .../resources/private/[niceId]/ssh/page.tsx | 4 ++-- .../resources/private/create/page.tsx | 8 +++---- .../settings/resources/public/create/page.tsx | 5 ++-- src/components/BrowserGatewayTargetForm.tsx | 24 ++++++++----------- src/components/HealthCheckCredenza.tsx | 12 ++++------ src/components/HealthChecksTable.tsx | 8 +++---- src/components/PrivateResourceSitesField.tsx | 6 ++--- src/components/PrivateResourceSshFields.tsx | 8 +++---- src/components/PrivateResourcesTable.tsx | 2 +- src/components/PublicResourcesTable.tsx | 4 ++-- src/components/SitesColumnFilterButton.tsx | 4 ++-- src/components/multi-site-selector.tsx | 10 ++++---- .../LauncherFilterPopover.tsx | 8 +++---- .../resource-launcher/ResourceLauncher.tsx | 4 ++-- src/components/site-selector.tsx | 12 +++++----- src/lib/privateResourceUtils.ts | 4 ++-- 16 files changed, 59 insertions(+), 64 deletions(-) diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx index ba60e41e6..fbdecfa5c 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -24,7 +24,7 @@ import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; import { asAnyControl, @@ -84,7 +84,7 @@ export default function PrivateResourceSshPage() { setSelectedSites(first); form.setValue( "siteIds", - first.map((s: Selectedsite) => s.siteId), + first.map((s: SelectedSite) => s.siteId), { shouldValidate: true } ); } diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index b895ff5a3..22db4a8cd 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -27,7 +27,7 @@ import { FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -83,7 +83,7 @@ export default function CreatePrivateResourcePage() { ? Number(siteIdParam) : null; - const [selectedSites, setSelectedSites] = useState([]); + const [selectedSites, setSelectedSites] = useState([]); const [selectedProviders, setSelectedProviders] = useState< SelectedAiProvider[] >([]); @@ -124,10 +124,10 @@ export default function CreatePrivateResourcePage() { .then((res) => { const site = res.data.data; if (!site || site.orgId !== orgId) return; - const selected: Selectedsite = { + const selected: SelectedSite = { siteId: site.siteId, name: site.name, - type: site.type as Selectedsite["type"] + type: site.type as SelectedSite["type"] }; setSelectedSites([selected]); form.setValue("siteIds", [site.siteId]); diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index f5b5b2868..f05052001 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -28,7 +28,7 @@ import { import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { SitesSelector, - type Selectedsite + type SelectedSite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { @@ -253,7 +253,7 @@ export default function Page() { "site" | "remote" >("site"); const [nativeSelectedSite, setNativeSelectedSite] = - useState(null); + useState(null); const [nativeSiteOpen, setNativeSiteOpen] = useState(false); useEffect(() => { @@ -1226,6 +1226,7 @@ export default function Page() { = BaseProps & { }; export type BrowserGatewayTargetFormProps = - | MultiSiteFormProps - | SingleSiteFormProps; + MultiSiteFormProps | SingleSiteFormProps; export function BrowserGatewayTargetForm( props: BrowserGatewayTargetFormProps @@ -90,7 +89,7 @@ export function BrowserGatewayTargetForm( const showMultiSiteDisclaimer = props.multiSite === true && - ((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1; + ((watchedSites as SelectedSite[] | undefined)?.length ?? 0) > 1; return (
@@ -112,7 +111,7 @@ export function BrowserGatewayTargetForm( "aria-invalid:border-destructive aria-invalid:ring-destructive/20", props.multiSite === true ? ( - field.value as Selectedsite[] + field.value as SelectedSite[] )?.length === 0 && "text-muted-foreground" : !field.value && @@ -122,12 +121,12 @@ export function BrowserGatewayTargetForm( {props.multiSite === true ? formatMultiSitesSelectorLabel( - (field.value as Selectedsite[]) ?? + (field.value as SelectedSite[]) ?? [], t ) : (( - field.value as Selectedsite | null + field.value as SelectedSite | null )?.name ?? t("siteSelect"))} @@ -140,7 +139,7 @@ export function BrowserGatewayTargetForm( ( { field.onChange(site); @@ -179,8 +178,7 @@ export function BrowserGatewayTargetForm( onChange={field.onChange} value={ (watchedDestination as - | string - | undefined) ?? "" + string | undefined) ?? "" } /> @@ -205,9 +203,7 @@ export function BrowserGatewayTargetForm( onChange={field.onChange} value={ (watchedDestinationPort as - | string - | number - | undefined) ?? "" + string | number | undefined) ?? "" } /> diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index a1c81a72f..aed14e58f 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -46,7 +46,7 @@ import { PopoverTrigger } from "@/components/ui/popover"; import { SitesSelector } from "@app/components/site-selector"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; import { SwitchInput } from "@app/components/SwitchInput"; @@ -144,7 +144,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); - const [selectedSite, setSelectedSite] = useState(null); + const [selectedSite, setSelectedSite] = useState(null); const healthCheckSchema = z .object({ @@ -183,11 +183,9 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { { message: t("healthCheckPortInvalid") } ), hcFollowRedirects: z.boolean(), - hcHostname: z - .string() - .refine((val) => !/\s/.test(val), { - message: t("healthCheckHostnameInvalid") - }), + hcHostname: z.string().refine((val) => !/\s/.test(val), { + message: t("healthCheckHostnameInvalid") + }), hcMode: z.string(), hcUnhealthyInterval: z.int().positive().min(5), hcTlsServerName: z.string(), diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index b4f273e5a..1c73076a1 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -28,7 +28,7 @@ import { Switch } from "@app/components/ui/switch"; import { toast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { SelectedSite, SitesSelector } from "@app/components/site-selector"; import { ResourceSelector, SelectedResource @@ -59,7 +59,7 @@ type StandaloneHealthChecksTableProps = { healthChecks: HealthCheckRow[]; rowCount: number; pagination: PaginationState; - initialFilterSite?: Selectedsite | null; + initialFilterSite?: SelectedSite | null; initialFilterResource?: SelectedResource | null; }; @@ -117,7 +117,7 @@ export default function HealthChecksTable({ const siteIdQ = searchParams.get("siteId"); const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; - const selectedSite: Selectedsite | null = useMemo(() => { + const selectedSite: SelectedSite | null = useMemo(() => { if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { return null; } @@ -227,7 +227,7 @@ export default function HealthChecksTable({ setResourceFilterOpen(false); }; - const onPickSite = (site: Selectedsite) => { + const onPickSite = (site: SelectedSite) => { handleFilterChange("siteId", String(site.siteId)); setSiteFilterOpen(false); }; diff --git a/src/components/PrivateResourceSitesField.tsx b/src/components/PrivateResourceSitesField.tsx index 0b0b930cb..5b1e0af31 100644 --- a/src/components/PrivateResourceSitesField.tsx +++ b/src/components/PrivateResourceSitesField.tsx @@ -5,7 +5,7 @@ import { formatMultiSitesSelectorLabel } from "@app/components/multi-site-selector"; import { SitesSelector } from "@app/components/site-selector"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { FormControl, @@ -28,8 +28,8 @@ import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateReso type PrivateResourceSitesFieldProps = { control: Control; orgId: string; - selectedSites: Selectedsite[]; - onSelectedSitesChange: (sites: Selectedsite[]) => void; + selectedSites: SelectedSite[]; + onSelectedSitesChange: (sites: SelectedSite[]) => void; siteIdsFieldName?: FieldPath; singleSite?: boolean; }; diff --git a/src/components/PrivateResourceSshFields.tsx b/src/components/PrivateResourceSshFields.tsx index 04ac155de..a727c9649 100644 --- a/src/components/PrivateResourceSshFields.tsx +++ b/src/components/PrivateResourceSshFields.tsx @@ -25,7 +25,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { useTranslations } from "next-intl"; import { useState, type ReactNode } from "react"; import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; type PrivateResourceSshFieldsProps = { control: Control; @@ -33,8 +33,8 @@ type PrivateResourceSshFieldsProps = { watch: UseFormWatch; orgId?: string; disabled?: boolean; - selectedSites: Selectedsite[]; - onSelectedSitesChange: (sites: Selectedsite[]) => void; + selectedSites: SelectedSite[]; + onSelectedSitesChange: (sites: SelectedSite[]) => void; labelPrefix?: "create" | "edit"; showSshSettings?: boolean; layout?: "default" | "wizard"; @@ -101,7 +101,7 @@ export function PrivateResourceSshFields({ onSelectedSitesChange(first); setValue( "siteIds", - first.map((s: Selectedsite) => s.siteId), + first.map((s: SelectedSite) => s.siteId), { shouldValidate: true } ); } diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index d15d53205..02b4500ac 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -7,7 +7,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; -import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { SelectedSite, SitesSelector } from "@app/components/site-selector"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 4a00b4555..22f25c106 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -7,7 +7,7 @@ import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; -import { Selectedsite } from "@app/components/site-selector"; +import { SelectedSite } from "@app/components/site-selector"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { @@ -105,7 +105,7 @@ type ProxyResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; - initialFilterSite?: Selectedsite | null; + initialFilterSite?: SelectedSite | null; /** Certificates prefetched on the server, keyed by full domain. */ initialCertificates?: GetBatchedCertificateResponse; }; diff --git a/src/components/SitesColumnFilterButton.tsx b/src/components/SitesColumnFilterButton.tsx index 2e138f564..ae363f0e5 100644 --- a/src/components/SitesColumnFilterButton.tsx +++ b/src/components/SitesColumnFilterButton.tsx @@ -3,7 +3,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { CheckIcon, Funnel } from "lucide-react"; -import { SiteOnlineStatus, type Selectedsite } from "./site-selector"; +import { SiteOnlineStatus, type SelectedSite } from "./site-selector"; import { Button } from "./ui/button"; import { useTranslations } from "next-intl"; import { Badge } from "./ui/badge"; @@ -62,7 +62,7 @@ export function SitesColumnFilterButton({ // always include the selected site in the list of sites shown const sitesShown = useMemo(() => { - const allSites: Array = [...sites]; + const allSites: Array = [...sites]; if ( debouncedQuery.trim().length === 0 && selectedSite && diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index ef2312988..a4e7c0bdd 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -12,12 +12,12 @@ import { import { Checkbox } from "./ui/checkbox"; import { useTranslations } from "next-intl"; import { useDebounce } from "use-debounce"; -import { SiteOnlineStatus, type Selectedsite } from "./site-selector"; +import { SiteOnlineStatus, type SelectedSite } from "./site-selector"; export type MultiSitesSelectorProps = { orgId: string; - selectedSites: Selectedsite[]; - onSelectionChange: (sites: Selectedsite[]) => void; + selectedSites: SelectedSite[]; + onSelectionChange: (sites: SelectedSite[]) => void; filterTypes?: string[]; scope?: "org" | "launcher"; onClear?: () => void; @@ -25,7 +25,7 @@ export type MultiSitesSelectorProps = { }; export function formatMultiSitesSelectorLabel( - selectedSites: Selectedsite[], + selectedSites: SelectedSite[], t: (key: string, values?: { count: number }) => string ): string { if (selectedSites.length === 0) { @@ -91,7 +91,7 @@ export function MultiSitesSelector({ [selectedSites] ); - const toggleSite = (site: Selectedsite) => { + const toggleSite = (site: SelectedSite) => { if (selectedIds.has(site.siteId)) { onSelectionChange( selectedSites.filter((s) => s.siteId !== site.siteId) diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 90e20f2d7..061bfd0a6 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -23,13 +23,13 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; import { useMemo, useState } from "react"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; type LauncherFilterPopoverProps = { orgId: string; - selectedSites: Selectedsite[]; + selectedSites: SelectedSite[]; selectedLabels: SelectedLabel[]; - onSitesChange: (sites: Selectedsite[]) => void; + onSitesChange: (sites: SelectedSite[]) => void; onLabelsChange: (labels: SelectedLabel[]) => void; }; @@ -58,7 +58,7 @@ export function LauncherFilterPopover({ }) ); - const resolvedSelectedSites: Selectedsite[] = useMemo( + const resolvedSelectedSites: SelectedSite[] = useMemo( () => selectedSites.map((selected) => { const found = sites.find( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 33afedca8..f5d62c7b2 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -63,7 +63,7 @@ import { useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import type { SelectedLabel } from "@app/components/labels-selector"; import { useMediaQuery } from "@app/hooks/useMediaQuery"; import { cn } from "@app/lib/cn"; @@ -256,7 +256,7 @@ export default function ResourceLauncher({ (Boolean(defaultViewOverrides.personal) || (isAdmin && Boolean(defaultViewOverrides.orgWide))); - const selectedSites: Selectedsite[] = useMemo( + const selectedSites: SelectedSite[] = useMemo( () => config.siteIds.map((siteId) => ({ siteId, diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index 2a7717572..af918a4fb 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -15,7 +15,7 @@ import { CheckIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import { useDebounce } from "use-debounce"; -export type Selectedsite = Pick< +export type SelectedSite = Pick< ListSitesResponse["sites"][number], "name" | "siteId" | "type" > & { @@ -24,8 +24,8 @@ export type Selectedsite = Pick< }; type SiteOnlineStatusProps = { - type: Selectedsite["type"]; - online: Selectedsite["online"]; + type: SelectedSite["type"]; + online: SelectedSite["online"]; }; /** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */ @@ -57,8 +57,8 @@ export function SiteOnlineStatus({ type, online }: SiteOnlineStatusProps) { export type SitesSelectorProps = { orgId: string; - selectedSite?: Selectedsite | null; - onSelectSite: (selected: Selectedsite) => void; + selectedSite?: SelectedSite | null; + onSelectSite: (selected: SelectedSite) => void; filterTypes?: string[]; }; @@ -82,7 +82,7 @@ export function SitesSelector({ // always include the selected site in the list of sites shown const sitesShown = useMemo(() => { - const allSites: Array = filterTypes + const allSites: Array = filterTypes ? sites.filter((s) => filterTypes.includes(s.type)) : [...sites]; if ( diff --git a/src/lib/privateResourceUtils.ts b/src/lib/privateResourceUtils.ts index 37cc63756..7d53b5d71 100644 --- a/src/lib/privateResourceUtils.ts +++ b/src/lib/privateResourceUtils.ts @@ -1,11 +1,11 @@ "use client"; -import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedSite } from "@app/components/site-selector"; import type { SiteResourceData } from "@app/lib/privateResourceForm"; export function buildSelectedSitesForResource( resource: Pick -): Selectedsite[] { +): SelectedSite[] { return resource.siteIds.map((siteId, idx) => ({ name: resource.siteNames[idx] ?? "", siteId, From a17b870ed29f69e1ea72c819735f849393cae725 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 09:35:00 -0400 Subject: [PATCH 10/77] Adding gateway resources --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/deleteSiteAssociatedResources.ts | 1 - .../siteResource/createSiteResource.ts | 58 +++++++--- .../siteResource/updateSiteResource.ts | 67 +++++++---- .../private/[niceId]/gateway/page.tsx | 106 ++++++++++++++++++ .../resources/private/[niceId]/layout.tsx | 3 +- .../resources/private/create/page.tsx | 37 ++++++ src/components/PrivateResourceInfoBox.tsx | 3 +- src/components/PrivateResourcesTable.tsx | 3 +- src/components/SiteResourcesOverview.tsx | 3 +- src/lib/privateResourceForm.ts | 7 ++ 12 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 145b35756..63ea3b23a 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -492,7 +492,7 @@ export const siteResources = pgTable( name: varchar("name").notNull(), ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") - .$type<"host" | "cidr" | "http" | "ssh" | "inference">() + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() .notNull(), // "host" | "cidr" | "http" scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a72e15556..cdd06ea20 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -513,7 +513,7 @@ export const siteResources = sqliteTable("siteResources", { name: text("name").notNull(), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode") - .$type<"host" | "cidr" | "http" | "ssh" | "inference">() + .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() .notNull(), // "host" | "cidr" | "http" scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts index c0e520846..1ee2d0cf3 100644 --- a/server/lib/deleteSiteAssociatedResources.ts +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -17,7 +17,6 @@ import { performDeleteSiteResources, runSiteResourceDeleteSideEffects } from "@server/lib/deleteSiteResource"; -import logger from "@server/logger"; export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 4c3593f4c..ad978d530 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -53,7 +53,7 @@ const createSiteResourceSchema = z name: z.string().min(1).max(255), niceId: z.string().optional(), // protocol: z.enum(["tcp", "udp"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]), ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()).optional(), @@ -165,10 +165,11 @@ const createSiteResourceSchema = z ) .refine( (data) => { - // destination is only optional for ssh mode with native authDaemonMode or inference + // destination is only optional for ssh mode with native authDaemonMode, inference, or gateway if ( (data.mode === "ssh" && data.authDaemonMode === "native") || - data.mode == "inference" + data.mode == "inference" || + data.mode == "gateway" ) { return true; } @@ -179,7 +180,7 @@ const createSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native or inference" + "Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway" } ) .refine( @@ -447,14 +448,18 @@ export async function createSiteResource( ); } + // gateway resources always route the whole subnet with everything open + const effectiveDestination = + mode === "gateway" ? "0.0.0.0/0" : destination; + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) - .safeParse(destination).success; + .safeParse(effectiveDestination).success; if ( isIp && - (isIpInCidr(destination!, org.subnet) || - isIpInCidr(destination!, org.utilitySubnet)) + (isIpInCidr(effectiveDestination!, org.subnet) || + isIpInCidr(effectiveDestination!, org.utilitySubnet)) ) { return next( createHttpError( @@ -584,6 +589,32 @@ export async function createSiteResource( tcpPortRangeStringAdjusted = destinationPort ? destinationPort.toString() : "22"; + } else if (mode === "gateway") { + tcpPortRangeStringAdjusted = "*"; + } + + let udpPortRangeStringAdjusted = udpPortRangeString; + if (mode === "gateway") { + udpPortRangeStringAdjusted = "*"; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + udpPortRangeStringAdjusted = ""; + } + + // default to true for http/ssh/inference, false otherwise; + // gateway always allows icmp + let disableIcmpAdjusted = disableIcmp ?? false; + if (mode === "gateway") { + disableIcmpAdjusted = false; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + disableIcmpAdjusted = true; } // Create the site resource @@ -594,21 +625,14 @@ export async function createSiteResource( mode, ssl, networkId: network ? network.networkId : null, - destination: destination, // the ssh can be null + destination: effectiveDestination, // the ssh can be null scheme, destinationPort, alias: alias ? alias.trim() : null, aliasAddress, tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" || mode == "inference" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" || mode == "inference" - ? true - : false), // default to true for http resources, otherwise false + udpPortRangeString: udpPortRangeStringAdjusted, + disableIcmp: disableIcmpAdjusted, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d5662eb16..aaae8a1e1 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -51,7 +51,9 @@ const updateSiteResourceSchema = z ) .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(), + mode: z + .enum(["host", "cidr", "http", "ssh", "inference", "gateway"]) + .optional(), ssl: z.boolean().optional(), scheme: z.enum(["http", "https"]).nullish(), destinationPort: z.int().positive().nullish(), @@ -158,10 +160,11 @@ const updateSiteResourceSchema = z if (data.mode === undefined && data.destination === undefined) { return true; } - // destination is only optional for ssh mode with native authDaemonMode or inference + // destination is only optional for ssh mode with native authDaemonMode, inference, or gateway if ( (data.mode === "ssh" && data.authDaemonMode === "native") || - data.mode == "inference" + data.mode == "inference" || + data.mode == "gateway" ) { return true; } @@ -172,7 +175,7 @@ const updateSiteResourceSchema = z }, { message: - "Destination is required unless mode is ssh with authDaemonMode native or inference" + "Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway" } ) .refine( @@ -409,14 +412,18 @@ export async function updateSiteResource( } } + // gateway resources always route the whole subnet with everything open + const effectiveDestination = + mode === "gateway" ? "0.0.0.0/0" : destination; + // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) - .safeParse(destination).success; + .safeParse(effectiveDestination).success; if ( isIp && - (isIpInCidr(destination!, org.subnet) || - isIpInCidr(destination!, org.utilitySubnet)) + (isIpInCidr(effectiveDestination!, org.subnet) || + isIpInCidr(effectiveDestination!, org.utilitySubnet)) ) { return next( createHttpError( @@ -542,6 +549,34 @@ export async function updateSiteResource( tcpPortRangeStringAdjusted = destinationPort ? destinationPort.toString() : "22"; + } else if (mode === "gateway") { + tcpPortRangeStringAdjusted = "*"; + } + + // undefined means "leave unchanged" (partial update); only + // adjusted when the mode is explicitly being changed + let udpPortRangeStringAdjusted = udpPortRangeString; + if (mode === "gateway") { + udpPortRangeStringAdjusted = "*"; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + udpPortRangeStringAdjusted = ""; + } + + let disableIcmpAdjusted = disableIcmp; + if (mode === "gateway") { + disableIcmpAdjusted = false; + } else if ( + mode === "http" || + mode === "ssh" || + mode === "inference" + ) { + disableIcmpAdjusted = true; + } else if (mode !== undefined) { + disableIcmpAdjusted = disableIcmp ?? false; } [updatedSiteResource] = await trx @@ -552,7 +587,8 @@ export async function updateSiteResource( mode: mode, scheme, ssl, - destination: destination, + destination: + mode === "gateway" ? effectiveDestination : destination, destinationPort: destinationPort, enabled: enabled, alias: @@ -562,19 +598,8 @@ export async function updateSiteResource( : null : undefined, tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" || mode == "inference" - ? "" - : udpPortRangeString, - disableIcmp: - mode !== undefined - ? disableIcmp || - (mode == "http" || - mode == "ssh" || - mode == "inference" - ? true - : false) - : disableIcmp, + udpPortRangeString: udpPortRangeStringAdjusted, + disableIcmp: disableIcmpAdjusted, domainId, subdomain: finalSubdomain, fullDomain, diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx new file mode 100644 index 000000000..1743ba3f8 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/gateway/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { createGatewayFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField"; +import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource"; +import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils"; + +export default function PrivateResourceGatewayPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo(() => createGatewayFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "gateway" + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + siteIds: data.siteIds, + mode: "gateway" + }); + }, null); + + return ( + + + + + {t("gatewaySettings")} + + + {t( + "editInternalResourceDialogDestinationGatewayDescription" + )} + + + + + +
+ + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx index 735848d01..31fe7cfae 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -53,7 +53,8 @@ export default async function PrivateResourceLayout( | "cidrSettings" | "httpSettings" | "sshSettings" - | "inferenceSettings"; + | "inferenceSettings" + | "gatewaySettings"; const navItems = [ { diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index 22db4a8cd..27ac06e3f 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -164,6 +164,11 @@ export default function CreatePrivateResourcePage() { value: "inference" as const, title: t("createInternalResourceDialogModeInference"), description: t("resourceTypeInferenceDescription") + }, + { + value: "gateway" as const, + title: t("createInternalResourceDialogModeGateway"), + description: t("resourceTypeGatewayDescription") } ]; @@ -560,6 +565,38 @@ export default function CreatePrivateResourcePage() { )} + {/* Gateway destination */} + {mode === "gateway" && ( + + + + {t("gatewaySettings")} + + + {t( + "editInternalResourceDialogDestinationGatewayDescription" + )} + + + + + + + + + + + + + )} + {/* HTTP configuration */} {mode === "http" && ( diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index a7add3e36..48f7fdd06 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -93,7 +93,8 @@ export function PrivateResourceInfoSections({ cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; const destination = formatSiteResourceDestinationDisplay({ diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 02b4500ac..d0f05c7f5 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -376,7 +376,8 @@ export default function PrivateResourcesTable({ cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; return {modeLabels[resourceRow.mode]}; } diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index d5d1b4271..08feb7f56 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -71,7 +71,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { cidr: t("editInternalResourceDialogModeCidr"), http: t("editInternalResourceDialogModeHttp"), ssh: t("editInternalResourceDialogModeSsh"), - inference: t("editInternalResourceDialogModeInference") + inference: t("editInternalResourceDialogModeInference"), + gateway: t("editInternalResourceDialogModeGateway") }; const dest = formatSiteResourceDestinationDisplay({ mode: row.mode, diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index 612cce4d8..ad678bb73 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -652,6 +652,13 @@ export function createCidrFormSchema(t: TranslateFn) { .superRefine((data, ctx) => destinationRefine(data, ctx, t)); } +export function createGatewayFormSchema(t: TranslateFn) { + return z.object({ + siteIds: z.array(z.number().int().positive()).min(1), + mode: z.literal("gateway") + }); +} + export function createHttpFormSchema(t: TranslateFn) { return z .object({ From 3378125f8e76cb82f8b4cf95d9914347919c6a59 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 14:41:20 -0400 Subject: [PATCH 11/77] Can create gateway resource --- messages/en-US.json | 6 ++++ server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- src/components/PrivateResourceInfoBox.tsx | 35 ++++++++++++++--------- src/lib/privateResourceForm.ts | 10 ++++++- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..1cfee968e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -307,6 +307,8 @@ "privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients", "privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain", "privateResourceTypeSshDescription": "Access an SSH server from connected clients", + "privateResourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.", + "resourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.", "resourceDomainDescription": "The resource will be served at this fully qualified domain name.", "resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", @@ -2927,6 +2929,7 @@ "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogModeGateway": "Exit Node", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", "editInternalResourceDialogModeInference": "AI Gateway", @@ -2938,6 +2941,7 @@ "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "editInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", + "editInternalResourceDialogDestinationGatewayDescription": "The sites to uses as exit nodes for this resource", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", @@ -2952,6 +2956,7 @@ "privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.", "hostSettings": "Host", "cidrSettings": "CIDR", + "gatewaySettings": "Exit Node", "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", @@ -2990,6 +2995,7 @@ "createInternalResourceDialogModeHttps": "HTTPS", "createInternalResourceDialogModeSsh": "SSH", "createInternalResourceDialogModeInference": "AI Gateway", + "createInternalResourceDialogModeGateway": "Exit Node", "scheme": "Scheme", "createInternalResourceDialogScheme": "Scheme", "createInternalResourceDialogEnableSsl": "Enable TLS", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 63ea3b23a..a4210ab8e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -493,7 +493,7 @@ export const siteResources = pgTable( ssl: boolean("ssl").notNull().default(false), mode: varchar("mode") .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() - .notNull(), // "host" | "cidr" | "http" + .notNull(), scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index cdd06ea20..a2c2339aa 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -514,7 +514,7 @@ export const siteResources = sqliteTable("siteResources", { ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode") .$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">() - .notNull(), // "host" | "cidr" | "http" + .notNull(), scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/src/components/PrivateResourceInfoBox.tsx b/src/components/PrivateResourceInfoBox.tsx index 48f7fdd06..bec193bae 100644 --- a/src/components/PrivateResourceInfoBox.tsx +++ b/src/components/PrivateResourceInfoBox.tsx @@ -108,15 +108,19 @@ export function PrivateResourceInfoSections({ tcpPortRangeString: siteResource.tcpPortRangeString ?? "*", udpPortRangeString: siteResource.udpPortRangeString ?? "*" }); + const showAccess = siteResource.mode !== "gateway"; const showAlias = siteResource.mode !== "cidr" && siteResource.mode !== "http" && - siteResource.mode !== "inference"; + siteResource.mode !== "inference" && + siteResource.mode !== "gateway"; const showDestination = !( siteResource.mode === "ssh" && siteResource.authDaemonMode === "native" - ) && siteResource.mode !== "inference"; + ) && + siteResource.mode !== "inference" && + siteResource.mode !== "gateway"; const showCertificate = !!( (siteResource.mode === "http" || siteResource.mode === "inference") && siteResource.ssl && @@ -129,7 +133,8 @@ export function PrivateResourceInfoSections({ siteResource.mode !== "inference"; const numSections = - 2 + + 1 + + (showAccess ? 1 : 0) + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (showCertificate ? 1 : 0) + @@ -144,17 +149,19 @@ export function PrivateResourceInfoSections({ - - {t("access")} - - - - + {showAccess ? ( + + {t("access")} + + + + + ) : null} {showDestination ? ( diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index ad678bb73..4b8464540 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -421,7 +421,14 @@ export function createCreateFormSchema(t: TranslateFn) { .min(1, t("createInternalResourceDialogNameRequired")) .max(255, t("createInternalResourceDialogNameMaxLength")), siteIds: z.array(z.number().int().positive()).optional(), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum([ + "host", + "cidr", + "http", + "ssh", + "inference", + "gateway" + ]), destination: z.string().nullish(), alias: z.string().nullish(), destinationPort: z @@ -468,6 +475,7 @@ export function createCreateFormSchema(t: TranslateFn) { if ( data.mode !== "ssh" && data.mode !== "inference" && + data.mode !== "gateway" && (!trimmedDestination || trimmedDestination.length < 1) ) { ctx.addIssue({ From 118120c9ce92e10a4e769712e4d4848ca5d75b20 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 14:54:00 -0400 Subject: [PATCH 12/77] Create gateway resources in blueprints --- server/lib/blueprints/privateResources.ts | 52 ++++++++++++++++------- server/lib/blueprints/types.ts | 8 ++-- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/server/lib/blueprints/privateResources.ts b/server/lib/blueprints/privateResources.ts index 085fed836..55904492b 100644 --- a/server/lib/blueprints/privateResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -259,6 +259,11 @@ export async function updatePrivateResources( } const isInference = resourceData.mode === "inference"; + const isGateway = resourceData.mode === "gateway"; + // gateway resources always route the whole subnet with everything open + const effectiveDestination = isGateway + ? "0.0.0.0/0" + : resourceData.destination; // Update existing resource const [updatedResource] = await trx @@ -268,23 +273,28 @@ export async function updatePrivateResources( mode: resourceData.mode, ssl: resourceSsl, scheme: resourceData.scheme, - destination: resourceData.destination, + destination: effectiveDestination, destinationPort: resourceData["destination-port"], enabled: resourceEnabled, alias: resourceData.alias || null, - disableIcmp: - resourceData["disable-icmp"] || - (resourceData.mode == "http" || isInference - ? true - : false), // default to true for http/inference resources, otherwise false + disableIcmp: isGateway + ? false // gateway always allows icmp + : resourceData["disable-icmp"] || + (resourceData.mode == "http" || isInference + ? true + : false), // default to true for http/inference resources, otherwise false tcpPortRangeString: resourceData.mode == "http" || isInference ? "443,80" - : resourceData["tcp-ports"], + : isGateway + ? "*" + : resourceData["tcp-ports"], udpPortRangeString: resourceData.mode == "http" || isInference ? "" - : resourceData["udp-ports"], + : isGateway + ? "*" + : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || null, subdomain: domainInfo ? domainInfo.subdomain : null, domainId: domainInfo ? domainInfo.domainId : null, @@ -529,6 +539,11 @@ export async function updatePrivateResources( } const isInference = resourceData.mode === "inference"; + const isGateway = resourceData.mode === "gateway"; + // gateway resources always route the whole subnet with everything open + const effectiveDestination = isGateway + ? "0.0.0.0/0" + : resourceData.destination; let domainInfo: | { subdomain: string | null; domainId: string } @@ -590,24 +605,29 @@ export async function updatePrivateResources( mode: resourceData.mode, ssl: resourceSsl, scheme: resourceData.scheme, - destination: resourceData.destination, + destination: effectiveDestination, destinationPort: resourceData["destination-port"], enabled: resourceEnabled, alias: resourceData.alias || null, aliasAddress: aliasAddress, - disableIcmp: - resourceData["disable-icmp"] || - (resourceData.mode == "http" || isInference - ? true - : false), // default to true for http/inference resources, otherwise false + disableIcmp: isGateway + ? false // gateway always allows icmp + : resourceData["disable-icmp"] || + (resourceData.mode == "http" || isInference + ? true + : false), // default to true for http/inference resources, otherwise false tcpPortRangeString: resourceData.mode == "http" || isInference ? "443,80" - : resourceData["tcp-ports"], + : isGateway + ? "*" + : resourceData["tcp-ports"], udpPortRangeString: resourceData.mode == "http" || isInference ? "" - : resourceData["udp-ports"], + : isGateway + ? "*" + : resourceData["udp-ports"], fullDomain: resourceData["full-domain"] || null, subdomain: domainInfo ? domainInfo.subdomain : null, domainId: domainInfo ? domainInfo.domainId : null, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ff3996417..a057eb745 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -612,7 +612,7 @@ export function isTargetsOnlyResource(resource: any): boolean { export const PrivateResourceSchema = z .object({ name: z.string().min(1).max(255), - mode: z.enum(["host", "cidr", "http", "ssh", "inference"]), + mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]), site: z.string().optional(), // DEPRECATED IN FAVOR OF sites sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), @@ -652,13 +652,15 @@ export const PrivateResourceSchema = z }) .refine( (data) => { - // destination is optional only for ssh+native or inference; required for everything else + // destination is optional only for ssh+native, inference, or gateway + // (gateway always routes the whole subnet, so destination is ignored); required for everything else const isNativeSSH = data.mode === "ssh" && (data["auth-daemon"] === undefined || data["auth-daemon"].mode === "native"); if ( data.mode !== "inference" && + data.mode !== "gateway" && !isNativeSSH && !data.destination ) { @@ -669,7 +671,7 @@ export const PrivateResourceSchema = z { path: ["destination"], message: - "destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'" + "destination is required unless mode is 'ssh' with auth-daemon mode 'native', 'inference', or 'gateway'" } ) .refine( From 8ba14c2a8aaa9d84a0aba52bf495712524839d2c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 15:24:17 -0400 Subject: [PATCH 13/77] Dont show the gateway resources in the table --- src/components/PrivateResourcesTable.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index d0f05c7f5..8c6281dd2 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -393,7 +393,11 @@ export default function PrivateResourcesTable({ cell: ({ row }) => { const resourceRow = row.original; const display = formatDestinationDisplay(resourceRow); - if (resourceRow.destination) { + if ( + resourceRow.destination && + resourceRow.mode !== "gateway" + ) { + // don't show the gateway resource destination which is 0.0.0.0/0 to not confuse people return ( Date: Wed, 23 Sep 2026 15:24:33 -0400 Subject: [PATCH 14/77] Send gateway resources downstream like cidr resources to newt --- server/lib/ip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 518bb36db..2ecfd90da 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -808,7 +808,7 @@ export async function generateSubnetProxyTargetV2( resourceId: siteResource.siteResourceId }); } - } else if (siteResource.mode == "cidr") { + } else if (siteResource.mode == "cidr" || siteResource.mode == "gateway") { targets.push({ sourcePrefixes: [], destPrefix: siteResource.destination!, From e68963b852fa66372eab375969d18784cffdc62c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 09:43:47 -0400 Subject: [PATCH 15/77] Add comments about not processing gateway --- server/lib/ip.ts | 1 + server/routers/olm/buildConfiguration.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 2ecfd90da..c3567b3ef 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -500,6 +500,7 @@ export function generateRemoteSubnets( if (!sr.enabled) return false; if (!sr.destination) return false; + // we purposely filter out the gateway resource here because we add it manually on the client if (sr.mode === "cidr") { // check if its a valid CIDR using zod const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts index 6db0fa291..bd82e43ad 100644 --- a/server/routers/olm/buildConfiguration.ts +++ b/server/routers/olm/buildConfiguration.ts @@ -231,7 +231,7 @@ export async function buildSiteConfigurationForOlmClient( publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, - remoteSubnets: generateRemoteSubnets(allSiteResources), + remoteSubnets: generateRemoteSubnets(allSiteResources), // we dont add the gateway resources here aliases: generateAliasConfig(allSiteResources) }); } From 97bf58a8769ee278968d4af87e8ce93bcd81342e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 10:34:23 -0400 Subject: [PATCH 16/77] Update to have two different badgers --- config/traefik/dynamic_config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/traefik/dynamic_config.yml b/config/traefik/dynamic_config.yml index 6e7f0fdd9..5fc7695ee 100644 --- a/config/traefik/dynamic_config.yml +++ b/config/traefik/dynamic_config.yml @@ -1,6 +1,6 @@ http: middlewares: - badger: + badger-dashboard: plugin: badger: disableForwardAuth: true @@ -17,7 +17,7 @@ http: - web middlewares: - redirect-to-https - - badger + - badger-dashboard # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -26,7 +26,7 @@ http: entryPoints: - websecure middlewares: - - badger + - badger-dashboard tls: certResolver: letsencrypt @@ -37,7 +37,7 @@ http: entryPoints: - websecure middlewares: - - badger + - badger-dashboard tls: certResolver: letsencrypt From 8f760159e24e93a42ec1df1c0025c56c0276794a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 10:34:23 -0400 Subject: [PATCH 17/77] Update to have two different badgers --- config/traefik/dynamic_config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/traefik/dynamic_config.yml b/config/traefik/dynamic_config.yml index 6e7f0fdd9..5fc7695ee 100644 --- a/config/traefik/dynamic_config.yml +++ b/config/traefik/dynamic_config.yml @@ -1,6 +1,6 @@ http: middlewares: - badger: + badger-dashboard: plugin: badger: disableForwardAuth: true @@ -17,7 +17,7 @@ http: - web middlewares: - redirect-to-https - - badger + - badger-dashboard # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -26,7 +26,7 @@ http: entryPoints: - websecure middlewares: - - badger + - badger-dashboard tls: certResolver: letsencrypt @@ -37,7 +37,7 @@ http: entryPoints: - websecure middlewares: - - badger + - badger-dashboard tls: certResolver: letsencrypt From ed28d34945f4d4a3c789a274ff7abb16d13039c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 11:56:10 -0400 Subject: [PATCH 18/77] Support batched relay,unrelay,local,unlocal messages --- server/private/lib/dns/server.ts | 45 ++++-- server/private/lib/readConfigFile.ts | 10 ++ server/routers/newt/peers.ts | 34 +++++ server/routers/olm/batchUtils.ts | 55 ++++++++ server/routers/olm/handleOlmLocalMessage.ts | 88 +++++++++--- server/routers/olm/handleOlmRelayMessage.ts | 133 ++++++++++++++---- server/routers/olm/handleOlmUnLocalMessage.ts | 120 ++++++++++++---- server/routers/olm/handleOlmUnRelayMessage.ts | 122 ++++++++++++---- 8 files changed, 492 insertions(+), 115 deletions(-) create mode 100644 server/routers/olm/batchUtils.ts diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index 10c46c358..4198f3d2e 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -384,7 +384,8 @@ export class AuthoritativeDNSServer { // Get records from cache or database const records = await this.getResourceRecordsByFullDomain( queryName, - queryType + queryType, + baseDomain! ); if (records.length > 0) { @@ -475,15 +476,30 @@ export class AuthoritativeDNSServer { const labels = queryName.replace(/\.$/, "").split("."); - // Fast path: O(1) in-memory Set lookup — no DB or network I/O - if (this.allDomains.size > 0) { - for (let i = 0; i < labels.length; i++) { - const candidate = labels.slice(i).join("."); - if (this.allDomains.has(candidate)) { - this.authoritativeDomainCache.set(cacheKey, candidate); - return candidate; - } + // Infrastructure zone cuts (e.g. this deployment's own nameserver + // zone, cname/site extension zones) that this server is + // authoritative for regardless of the customer `domains` table. + // Static config, so always available - checked alongside allDomains + // below rather than gating a separate fast path on it. + const configuredZones = (config.getRawConfig().dns?.zones ?? []).map( + (z) => z.toLowerCase() + ); + + // Fast path: O(1) in-memory Set/array lookup — no DB or network I/O + for (let i = 0; i < labels.length; i++) { + const candidate = labels.slice(i).join("."); + if ( + this.allDomains.has(candidate) || + configuredZones.includes(candidate) + ) { + this.authoritativeDomainCache.set(cacheKey, candidate); + return candidate; } + } + + // Once the in-memory customer-domain set has loaded, the checks + // above are conclusive - no DB fallback needed. + if (this.allDomains.size > 0) { this.authoritativeDomainCache.set(cacheKey, null); return null; } @@ -582,7 +598,8 @@ export class AuthoritativeDNSServer { private async getResourceRecordsByFullDomain( name: string, - queryType: dns.RecordType + queryType: dns.RecordType, + baseDomain: string ): Promise { const cacheKey = `resourceRecords:${name}:${queryType}`; @@ -630,8 +647,14 @@ export class AuthoritativeDNSServer { if (resourceRows.length === 0) { // Resource doesn't exist at all — also pre-populate the domainExists // cache so the subsequent domainExists() call hits memory, not the DB. + // Skip that pre-population when `name` is the zone apex itself: the + // apex always exists (it owns the SOA) even with no resource row, and + // caching `false` here would make domainExists() return that stale + // false instead of ever reaching its own apex check. logger.debug(`No resource found for domain: ${name}`); - this.cache.set(`exists:${name}`, false, 60); + if (name.replace(/\.$/, "") !== baseDomain) { + this.cache.set(`exists:${name}`, false, 60); + } this.cache.set(cacheKey, [], 60); return []; } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index db6999724..a78a16d3b 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,6 +109,16 @@ export const privateConfigSchema = z .array(z.string()) .optional() .default([]), + // Zone names (zone cuts) this server is authoritative for + // beyond the customer domains stored in the `domains` table - + // e.g. a self-hosted deployment's own nameserver zone or + // cname/site extension zones. Any query landing on one of + // these names, or an empty non-terminal beneath one, that + // doesn't match a more specific record gets NOERROR/NODATA + + // SOA (not NXDOMAIN), so QNAME-minimising resolvers (RFC + // 9156) don't treat the ancestor as proof nothing exists + // below it (RFC 8020). Left for the user to populate. + zones: z.array(z.string()).optional().default([]), rate_limit: z .object({ enabled: z.boolean().optional().default(true), diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index bd11f03d0..cb69d7480 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -127,6 +127,40 @@ export async function deletePeersBatch( logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`); } +export async function updatePeersBatch( + peers: { + siteId: number; + publicKey: string; + newtId: string; + peer: { + allowedIps?: string[]; + endpoint?: string; + }; + }[] +) { + if (peers.length === 0) { + return; + } + + await sendToClientsBatch( + peers.map((peer) => ({ + clientId: peer.newtId, + message: { + type: "newt/wg/peer/update", + data: { + publicKey: peer.publicKey, + ...peer.peer + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched newt peer updates:`, error); + }); + + logger.info(`Updated ${peers.length} peer(s) on newts (batch)`); +} + export async function updatePeer( siteId: number, publicKey: string, diff --git a/server/routers/olm/batchUtils.ts b/server/routers/olm/batchUtils.ts new file mode 100644 index 000000000..681008e35 --- /dev/null +++ b/server/routers/olm/batchUtils.ts @@ -0,0 +1,55 @@ +// Shared parsing for the olm relay/unrelay/local/unlocal websocket messages, which accept +// either a single siteId or a batched siteIds array (with a parallel chainIds array), so +// olm clients with many sites can coalesce decisions into one message instead of sending +// one message per site. Older olm clients that only ever send the singular form are handled +// identically to a batch of one, and the reply mirrors whichever form the request used. + +import { db, newts } from "@server/db"; +import { inArray } from "drizzle-orm"; + +// Resolves the newtId for each of the given siteIds in a single query, for batching the +// resulting newt/wg/peer/update pushes instead of looking each one up individually. +export async function resolveNewtIdsBySite( + siteIds: number[] +): Promise> { + if (siteIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ siteId: newts.siteId, newtId: newts.newtId }) + .from(newts) + .where(inArray(newts.siteId, siteIds)); + + const map = new Map(); + for (const row of rows) { + if (row.siteId != null) { + map.set(row.siteId, row.newtId); + } + } + return map; +} + +export type SiteChainBatch = { + siteIds: number[]; + chainIds: (string | undefined)[]; + isBatch: boolean; +}; + +export function parseSiteChainBatch(data: any): SiteChainBatch { + if (Array.isArray(data?.siteIds)) { + const siteIds: number[] = data.siteIds; + const chainIds: (string | undefined)[] = + Array.isArray(data.chainIds) && + data.chainIds.length === siteIds.length + ? data.chainIds + : siteIds.map(() => data.chainId); + return { siteIds, chainIds, isBatch: true }; + } + + return { + siteIds: data?.siteId !== undefined ? [data.siteId] : [], + chainIds: [data?.chainId], + isBatch: false + }; +} diff --git a/server/routers/olm/handleOlmLocalMessage.ts b/server/routers/olm/handleOlmLocalMessage.ts index 83ef8cbf1..ca80fea06 100644 --- a/server/routers/olm/handleOlmLocalMessage.ts +++ b/server/routers/olm/handleOlmLocalMessage.ts @@ -1,9 +1,10 @@ import { db, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, Olm } from "@server/db"; -import { and, eq } from "drizzle-orm"; -import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import { eq, inArray } from "drizzle-orm"; +import { updatePeersBatch } from "../newt/peers"; import logger from "@server/logger"; +import { parseSiteChainBatch, resolveNewtIdsBySite } from "./batchUtils"; export const handleOlmLocalMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -40,33 +41,82 @@ export const handleOlmLocalMessage: MessageHandler = async (context) => { return; } - const { siteId, chainId } = message.data; + const { siteIds, chainIds, isBatch } = parseSiteChainBatch(message.data); - // Get the site - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site || !site.exitNodeId) { - logger.warn("Site not found or has no exit node"); + if (siteIds.length === 0) { + logger.warn("Local message has no siteId(s)"); return; } - // update the peer on the newt - await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the newt knows to accept local + // Get the sites + const siteRows = await db + .select() + .from(sites) + .where(inArray(sites.siteId, siteIds)); + const sitesById = new Map(siteRows.map((s) => [s.siteId, s])); + + const valid: { siteId: number; chainId?: string }[] = []; + + for (let i = 0; i < siteIds.length; i++) { + const siteId = siteIds[i]; + + const site = sitesById.get(siteId); + if (!site || !site.exitNodeId) { + logger.warn(`Site ${siteId} not found or has no exit node`); + continue; + } + + valid.push({ siteId, chainId: chainIds[i] }); + } + + if (valid.length === 0) { + return; + } + + // Only ack sites we can actually tell their newt to accept local + const newtIdBySiteId = await resolveNewtIdsBySite(valid.map((v) => v.siteId)); + const pushable = valid.filter((v) => { + if (!newtIdBySiteId.has(v.siteId)) { + logger.warn(`Newt not found for site ${v.siteId}`); + return false; + } + return true; }); + if (pushable.length === 0) { + return; + } + + // update the peer on each newt to accept local + await updatePeersBatch( + pushable.map((v) => ({ + siteId: v.siteId, + publicKey: client.pubKey!, + newtId: newtIdBySiteId.get(v.siteId)!, + peer: { endpoint: "" } // this removes the endpoint so the newt knows to accept local + })) + ); + // Just ack the message, we don't keep sending it + if (isBatch) { + return { + message: { + type: "olm/wg/peer/local", + data: { + siteIds: pushable.map((v) => v.siteId), + chainIds: pushable.map((v) => v.chainId) + } + }, + broadcast: false, + excludeSender: false + }; + } + + const single = pushable[0]; return { message: { type: "olm/wg/peer/local", - data: { - siteId: siteId, - chainId - } + data: { siteId: single.siteId, chainId: single.chainId } }, broadcast: false, excludeSender: false diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 406ed7bbb..1cd27912b 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -1,10 +1,11 @@ import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; -import { and, eq } from "drizzle-orm"; -import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import { and, eq, inArray } from "drizzle-orm"; +import { updatePeersBatch } from "../newt/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { parseSiteChainBatch, resolveNewtIdsBySite } from "./batchUtils"; export const handleOlmRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -41,29 +42,66 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { return; } - const { siteId, chainId } = message.data; + const { siteIds, chainIds, isBatch } = parseSiteChainBatch(message.data); - // Get the site - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site || !site.exitNodeId) { - logger.warn("Site not found or has no exit node"); + if (siteIds.length === 0) { + logger.warn("Relay message has no siteId(s)"); return; } - // get the site's exit node - const [exitNode] = await db + // Get the sites + const siteRows = await db .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); + .from(sites) + .where(inArray(sites.siteId, siteIds)); + const sitesById = new Map(siteRows.map((s) => [s.siteId, s])); - if (!exitNode) { - logger.warn("Exit node not found for site"); + const exitNodeIds = [ + ...new Set( + siteRows + .map((s) => s.exitNodeId) + .filter((id): id is number => id != null) + ) + ]; + + // Get the sites' exit nodes + const exitNodeRows = exitNodeIds.length + ? await db + .select() + .from(exitNodes) + .where(inArray(exitNodes.exitNodeId, exitNodeIds)) + : []; + const exitNodesById = new Map(exitNodeRows.map((e) => [e.exitNodeId, e])); + + const valid: { + siteId: number; + chainId?: string; + relayEndpoint: string; + }[] = []; + + for (let i = 0; i < siteIds.length; i++) { + const siteId = siteIds[i]; + + const site = sitesById.get(siteId); + if (!site || !site.exitNodeId) { + logger.warn(`Site ${siteId} not found or has no exit node`); + continue; + } + + const exitNode = exitNodesById.get(site.exitNodeId); + if (!exitNode) { + logger.warn(`Exit node not found for site ${siteId}`); + continue; + } + + valid.push({ + siteId, + chainId: chainIds[i], + relayEndpoint: exitNode.endpoint + }); + } + + if (valid.length === 0) { return; } @@ -75,23 +113,64 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), - eq(clientSitesAssociationsCache.siteId, siteId) + inArray( + clientSitesAssociationsCache.siteId, + valid.map((v) => v.siteId) + ) ) ); - // update the peer on the newt - await newtUpdatePeer(siteId, client.pubKey, { - endpoint: "" // this removes the endpoint so the newt knows to relay + // Only ack sites we can actually tell their newt to relay for + const newtIdBySiteId = await resolveNewtIdsBySite(valid.map((v) => v.siteId)); + const pushable = valid.filter((v) => { + if (!newtIdBySiteId.has(v.siteId)) { + logger.warn(`Newt not found for site ${v.siteId}`); + return false; + } + return true; }); + if (pushable.length === 0) { + return; + } + + // update the peer on each newt so it knows to relay + await updatePeersBatch( + pushable.map((v) => ({ + siteId: v.siteId, + publicKey: client.pubKey!, + newtId: newtIdBySiteId.get(v.siteId)!, + peer: { endpoint: "" } // this removes the endpoint so the newt knows to relay + })) + ); + + const relayPort = config.getRawConfig().gerbil.clients_start_port; + + if (isBatch) { + return { + message: { + type: "olm/wg/peer/relay", + data: { + siteIds: pushable.map((v) => v.siteId), + relayEndpoints: pushable.map((v) => v.relayEndpoint), + relayPort, + chainIds: pushable.map((v) => v.chainId) + } + }, + broadcast: false, + excludeSender: false + }; + } + + const single = pushable[0]; return { message: { type: "olm/wg/peer/relay", data: { - siteId: siteId, - relayEndpoint: exitNode.endpoint, - relayPort: config.getRawConfig().gerbil.clients_start_port, - chainId + siteId: single.siteId, + relayEndpoint: single.relayEndpoint, + relayPort, + chainId: single.chainId } }, broadcast: false, diff --git a/server/routers/olm/handleOlmUnLocalMessage.ts b/server/routers/olm/handleOlmUnLocalMessage.ts index 55d312815..f98aaf436 100644 --- a/server/routers/olm/handleOlmUnLocalMessage.ts +++ b/server/routers/olm/handleOlmUnLocalMessage.ts @@ -1,9 +1,10 @@ -import { db, exitNodes, sites } from "@server/db"; +import { db, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; -import { and, eq } from "drizzle-orm"; -import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import { and, eq, inArray } from "drizzle-orm"; +import { updatePeersBatch } from "../newt/peers"; import logger from "@server/logger"; +import { parseSiteChainBatch, resolveNewtIdsBySite } from "./batchUtils"; export const handleOlmUnLocalMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -40,54 +41,111 @@ export const handleOlmUnLocalMessage: MessageHandler = async (context) => { return; } - const { siteId, chainId } = message.data; + const { siteIds, chainIds, isBatch } = parseSiteChainBatch(message.data); - // Get the site - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site) { - logger.warn("Site not found or has no exit node"); + if (siteIds.length === 0) { + logger.warn("Unlocal message has no siteId(s)"); return; } - const [clientSiteAssociation] = await db + // Get the sites + const siteRows = await db + .select() + .from(sites) + .where(inArray(sites.siteId, siteIds)); + const sitesById = new Map(siteRows.map((s) => [s.siteId, s])); + + const assocRows = await db .select() .from(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), - eq(clientSitesAssociationsCache.siteId, siteId) + inArray(clientSitesAssociationsCache.siteId, siteIds) ) ); + const assocBySiteId = new Map(assocRows.map((a) => [a.siteId, a])); - if (!clientSiteAssociation) { - logger.warn("Client-Site association not found"); + const valid: { siteId: number; chainId?: string; endpoint: string }[] = []; + + for (let i = 0; i < siteIds.length; i++) { + const siteId = siteIds[i]; + + const site = sitesById.get(siteId); + if (!site) { + logger.warn(`Site ${siteId} not found or has no exit node`); + continue; + } + + const clientSiteAssociation = assocBySiteId.get(siteId); + if (!clientSiteAssociation) { + logger.warn(`Client-Site association not found for site ${siteId}`); + continue; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn( + `Client-Site association has no endpoint, cannot unrelay site ${siteId}` + ); + continue; + } + + valid.push({ + siteId, + chainId: chainIds[i], + endpoint: clientSiteAssociation.isRelayed + ? "" + : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + }); + } + + if (valid.length === 0) { return; } - if (!clientSiteAssociation.endpoint) { - logger.warn("Client-Site association has no endpoint, cannot unrelay"); - return; - } - - // update the peer on the newt - await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.isRelayed - ? "" - : clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + // Only ack sites we can actually push to their newt + const newtIdBySiteId = await resolveNewtIdsBySite(valid.map((v) => v.siteId)); + const pushable = valid.filter((v) => { + if (!newtIdBySiteId.has(v.siteId)) { + logger.warn(`Newt not found for site ${v.siteId}`); + return false; + } + return true; }); + if (pushable.length === 0) { + return; + } + + // update the peer on each newt + await updatePeersBatch( + pushable.map((v) => ({ + siteId: v.siteId, + publicKey: client.pubKey!, + newtId: newtIdBySiteId.get(v.siteId)!, + peer: { endpoint: v.endpoint } + })) + ); + + if (isBatch) { + return { + message: { + type: "olm/wg/peer/unlocal", + data: { + siteIds: pushable.map((v) => v.siteId), + chainIds: pushable.map((v) => v.chainId) + } + }, + broadcast: false, + excludeSender: false + }; + } + + const single = pushable[0]; return { message: { type: "olm/wg/peer/unlocal", - data: { - siteId: siteId, - chainId - } + data: { siteId: single.siteId, chainId: single.chainId } }, broadcast: false, excludeSender: false diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index 3f73a5834..a6e89852e 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -1,9 +1,10 @@ import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; -import { and, eq } from "drizzle-orm"; -import { updatePeer as newtUpdatePeer } from "../newt/peers"; +import { and, eq, inArray } from "drizzle-orm"; +import { updatePeersBatch } from "../newt/peers"; import logger from "@server/logger"; +import { parseSiteChainBatch, resolveNewtIdsBySite } from "./batchUtils"; export const handleOlmUnRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -40,21 +41,21 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - const { siteId, chainId } = message.data; + const { siteIds, chainIds, isBatch } = parseSiteChainBatch(message.data); - // Get the site - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site) { - logger.warn("Site not found or has no exit node"); + if (siteIds.length === 0) { + logger.warn("Unrelay message has no siteId(s)"); return; } - const [clientSiteAssociation] = await db + // Get the sites + const siteRows = await db + .select() + .from(sites) + .where(inArray(sites.siteId, siteIds)); + const sitesById = new Map(siteRows.map((s) => [s.siteId, s])); + + const assocRows = await db .update(clientSitesAssociationsCache) .set({ isRelayed: false @@ -62,33 +63,100 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), - eq(clientSitesAssociationsCache.siteId, siteId) + inArray(clientSitesAssociationsCache.siteId, siteIds) ) ) .returning(); + const assocBySiteId = new Map(assocRows.map((a) => [a.siteId, a])); - if (!clientSiteAssociation) { - logger.warn("Client-Site association not found"); + const valid: { + siteId: number; + chainId?: string; + endpoint: string; + clientEndpoint: string; + }[] = []; + + for (let i = 0; i < siteIds.length; i++) { + const siteId = siteIds[i]; + + const site = sitesById.get(siteId); + if (!site) { + logger.warn(`Site ${siteId} not found or has no exit node`); + continue; + } + + const clientSiteAssociation = assocBySiteId.get(siteId); + if (!clientSiteAssociation) { + logger.warn(`Client-Site association not found for site ${siteId}`); + continue; + } + + if (!clientSiteAssociation.endpoint) { + logger.warn( + `Client-Site association has no endpoint, cannot unrelay site ${siteId}` + ); + continue; + } + + valid.push({ + siteId, + chainId: chainIds[i], + endpoint: site.endpoint ?? "", + clientEndpoint: clientSiteAssociation.endpoint + }); + } + + if (valid.length === 0) { return; } - if (!clientSiteAssociation.endpoint) { - logger.warn("Client-Site association has no endpoint, cannot unrelay"); - return; - } - - // update the peer on the newt - await newtUpdatePeer(siteId, client.pubKey, { - endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt + // Only ack sites we can actually tell their newt to connect directly + const newtIdBySiteId = await resolveNewtIdsBySite(valid.map((v) => v.siteId)); + const pushable = valid.filter((v) => { + if (!newtIdBySiteId.has(v.siteId)) { + logger.warn(`Newt not found for site ${v.siteId}`); + return false; + } + return true; }); + if (pushable.length === 0) { + return; + } + + // update the peer on each newt to connect directly to the client + await updatePeersBatch( + pushable.map((v) => ({ + siteId: v.siteId, + publicKey: client.pubKey!, + newtId: newtIdBySiteId.get(v.siteId)!, + peer: { endpoint: v.clientEndpoint } // this is the endpoint of the client to connect directly to the newt + })) + ); + + if (isBatch) { + return { + message: { + type: "olm/wg/peer/unrelay", + data: { + siteIds: pushable.map((v) => v.siteId), + endpoints: pushable.map((v) => v.endpoint), + chainIds: pushable.map((v) => v.chainId) + } + }, + broadcast: false, + excludeSender: false + }; + } + + const single = pushable[0]; return { message: { type: "olm/wg/peer/unrelay", data: { - siteId: siteId, - endpoint: site.endpoint, - chainId + siteId: single.siteId, + endpoint: single.endpoint, + chainId: single.chainId } }, broadcast: false, From e92911e7c85c440efd8c3d8e235f2af483aa0621 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 14:29:29 -0400 Subject: [PATCH 19/77] Use formatEndpoint where appropriate Fixes fosrl/newt#454 --- server/routers/newt/buildConfiguration.ts | 2 +- server/routers/newt/handleNewtRegisterMessage.ts | 3 ++- server/routers/newt/targets.ts | 9 +++------ server/routers/olm/handleOlmRegisterMessage.ts | 3 ++- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index e1782fd40..6dece5dfa 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -96,7 +96,7 @@ export async function buildClientConfigurationForNewtClient( await updatePeer(client.clients.clientId, { siteId: site.siteId, endpoint: site.endpoint!, - relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, + relayEndpoint: formatEndpoint(exitNode.endpoint, config.getRawConfig().gerbil.clients_start_port), publicKey: site.publicKey!, serverIP: site.address, serverPort: site.listenPort diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 43dee26f5..4d90aa0aa 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -15,6 +15,7 @@ import { fetchContainers } from "./dockerSocket"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; import { NewtErrorCodes, sendNewtError } from "./error"; +import { formatEndpoint } from "@server/lib/ip"; export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -224,7 +225,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { message: { type: "newt/wg/connect", data: { - endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort), relayPort: config.getRawConfig().gerbil.clients_start_port, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 44aa34637..0dfac34c8 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -4,6 +4,7 @@ import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { formatEndpoint } from "@server/lib/ip"; export async function addTargets( newtId: string, @@ -14,9 +15,7 @@ export async function addTargets( ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; + return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`; }); if (payloadTargets.length > 0) { @@ -208,9 +207,7 @@ export async function removeTargets( ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { - return `${target.internalPort ? target.internalPort + ":" : ""}${ - target.ip - }:${target.port}`; + return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`; }); if (payloadTargets.length > 0) { diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index b65a78a70..f583729ef 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -30,6 +30,7 @@ import { } from "#dynamic/lib/exitNodes"; import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; import { addPeer, deletePeer } from "../gerbil/peers"; +import { formatEndpoint } from "@server/lib/ip"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -508,7 +509,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { ? { aliases: exitNodeAliases, connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias - endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort), publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it From 6ddb1550e91d6b226aab1eaa5658fa3229cdbfca Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 24 Sep 2026 16:31:54 -0400 Subject: [PATCH 20/77] Export blueprint functionality --- messages/en-US.json | 5 + .../lib/blueprints/exportResourceBlueprint.ts | 682 ++++++++++++++++++ server/routers/external.ts | 14 + .../resource/exportResourceBlueprint.ts | 100 +++ server/routers/resource/index.ts | 1 + .../exportSiteResourceBlueprint.ts | 100 +++ server/routers/siteResource/index.ts | 1 + src/components/ExportBlueprintModal.tsx | 138 ++++ src/components/PrivateResourcesTable.tsx | 29 + src/components/PublicResourcesTable.tsx | 28 + 10 files changed, 1098 insertions(+) create mode 100644 server/lib/blueprints/exportResourceBlueprint.ts create mode 100644 server/routers/resource/exportResourceBlueprint.ts create mode 100644 server/routers/siteResource/exportSiteResourceBlueprint.ts create mode 100644 src/components/ExportBlueprintModal.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..64c61f5b6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2370,6 +2370,11 @@ "blueprintContentsDescription": "Define the YAML content describing the infrastructure", "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", "blueprintErrorCreate": "Error creating blueprint", + "exportAsBlueprint": "Export as Blueprint", + "exportBlueprintTitle": "Export Blueprint", + "exportBlueprintDescription": "Download a blueprint YAML snippet for \"{name}\" that can be applied to recreate this resource.", + "exportBlueprintError": "Failed to generate blueprint", + "download": "Download", "searchBlueprintProgress": "Search blueprints...", "appliedAt": "Applied At", "source": "Source", diff --git a/server/lib/blueprints/exportResourceBlueprint.ts b/server/lib/blueprints/exportResourceBlueprint.ts new file mode 100644 index 000000000..5b825e74c --- /dev/null +++ b/server/lib/blueprints/exportResourceBlueprint.ts @@ -0,0 +1,682 @@ +import { stringify as stringifyYaml } from "yaml"; +import { and, asc, eq, isNull, not, or } from "drizzle-orm"; +import { + aiBudgets, + aiModels, + clients, + clientSiteResources, + db, + labels, + resourceAiModels, + resourceHeaderAuth, + resourceLabels, + resourcePassword, + resourcePincode, + resourcePolicies, + resourceRules, + resources, + resourceWhitelist, + roles, + roleResources, + roleSiteResources, + siteNetworks, + siteResourceAiModels, + siteResourceLabels, + siteResources, + sites, + targetHealthCheck, + targets, + userResources, + userSiteResources, + users +} from "@server/db"; +import { queryResourcePolicy } from "@server/routers/policy/getResourcePolicy"; +import { + listPublicResourceAiProviders, + listSiteResourceAiProviders +} from "@server/lib/aiInferenceResource"; + +const HTTP_LIKE_PUBLIC_MODES = ["http", "ssh", "rdp", "vnc", "inference"]; + +function reverseRuleAction(action: string): "allow" | "deny" | "pass" { + if (action === "ACCEPT") return "allow"; + if (action === "PASS") return "pass"; + return "deny"; +} + +function reverseRuleMatch(match: string): string { + return match.toLowerCase(); +} + +function stripUndefined>(obj: T): T { + for (const key of Object.keys(obj)) { + if (obj[key] === undefined) { + delete obj[key]; + } + } + return obj; +} + +function buildHealthcheck(row: { + hcHostname: string | null; + hcPort: number | null; + hcEnabled: boolean | null; + hcPath: string | null; + hcScheme: string | null; + hcMode: string | null; + hcInterval: number | null; + hcUnhealthyInterval: number | null; + hcTimeout: number | null; + hcHeaders: string | null; + hcFollowRedirects: boolean | null; + hcMethod: string | null; + hcStatus: number | null; + hcHealthyThreshold: number | null; + hcUnhealthyThreshold: number | null; +}) { + if (!row.hcHostname) { + return undefined; + } + + let headers: { name: string; value: string }[] | undefined; + if (row.hcHeaders) { + try { + headers = JSON.parse(row.hcHeaders); + } catch { + headers = undefined; + } + } + + return stripUndefined({ + hostname: row.hcHostname, + port: row.hcPort ?? undefined, + enabled: row.hcEnabled ?? undefined, + path: row.hcPath ?? undefined, + scheme: row.hcScheme ?? undefined, + mode: row.hcMode ?? undefined, + interval: row.hcInterval ?? undefined, + "unhealthy-interval": row.hcUnhealthyInterval ?? undefined, + timeout: row.hcTimeout ?? undefined, + headers, + "follow-redirects": row.hcFollowRedirects ?? undefined, + method: row.hcMethod ?? undefined, + status: row.hcStatus ?? undefined, + "healthy-threshold": row.hcHealthyThreshold ?? undefined, + "unhealthy-threshold": row.hcUnhealthyThreshold ?? undefined + }); +} + +async function buildPublicAuthAndRules(resource: { + resourceId: number; + resourcePolicyId: number | null; + defaultResourcePolicyId: number | null; + sso: boolean | null; + skipToIdpId: number | null; +}): Promise<{ + policy: string | undefined; + auth: Record | undefined; + rules: Record[]; + notes: string[]; +}> { + const notes: string[] = []; + + if (resource.resourcePolicyId) { + // Shared policy attached: the policy's own auth config lives on the + // policy entity itself (edited separately). The resource only carries + // its own overrides in the legacy resource-level tables. + const [policyRow] = await db + .select({ niceId: resourcePolicies.niceId }) + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resource.resourcePolicyId)) + .limit(1); + + const [roleRows, userRows, whitelistRows, ruleRows, passwordRow, pincodeRow, headerAuthRow] = + await Promise.all([ + db + .select({ name: roles.name }) + .from(roleResources) + .innerJoin( + roles, + and( + eq(roleResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleResources.resourceId, resource.resourceId)), + db + .select({ username: users.username }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .where(eq(userResources.resourceId, resource.resourceId)), + db + .select({ email: resourceWhitelist.email }) + .from(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resource.resourceId)), + db + .select({ + enabled: resourceRules.enabled, + priority: resourceRules.priority, + action: resourceRules.action, + match: resourceRules.match, + value: resourceRules.value + }) + .from(resourceRules) + .where(eq(resourceRules.resourceId, resource.resourceId)), + db + .select({ passwordId: resourcePassword.passwordId }) + .from(resourcePassword) + .where(eq(resourcePassword.resourceId, resource.resourceId)) + .limit(1), + db + .select({ pincodeId: resourcePincode.pincodeId }) + .from(resourcePincode) + .where(eq(resourcePincode.resourceId, resource.resourceId)) + .limit(1), + db + .select({ headerAuthId: resourceHeaderAuth.headerAuthId }) + .from(resourceHeaderAuth) + .where(eq(resourceHeaderAuth.resourceId, resource.resourceId)) + .limit(1) + ]); + + if (passwordRow.length > 0) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (pincodeRow.length > 0) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (headerAuthRow.length > 0) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": resource.sso ?? undefined, + "sso-roles": roleRows.length ? roleRows.map((r) => r.name) : undefined, + "sso-users": userRows.length + ? userRows.map((u) => u.username) + : undefined, + "whitelist-users": whitelistRows.length + ? whitelistRows.map((w) => w.email) + : undefined, + "auto-login-idp": resource.skipToIdpId ?? undefined + }); + + return { + policy: policyRow?.niceId, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: ruleRows.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; + } + + if (!resource.defaultResourcePolicyId) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + // Inline policy: the resource's own default (non-shared) policy carries + // all of its auth config. + const policy = await queryResourcePolicy({ + resourcePolicyId: resource.defaultResourcePolicyId + }); + + if (!policy) { + return { policy: undefined, auth: undefined, rules: [], notes }; + } + + if (policy.passwordId) { + notes.push( + "A password is configured on this resource. Passwords cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.pincodeId) { + notes.push( + "A pincode is configured on this resource. Pincodes cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + if (policy.headerAuth?.id) { + notes.push( + "Basic auth is configured on this resource. Basic auth credentials cannot be exported for security reasons; re-set it after applying this blueprint." + ); + } + + const auth = stripUndefined({ + "sso-enabled": policy.sso ?? undefined, + "sso-roles": policy.roles.length + ? policy.roles.map((r) => r.name) + : undefined, + "sso-users": policy.users.length + ? policy.users.map((u) => u.username) + : undefined, + "whitelist-users": policy.emailWhiteList.length + ? policy.emailWhiteList.map((w) => w.email) + : undefined, + "auto-login-idp": policy.idpId ?? undefined + }); + + return { + policy: undefined, + auth: Object.keys(auth).length > 0 ? auth : undefined, + rules: policy.rules.map((r) => + stripUndefined({ + action: reverseRuleAction(r.action), + match: reverseRuleMatch(r.match), + value: r.value, + priority: r.priority, + enabled: r.enabled + }) + ), + notes + }; +} + +async function buildAiProvidersAndBudget(input: + | { scope: "public"; resourceId: number } + | { scope: "site"; siteResourceId: number } +) { + const providers = + input.scope === "public" + ? await listPublicResourceAiProviders(input.resourceId) + : await listSiteResourceAiProviders(input.siteResourceId); + + const aiProvidersConfig = await Promise.all( + providers.map(async (p) => { + const entry: Record = { + provider: p.niceId, + "access-mode": p.accessMode, + enabled: p.enabled + }; + + if (p.accessMode === "select") { + const modelRows = + input.scope === "public" + ? await db + .select({ modelKey: aiModels.modelKey }) + .from(resourceAiModels) + .innerJoin( + aiModels, + eq(resourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + resourceAiModels.resourceId, + input.resourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ) + : await db + .select({ modelKey: aiModels.modelKey }) + .from(siteResourceAiModels) + .innerJoin( + aiModels, + eq(siteResourceAiModels.modelId, aiModels.modelId) + ) + .where( + and( + eq( + siteResourceAiModels.siteResourceId, + input.siteResourceId + ), + eq(aiModels.providerId, p.providerId) + ) + ); + + if (modelRows.length > 0) { + entry.models = modelRows.map((m) => m.modelKey); + } + } + + return entry; + }) + ); + + const budgetRows = await db + .select() + .from(aiBudgets) + .where( + input.scope === "public" + ? eq(aiBudgets.resourceId, input.resourceId) + : eq(aiBudgets.siteResourceId, input.siteResourceId) + ); + + const aiBudgetConfig = budgetRows.map((b) => ({ + amount: b.amount, + unit: b.unit, + period: b.period, + enforcement: b.enforcement, + enabled: b.enabled + })); + + return { aiProvidersConfig, aiBudgetConfig }; +} + +export async function generatePublicResourceBlueprintYaml( + resourceId: number +): Promise<{ niceId: string; contents: string }> { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + throw new Error(`Resource ${resourceId} not found`); + } + + const mode = resource.mode; + const isHttpLike = HTTP_LIKE_PUBLIC_MODES.includes(mode); + const isInference = mode === "inference"; + + const resourceConfig: Record = { + name: resource.name, + mode, + enabled: resource.enabled + }; + + if (isHttpLike) { + if (resource.fullDomain) { + resourceConfig["full-domain"] = resource.fullDomain; + } + resourceConfig.ssl = resource.ssl; + } else { + if (resource.proxyPort) { + resourceConfig["proxy-port"] = resource.proxyPort; + } + if (mode === "tcp") { + resourceConfig["proxy-protocol"] = resource.proxyProtocol; + resourceConfig["proxy-protocol-version"] = + resource.proxyProtocolVersion ?? undefined; + } + } + + if (resource.setHostHeader) { + resourceConfig["host-header"] = resource.setHostHeader; + } + if (resource.tlsServerName) { + resourceConfig["tls-server-name"] = resource.tlsServerName; + } + if (resource.requestHeaders) { + try { + resourceConfig.requestHeaders = JSON.parse(resource.requestHeaders); + } catch { + // ignore malformed stored headers + } + } + if (resource.responseHeaders) { + try { + resourceConfig.responseHeaders = JSON.parse(resource.responseHeaders); + } catch { + // ignore malformed stored headers + } + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: resource.pamMode ?? undefined, + mode: resource.authDaemonMode ?? undefined, + port: resource.authDaemonPort ?? undefined + }); + } + + if (resource.maintenanceModeEnabled) { + resourceConfig.maintenance = stripUndefined({ + enabled: resource.maintenanceModeEnabled, + type: resource.maintenanceModeType ?? undefined, + title: resource.maintenanceTitle ?? undefined, + message: resource.maintenanceMessage ?? undefined, + "estimated-time": resource.maintenanceEstimatedTime ?? undefined + }); + } + + // Labels + const labelRows = await db + .select({ name: labels.name }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(eq(resourceLabels.resourceId, resourceId)); + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + // Auth / rules / shared policy + const { policy, auth, rules, notes } = await buildPublicAuthAndRules( + resource + ); + if (policy) { + resourceConfig.policy = policy; + } + if (auth) { + resourceConfig.auth = auth; + } + if (rules.length > 0) { + resourceConfig.rules = rules; + } + + // Targets (all modes except inference use targets) + if (!isInference) { + const targetRows = await db + .select({ + ip: targets.ip, + method: targets.method, + port: targets.port, + enabled: targets.enabled, + internalPort: targets.internalPort, + path: targets.path, + pathMatchType: targets.pathMatchType, + rewritePath: targets.rewritePath, + rewritePathType: targets.rewritePathType, + priority: targets.priority, + siteNiceId: sites.niceId, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcFollowRedirects: targetHealthCheck.hcFollowRedirects, + hcMethod: targetHealthCheck.hcMethod, + hcStatus: targetHealthCheck.hcStatus, + hcHealthyThreshold: targetHealthCheck.hcHealthyThreshold, + hcUnhealthyThreshold: targetHealthCheck.hcUnhealthyThreshold + }) + .from(targets) + .leftJoin(sites, eq(sites.siteId, targets.siteId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) + .where(eq(targets.resourceId, resourceId)) + .orderBy(asc(targets.targetId)); + + resourceConfig.targets = targetRows.map((row) => + stripUndefined({ + site: row.siteNiceId ?? undefined, + method: mode === "http" ? row.method ?? undefined : undefined, + hostname: row.ip, + port: row.port, + enabled: row.enabled, + "internal-port": row.internalPort ?? undefined, + path: row.path ?? undefined, + "path-match": row.pathMatchType ?? undefined, + "rewrite-path": row.rewritePath ?? undefined, + "rewrite-match": row.rewritePathType ?? undefined, + priority: row.priority, + healthcheck: buildHealthcheck(row) + }) + ); + } else { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "public", resourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "proxy-resources": { + [resource.niceId]: resourceConfig + } + }; + + const notesPrefix = + notes.length > 0 + ? notes.map((n) => `# ${n}`).join("\n") + "\n\n" + : ""; + + return { + niceId: resource.niceId, + contents: notesPrefix + stringifyYaml(config) + }; +} + +export async function generatePrivateResourceBlueprintYaml( + siteResourceId: number +): Promise<{ niceId: string; contents: string }> { + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + throw new Error(`Site resource ${siteResourceId} not found`); + } + + const mode = siteResource.mode; + const isInference = mode === "inference"; + const isHttpLike = mode === "http" || isInference; + + const resourceConfig: Record = { + name: siteResource.name, + mode, + enabled: siteResource.enabled + }; + + if (!isInference && siteResource.networkId) { + const siteRows = await db + .select({ niceId: sites.niceId }) + .from(siteNetworks) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(eq(siteNetworks.networkId, siteResource.networkId)); + if (siteRows.length > 0) { + resourceConfig.sites = siteRows.map((s) => s.niceId); + } + } + + if (siteResource.destination) { + resourceConfig.destination = siteResource.destination; + } + if (siteResource.destinationPort) { + resourceConfig["destination-port"] = siteResource.destinationPort; + } + + if (!isHttpLike) { + resourceConfig["tcp-ports"] = siteResource.tcpPortRangeString; + resourceConfig["udp-ports"] = siteResource.udpPortRangeString; + resourceConfig["disable-icmp"] = siteResource.disableIcmp; + } + + if (isHttpLike) { + if (siteResource.fullDomain) { + resourceConfig["full-domain"] = siteResource.fullDomain; + } + resourceConfig.ssl = siteResource.ssl; + if (siteResource.scheme) { + resourceConfig.scheme = siteResource.scheme; + } + } + + if (siteResource.alias) { + resourceConfig.alias = siteResource.alias; + } + + if (mode === "ssh") { + resourceConfig["auth-daemon"] = stripUndefined({ + pam: siteResource.pamMode ?? undefined, + mode: siteResource.authDaemonMode ?? undefined, + port: siteResource.authDaemonPort ?? undefined + }); + } + + const [roleRows, userRows, clientRows, labelRows] = await Promise.all([ + db + .select({ name: roles.name }) + .from(roleSiteResources) + .innerJoin( + roles, + and( + eq(roleSiteResources.roleId, roles.roleId), + or(isNull(roles.isAdmin), not(roles.isAdmin)) + ) + ) + .where(eq(roleSiteResources.siteResourceId, siteResourceId)), + db + .select({ username: users.username }) + .from(userSiteResources) + .innerJoin(users, eq(userSiteResources.userId, users.userId)) + .where(eq(userSiteResources.siteResourceId, siteResourceId)), + db + .select({ niceId: clients.niceId }) + .from(clientSiteResources) + .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)), + db + .select({ name: labels.name }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .where(eq(siteResourceLabels.siteResourceId, siteResourceId)) + ]); + + if (roleRows.length > 0) { + resourceConfig.roles = roleRows.map((r) => r.name); + } + if (userRows.length > 0) { + resourceConfig.users = userRows.map((u) => u.username); + } + if (clientRows.length > 0) { + resourceConfig.machines = clientRows.map((c) => c.niceId); + } + if (labelRows.length > 0) { + resourceConfig.labels = labelRows.map((l) => l.name); + } + + if (isInference) { + const { aiProvidersConfig, aiBudgetConfig } = + await buildAiProvidersAndBudget({ scope: "site", siteResourceId }); + resourceConfig["ai-providers"] = aiProvidersConfig; + if (aiBudgetConfig.length > 0) { + resourceConfig["ai-budget"] = aiBudgetConfig; + } + } + + const config = { + "client-resources": { + [siteResource.niceId]: resourceConfig + } + }; + + return { + niceId: siteResource.niceId, + contents: stringifyYaml(config) + }; +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..382e3e826 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -386,6 +386,13 @@ authenticated.get( siteResource.getSiteResource ); +authenticated.get( + "/site-resource/:siteResourceId/blueprint", + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.exportSiteResourceBlueprint +); + authenticated.post( "/site-resource/:siteResourceId", verifySiteResourceAccess, @@ -764,6 +771,13 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); + +authenticated.get( + "/resource/:resourceId/blueprint", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.getResource), + resource.exportResourceBlueprint +); authenticated.get( "/org/:orgId/resource/:niceId", verifyOrgAccess, diff --git a/server/routers/resource/exportResourceBlueprint.ts b/server/routers/resource/exportResourceBlueprint.ts new file mode 100644 index 000000000..3e6d8e1a2 --- /dev/null +++ b/server/routers/resource/exportResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { generatePublicResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportResourceBlueprintSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +export type ExportResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.Blueprint], + request: { + params: exportResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ niceId: resources.niceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + const { niceId, contents } = + await generatePublicResourceBlueprintYaml(resourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..071f173db 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -43,3 +43,4 @@ export * from "./listResourceAiProviders"; export * from "./setResourceAiProviders"; export * from "./addAiProviderToResource"; export * from "./removeAiProviderFromResource"; +export * from "./exportResourceBlueprint"; diff --git a/server/routers/siteResource/exportSiteResourceBlueprint.ts b/server/routers/siteResource/exportSiteResourceBlueprint.ts new file mode 100644 index 000000000..e5d92446d --- /dev/null +++ b/server/routers/siteResource/exportSiteResourceBlueprint.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { generatePrivateResourceBlueprintYaml } from "@server/lib/blueprints/exportResourceBlueprint"; + +const exportSiteResourceBlueprintSchema = z.strictObject({ + siteResourceId: z.coerce.number().int().positive() +}); + +export type ExportSiteResourceBlueprintResponse = { + name: string; + contents: string; +}; + +registry.registerPath({ + method: "get", + path: "/site-resource/{siteResourceId}/blueprint", + description: + "Generate a blueprint YAML snippet that recreates this site resource.", + tags: [OpenAPITags.PrivateResource, OpenAPITags.Blueprint], + request: { + params: exportSiteResourceBlueprintSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function exportSiteResourceBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = exportSiteResourceBlueprintSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + const [siteResource] = await db + .select({ niceId: siteResources.niceId }) + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + const { niceId, contents } = + await generatePrivateResourceBlueprintYaml(siteResourceId); + + return response(res, { + data: { name: niceId, contents }, + success: true, + error: false, + message: "Blueprint generated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : "An error occurred" + ) + ); + } +} diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 046ed0a13..243074197 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -25,3 +25,4 @@ export * from "./listSiteResourceAiProviders"; export * from "./setSiteResourceAiProviders"; export * from "./addAiProviderToSiteResource"; export * from "./removeAiProviderFromSiteResource"; +export * from "./exportSiteResourceBlueprint"; diff --git a/src/components/ExportBlueprintModal.tsx b/src/components/ExportBlueprintModal.tsx new file mode 100644 index 000000000..d6ad61440 --- /dev/null +++ b/src/components/ExportBlueprintModal.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Editor from "@monaco-editor/react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; +import { Download, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type ExportBlueprintModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + endpoint: string; + resourceName: string; +}; + +export default function ExportBlueprintModal({ + open, + setOpen, + endpoint, + resourceName +}: ExportBlueprintModalProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [loading, setLoading] = useState(false); + const [contents, setContents] = useState(null); + const [fileName, setFileName] = useState(resourceName); + + useEffect(() => { + if (!open) { + return; + } + + setContents(null); + setLoading(true); + + api.get(endpoint) + .then((res) => { + setContents(res.data.data.contents); + setFileName(res.data.data.name || resourceName); + }) + .catch((e) => { + toast({ + variant: "destructive", + title: t("exportBlueprintError"), + description: formatAxiosError( + e, + t("exportBlueprintError") + ) + }); + setOpen(false); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, endpoint]); + + function handleDownload() { + if (!contents) { + return; + } + + const blob = new Blob([contents], { type: "application/x-yaml" }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute("download", `${fileName}.yaml`); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + } + + return ( + + + + {t("exportBlueprintTitle")} + + {t("exportBlueprintDescription", { + name: resourceName + })} + + + +
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 02b4500ac..4e3461ed5 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -2,6 +2,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; +import ExportBlueprintModal from "@app/components/ExportBlueprintModal"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceSitesStatusCell, @@ -171,6 +172,10 @@ export default function PrivateResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = + useState(null); + const [isRefreshing, startRefreshTransition] = useTransition(); const { isPaidUser } = usePaidStatus(); @@ -566,6 +571,14 @@ export default function PrivateResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedInternalResource( @@ -668,6 +681,22 @@ export default function PrivateResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/site-resource/${exportResource.id}/blueprint`} + resourceName={ + exportResource.niceId || exportResource.name + } + /> + )} + (); + const [isExportModalOpen, setIsExportModalOpen] = useState(false); + const [exportResource, setExportResource] = useState( + null + ); + const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -617,6 +623,14 @@ export default function PublicResourcesTable({ {t("viewSettings")} + { + setExportResource(resourceRow); + setIsExportModalOpen(true); + }} + > + {t("exportAsBlueprint")} + { setSelectedResource(resourceRow); @@ -720,6 +734,20 @@ export default function PublicResourcesTable({ /> )} + {exportResource && ( + { + setIsExportModalOpen(val); + if (!val) { + setExportResource(null); + } + }} + endpoint={`/resource/${exportResource.id}/blueprint`} + resourceName={exportResource.nice || exportResource.name} + /> + )} + Date: Thu, 24 Sep 2026 16:56:57 -0400 Subject: [PATCH 21/77] Add fist pass of mtls support --- messages/en-US.json | 33 ++ server/auth/actions.ts | 3 + server/db/pg/schema/schema.ts | 20 + server/db/sqlite/schema/schema.ts | 27 + server/lib/billing/tierMatrix.ts | 6 +- server/lib/blueprints/MtlsSchema.ts | 3 + server/lib/blueprints/publicResources.ts | 95 ++++ server/lib/blueprints/types.ts | 44 ++ server/lib/mtlsCertificate.ts | 61 +++ server/private/lib/blueprints/MtlsSchema.ts | 19 + .../private/lib/traefik/getTraefikConfig.ts | 87 +++- .../routers/billing/featureLifecycle.ts | 16 + server/routers/external.ts | 22 + server/routers/integration.ts | 33 ++ .../resource/createResourceMtlsCertificate.ts | 239 +++++++++ .../resource/deleteResourceMtlsCertificate.ts | 135 +++++ server/routers/resource/index.ts | 3 + .../resource/listResourceMtlsCertificates.ts | 111 ++++ server/routers/resource/updateResource.ts | 61 ++- .../resources/public/[niceId]/layout.tsx | 7 + .../resources/public/[niceId]/mtls/page.tsx | 490 ++++++++++++++++++ src/lib/queries.ts | 12 + 22 files changed, 1521 insertions(+), 6 deletions(-) create mode 100644 server/lib/blueprints/MtlsSchema.ts create mode 100644 server/lib/mtlsCertificate.ts create mode 100644 server/private/lib/blueprints/MtlsSchema.ts create mode 100644 server/routers/resource/createResourceMtlsCertificate.ts create mode 100644 server/routers/resource/deleteResourceMtlsCertificate.ts create mode 100644 server/routers/resource/listResourceMtlsCertificates.ts create mode 100644 src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index bfb07ef74..fd4ac085f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3892,6 +3892,39 @@ "learnMore": "Learn more", "backToHome": "Go back to home", "needToSignInToOrg": "Need to use your organization's identity provider?", + "mtls": "mTLS", + "mtlsCaCertificates": "Trusted CA certificates", + "mtlsCaCertificatesDescription": "Clients must present a certificate signed by one of these certificate authorities to connect. Upload public CA certificates only, never a private key.", + "mtlsCertificateName": "Name (optional)", + "mtlsCertificateNamePlaceholder": "Corporate device CA", + "mtlsCertificatePem": "CA certificate (PEM)", + "mtlsCertificatePemDescription": "Paste a PEM-encoded certificate or a bundle of certificates, or upload a .pem, .crt or .cer file.", + "mtlsUploadFile": "Upload file", + "mtlsAddCertificate": "Add CA certificate", + "mtlsCertificateAdded": "CA certificate added", + "mtlsCertificateAddedDescription": "Clients with certificates signed by this CA can now be verified.", + "mtlsCertificateErrorAdd": "Failed to add CA certificate", + "mtlsCertificateErrorAddDescription": "The certificate could not be added.", + "mtlsCertificateRemoved": "CA certificate removed", + "mtlsCertificateErrorRemove": "Failed to remove CA certificate", + "mtlsCertificateErrorRemoveDescription": "The certificate could not be removed.", + "mtlsLastCertificateRemoved": "mTLS was turned off because the last CA certificate was removed.", + "mtlsNoCertificates": "No CA certificates added yet", + "mtlsRemoveCertificate": "Remove CA certificate", + "mtlsExpired": "Expired", + "mtlsExpiresSoon": "Expires soon", + "mtlsExpiresOn": "Expires {date}", + "mtlsIssuedBy": "Issued by {issuer}", + "mtlsFingerprint": "SHA-256 fingerprint", + "mtlsEnforcement": "Client certificate enforcement", + "mtlsEnforcementDescription": "Control whether this resource requires a client certificate.", + "enableMtls": "Require client certificate", + "enableMtlsDescription": "Reject connections that don't present a valid certificate from a trusted CA. This is checked during the TLS handshake, before any other authentication method.", + "mtlsRequiresCertificate": "Add at least one CA certificate to enable mTLS.", + "mtlsRequiresSsl": "mTLS requires SSL. Enable SSL in this resource's HTTP settings first.", + "mtlsForwardedHeadersTitle": "Client certificate details are forwarded", + "mtlsForwardedHeadersDescription": "Your backend receives the verified certificate's subject, issuer, serial number and validity in the X-Forwarded-Tls-Client-Cert-Info header.", + "mtlsHandshakeNote": "Clients without a valid certificate fail the TLS handshake, so browsers show a connection error rather than a login page.", "maintenanceMode": "Maintenance Page", "maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeType": "Maintenance Mode Type", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 75caed82d..9af39bd9f 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createResourceMtlsCertificate = "createResourceMtlsCertificate", + deleteResourceMtlsCertificate = "deleteResourceMtlsCertificate", + listResourceMtlsCertificates = "listResourceMtlsCertificates", createSiteResource = "createSiteResource", deleteSiteResource = "deleteSiteResource", getSiteResource = "getSiteResource", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 145b35756..1c03bf1d6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -200,6 +200,7 @@ export const resources = pgTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: boolean("mtlsEnabled").notNull().default(false), postAuthPath: text("postAuthPath"), health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false), @@ -1107,6 +1108,22 @@ export const resourceWhitelist = pgTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = pgTable("resourceMtlsCertificates", { + mtlsCertificateId: serial("mtlsCertificateId").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: varchar("name"), + certificate: text("certificate").notNull(), + subject: varchar("subject"), + issuer: varchar("issuer"), + serialNumber: varchar("serialNumber"), + fingerprint: varchar("fingerprint"), + notBefore: bigint("notBefore", { mode: "number" }), + notAfter: bigint("notAfter", { mode: "number" }), + createdAt: bigint("createdAt", { mode: "number" }).notNull() +}); + export const resourceOtp = pgTable("resourceOtp", { otpId: serial("otpId").primaryKey(), resourceId: integer("resourceId") @@ -2019,6 +2036,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode >; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index a72e15556..b479b4af9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -220,6 +220,9 @@ export const resources = sqliteTable( maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + mtlsEnabled: integer("mtlsEnabled", { mode: "boolean" }) + .notNull() + .default(false), postAuthPath: text("postAuthPath"), health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }) @@ -1377,6 +1380,27 @@ export const resourceWhitelist = sqliteTable("resourceWhitelist", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); +export const resourceMtlsCertificates = sqliteTable( + "resourceMtlsCertificates", + { + mtlsCertificateId: integer("mtlsCertificateId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + name: text("name"), + certificate: text("certificate").notNull(), + subject: text("subject"), + issuer: text("issuer"), + serialNumber: text("serialNumber"), + fingerprint: text("fingerprint"), + notBefore: integer("notBefore"), + notAfter: integer("notAfter"), + createdAt: integer("createdAt").notNull() + } +); + export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ autoIncrement: true @@ -2062,6 +2086,9 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; +export type ResourceMtlsCertificate = InferSelectModel< + typeof resourceMtlsCertificates +>; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c04a60aed..b87b247ad 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -26,7 +26,8 @@ export enum TierFeature { WildcardSubdomain = "wildcardSubdomain", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", - RoleBasedSSHControls = "roleBasedSSHControls" + RoleBasedSSHControls = "roleBasedSSHControls", + Mtls = "mtls" // handle downgrade by disabling mTLS on resources } export const tierMatrix: Record = { @@ -70,5 +71,6 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"] + [TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"], + [TierFeature.Mtls]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/MtlsSchema.ts b/server/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..5e7054239 --- /dev/null +++ b/server/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const MtlsSchema = z.object({}); diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 719671d97..ffd4331df 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -10,6 +10,7 @@ import { Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourcePolicies, @@ -62,6 +63,10 @@ import { LimitId } from "../billing"; import { usageService } from "../billing/usageService"; import { syncInferenceAiConfig } from "./aiProviders"; import { syncAiBudgets } from "./aiBudgets"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "../mtlsCertificate"; export type PublicResourcesResults = { proxyResource: Resource; @@ -274,6 +279,20 @@ export async function updatePublicResources( ? JSON.stringify(resourceData.responseHeaders) : null; + // mTLS is a security control: refuse (rather than silently drop) a + // blueprint that asks for it on a plan that doesn't include it. + if (resourceData.mtls?.enabled) { + const mtlsLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.mtls + ); + if (!mtlsLicensed) { + throw new Error( + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ); + } + } + if (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( orgId, @@ -431,6 +450,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -618,6 +638,7 @@ export async function updatePublicResources( resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -722,6 +743,14 @@ export async function updatePublicResources( resourceId: existingResource.resourceId, budgets: resourceData["ai-budget"] || [] }); + + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + existingResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } } const existingResourceTargets = await trx @@ -1246,6 +1275,7 @@ export async function updatePublicResources( maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"], + mtlsEnabled: resourceData.mtls?.enabled, proxyProtocol: resourceData.mode === "tcp" ? (resourceData["proxy-protocol"] ?? false) @@ -1300,6 +1330,14 @@ export async function updatePublicResources( budgets: resourceData["ai-budget"] || [] }); + if (resourceData.mtls?.["ca-certificates"]) { + await syncResourceMtlsCertificates( + newResource.resourceId, + resourceData.mtls["ca-certificates"], + trx + ); + } + await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId @@ -1643,6 +1681,63 @@ async function syncUserResources( } } +async function syncResourceMtlsCertificates( + resourceId: number, + caCertificates: string[], + trx: Transaction +) { + // A PEM entry may itself be a bundle; flatten and dedupe by fingerprint. + const desired = new Map[0]>(); + for (const pem of caCertificates) { + for (const cert of parseCaCertificates(pem)) { + desired.set(cert.fingerprint, cert); + } + } + + if (desired.size > MAX_MTLS_CERTIFICATES_PER_RESOURCE) { + throw new Error( + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ); + } + + const existing = await trx + .select() + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + + for (const cert of desired.values()) { + if (existingFingerprints.has(cert.fingerprint)) { + continue; + } + await trx.insert(resourceMtlsCertificates).values({ + resourceId, + certificate: cert.certificate, + subject: cert.subject, + issuer: cert.issuer, + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + createdAt: Date.now() + }); + } + + for (const row of existing) { + if (!row.fingerprint || !desired.has(row.fingerprint)) { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + row.mtlsCertificateId + ) + ); + } + } +} + async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index ff3996417..967150ba1 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { existsSync } from "node:fs"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; +import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema"; import { isValidRegionId } from "@server/db/regions"; import { isValidHttpMethodList } from "@server/lib/validators"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; @@ -331,6 +332,7 @@ export const PublicResourceSchema = z responseHeaders: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional(), + mtls: MtlsSchema.optional(), "auth-daemon": AuthDaemonSchema.optional(), "proxy-protocol": z.boolean().optional(), "proxy-protocol-version": z.int().min(1).optional(), @@ -445,6 +447,48 @@ export const PublicResourceSchema = z error: "When protocol is 'http', 'ssh', 'rdp', 'vnc', or 'inference', a 'full-domain' must be provided" } ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return (resource.mode ?? resource.protocol) === "http"; + }, + { + path: ["mtls"], + error: "mTLS is only supported when mode is 'http'" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + return resource.ssl !== false; + }, + { + path: ["mtls"], + error: "mTLS requires 'ssl' to be enabled" + } + ) + .refine( + (resource) => { + if (isTargetsOnlyResource(resource) || !resource.mtls?.enabled) { + return true; + } + + const caCertificates = ( + resource.mtls as { "ca-certificates"?: string[] } + )["ca-certificates"]; + return (caCertificates?.length ?? 0) > 0; + }, + { + path: ["mtls", "ca-certificates"], + error: "mTLS requires at least one entry in 'ca-certificates'" + } + ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { diff --git a/server/lib/mtlsCertificate.ts b/server/lib/mtlsCertificate.ts new file mode 100644 index 000000000..169d97a87 --- /dev/null +++ b/server/lib/mtlsCertificate.ts @@ -0,0 +1,61 @@ +import { X509Certificate } from "crypto"; + +export const MAX_MTLS_CERTIFICATES_PER_RESOURCE = 20; + +export type ParsedCaCertificate = { + certificate: string; + subject: string; + issuer: string; + serialNumber: string; + fingerprint: string; + notBefore: number; + notAfter: number; +}; + +const PEM_CERTIFICATE_REGEX = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +// Certificate subjects/issuers come back as newline-separated "CN=foo\nO=bar"; +// flatten to a single readable line for storage and display. +function flattenDn(dn: string): string { + return dn.split("\n").join(", "); +} + +/** + * Parses one or more PEM-encoded CA certificates (a bundle is split into its + * individual certificates). Throws an Error with a user-presentable message + * if the input is not a valid certificate or looks like a private key. + */ +export function parseCaCertificates(input: string): ParsedCaCertificate[] { + if (/PRIVATE KEY/.test(input)) { + throw new Error( + "This looks like a private key, not a certificate. Upload the CA's public certificate only." + ); + } + + const blocks = input.match(PEM_CERTIFICATE_REGEX); + if (!blocks || blocks.length === 0) { + throw new Error( + "Invalid certificate: no PEM-encoded certificate found. Expected a block starting with -----BEGIN CERTIFICATE-----." + ); + } + + return blocks.map((block) => { + let cert: X509Certificate; + try { + cert = new X509Certificate(block); + } catch { + throw new Error("Invalid certificate: could not parse PEM."); + } + + return { + certificate: block.trim() + "\n", + subject: flattenDn(cert.subject), + issuer: flattenDn(cert.issuer), + serialNumber: cert.serialNumber, + fingerprint: cert.fingerprint256, + notBefore: new Date(cert.validFrom).getTime(), + notAfter: new Date(cert.validTo).getTime() + }; + }); +} diff --git a/server/private/lib/blueprints/MtlsSchema.ts b/server/private/lib/blueprints/MtlsSchema.ts new file mode 100644 index 000000000..3ad89cf21 --- /dev/null +++ b/server/private/lib/blueprints/MtlsSchema.ts @@ -0,0 +1,19 @@ +/* + * 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 { z } from "zod"; + +export const MtlsSchema = z.object({ + enabled: z.boolean().optional(), + "ca-certificates": z.array(z.string().min(1)).optional() +}); diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a98368376..4c2b87906 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -18,6 +18,7 @@ import { domains, exitNodes, loginPage, + resourceMtlsCertificates, SiteResource, targetHealthCheck } from "@server/db"; @@ -146,6 +147,8 @@ export async function getTraefikConfig( maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + mtlsEnabled: resources.mtlsEnabled, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -293,7 +296,9 @@ export async function getTraefikConfig( maintenanceModeType: row.maintenanceModeType, maintenanceTitle: row.maintenanceTitle, maintenanceMessage: row.maintenanceMessage, - maintenanceEstimatedTime: row.maintenanceEstimatedTime + maintenanceEstimatedTime: row.maintenanceEstimatedTime, + + mtlsEnabled: row.mtlsEnabled }); } @@ -317,6 +322,31 @@ export async function getTraefikConfig( }); } + // Trusted client-CA certificates for resources that require mTLS. + const mtlsResourceIds = [ + ...new Set( + [...resourcesMap.values()] + .filter((r) => r.mode === "http" && r.mtlsEnabled) + .map((r) => r.resourceId as number) + ) + ]; + const mtlsCaCertsByResource = new Map(); + if (mtlsResourceIds.length > 0) { + const caRows = await db + .select({ + resourceId: resourceMtlsCertificates.resourceId, + certificate: resourceMtlsCertificates.certificate + }) + .from(resourceMtlsCertificates) + .where(inArray(resourceMtlsCertificates.resourceId, mtlsResourceIds)) + .orderBy(resourceMtlsCertificates.mtlsCertificateId); + for (const row of caRows) { + const list = mtlsCaCertsByResource.get(row.resourceId) ?? []; + list.push(row.certificate); + mtlsCaCertsByResource.set(row.resourceId, list); + } + } + // Group browser gateway targets by resource const browserGatewayResourcesMap = browserGatewayUiUrl ? buildBrowserGatewayResourcesMap( @@ -531,7 +561,7 @@ export async function getTraefikConfig( resource.pathMatchType ); - let tls = {}; + let tls: Record = {}; if (!pangolinCertModeEnabled) { tls = buildWildcardTls({ fullDomain, @@ -553,6 +583,59 @@ export async function getTraefikConfig( } } + // mTLS: Traefik resolves TLS options per hostname, and routers on + // the same host that reference different options make it fall back + // to the defaults, so the options are keyed on the resource (not + // the per-path router key) and every TLS router below (including + // the maintenance ones) references the same name. + const mtlsCaCerts = + resource.mtlsEnabled && resource.ssl + ? mtlsCaCertsByResource.get(resource.resourceId) + : undefined; + if (mtlsCaCerts && mtlsCaCerts.length > 0) { + const mtlsOptionsName = `resource-${resource.resourceId}-mtls`; + const mtlsHeadersMiddlewareName = `resource-${resource.resourceId}-mtls-headers`; + + if (!config_output.tls) { + config_output.tls = {}; + } + if (!config_output.tls.options) { + config_output.tls.options = {}; + } + // caFiles entries are Traefik FileOrContent values: anything + // that isn't an existing file path is read as inline PEM, so + // the CA certs travel in this config to any exit node. + config_output.tls.options[mtlsOptionsName] = { + clientAuth: { + caFiles: mtlsCaCerts, + clientAuthType: "RequireAndVerifyClientCert" + } + }; + + config_output.http.middlewares[mtlsHeadersMiddlewareName] = { + passTLSClientCert: { + pem: false, + info: { + notAfter: true, + notBefore: true, + serialNumber: true, + sans: true, + subject: { + commonName: true, + organization: true + }, + issuer: { + commonName: true, + organization: true + } + } + } + }; + routerMiddlewares.unshift(mtlsHeadersMiddlewareName); + + tls = { ...tls, options: mtlsOptionsName }; + } + config_output.http.services![serviceName] = { loadBalancer: { servers: buildHttpLoadBalancerServers(targets), diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index b7487fe49..b26d6afe5 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -303,6 +303,10 @@ async function disableFeature( await disablemaintenancePage(orgId); break; + case TierFeature.Mtls: + await disableMtls(orgId); + break; + case TierFeature.DevicePosture: await disableDevicePosture(orgId); break; @@ -519,6 +523,18 @@ async function disablemaintenancePage(orgId: string): Promise { logger.info(`Disabled maintenance page on all resources for org ${orgId}`); } +// The uploaded CA certificates are kept so mTLS can be re-enabled on upgrade. +async function disableMtls(orgId: string): Promise { + await db + .update(resources) + .set({ + mtlsEnabled: false + }) + .where(eq(resources.orgId, orgId)); + + logger.info(`Disabled mTLS on all resources for org ${orgId}`); +} + async function disableDevicePosture(orgId: string): Promise {} async function disableTwoFactorEnforcement(orgId: string): Promise { diff --git a/server/routers/external.ts b/server/routers/external.ts index 33d1fea6c..432bdefb0 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -834,6 +834,28 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + "/resource/:resourceId/mtls/certificate", + verifyResourceAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); +authenticated.get( + "/resource/:resourceId/mtls/certificates", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); +authenticated.delete( + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyTargetAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index bcf5a6a32..a19661002 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -737,6 +737,39 @@ authenticated.delete( resource.deleteResourceRule ); +authenticated.put( + [ + "/resource/:resourceId/mtls/certificate", + "/public-resource/:resourceId/mtls/certificate" + ], + verifyApiKeyResourceAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createResourceMtlsCertificate), + logActionAudit(ActionsEnum.createResourceMtlsCertificate), + resource.createResourceMtlsCertificate +); + +authenticated.get( + [ + "/resource/:resourceId/mtls/certificates", + "/public-resource/:resourceId/mtls/certificates" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceMtlsCertificates), + resource.listResourceMtlsCertificates +); + +authenticated.delete( + [ + "/resource/:resourceId/mtls/certificate/:mtlsCertificateId", + "/public-resource/:resourceId/mtls/certificate/:mtlsCertificateId" + ], + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceMtlsCertificate), + logActionAudit(ActionsEnum.deleteResourceMtlsCertificate), + resource.deleteResourceMtlsCertificate +); + authenticated.get( "/target/:targetId", verifyApiKeyTargetAccess, diff --git a/server/routers/resource/createResourceMtlsCertificate.ts b/server/routers/resource/createResourceMtlsCertificate.ts new file mode 100644 index 000000000..e701dd6a3 --- /dev/null +++ b/server/routers/resource/createResourceMtlsCertificate.ts @@ -0,0 +1,239 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + MAX_MTLS_CERTIFICATES_PER_RESOURCE, + parseCaCertificates +} from "@server/lib/mtlsCertificate"; + +const createResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +const createResourceMtlsCertificateBodySchema = z.strictObject({ + name: z.string().min(1).max(255).optional(), + certificate: z.string().min(1).max(100_000) +}); + +export type CreateResourceMtlsCertificateResponse = { + certificates: { + mtlsCertificateId: number; + resourceId: number; + name: string | null; + subject: string | null; + issuer: string | null; + serialNumber: string | null; + fingerprint: string | null; + notBefore: number | null; + notAfter: number | null; + createdAt: number; + }[]; +}; + +const description = + "Add a trusted CA certificate that clients must present a certificate signed by when mTLS is enabled on a resource. Accepts a PEM bundle; each certificate in the bundle is stored separately."; + +registry.registerPath({ + method: "put", + path: "/resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +registry.registerPath({ + method: "put", + path: "/public-resource/{resourceId}/mtls/certificate", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: createResourceMtlsCertificateParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceMtlsCertificateBodySchema + } + } + } + }, + responses: {} +}); + +export async function createResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + createResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = createResourceMtlsCertificateBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const { name, certificate } = parsedBody.data; + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources" + ) + ); + } + + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + let parsedCerts; + try { + parsedCerts = parseCaCertificates(certificate); + } catch (e) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + e instanceof Error ? e.message : "Invalid certificate" + ) + ); + } + + const existing = await db + .select({ fingerprint: resourceMtlsCertificates.fingerprint }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + const existingFingerprints = new Set(existing.map((c) => c.fingerprint)); + const newCerts = parsedCerts.filter( + (c) => !existingFingerprints.has(c.fingerprint) + ); + + if (newCerts.length === 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + parsedCerts.length === 1 + ? "This CA certificate has already been added to this resource" + : "All certificates in this bundle have already been added to this resource" + ) + ); + } + + if ( + existing.length + newCerts.length > + MAX_MTLS_CERTIFICATES_PER_RESOURCE + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A resource can have at most ${MAX_MTLS_CERTIFICATES_PER_RESOURCE} trusted CA certificates` + ) + ); + } + + const now = Date.now(); + const inserted = await db + .insert(resourceMtlsCertificates) + .values( + newCerts.map((c) => ({ + resourceId, + // a label only makes sense for a single certificate + name: newCerts.length === 1 ? (name ?? null) : null, + certificate: c.certificate, + subject: c.subject, + issuer: c.issuer, + serialNumber: c.serialNumber, + fingerprint: c.fingerprint, + notBefore: c.notBefore, + notAfter: c.notAfter, + createdAt: now + })) + ) + .returning({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }); + + return response(res, { + data: { certificates: inserted }, + success: true, + error: false, + message: "CA certificate added successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/deleteResourceMtlsCertificate.ts b/server/routers/resource/deleteResourceMtlsCertificate.ts new file mode 100644 index 000000000..c4b5ed178 --- /dev/null +++ b/server/routers/resource/deleteResourceMtlsCertificate.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { and, count, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const deleteResourceMtlsCertificateParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive(), + mtlsCertificateId: z.coerce.number().int().positive() +}); + +const description = + "Remove a trusted CA certificate from a resource. If it is the last one, mTLS is disabled on the resource since it can no longer verify any client."; + +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "delete", + path: "/public-resource/{resourceId}/mtls/certificate/{mtlsCertificateId}", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: deleteResourceMtlsCertificateParamsSchema + }, + responses: {} +}); + +export type DeleteResourceMtlsCertificateResponse = { + // true when removing the last CA certificate forced mTLS off + mtlsDisabled: boolean; +}; + +export async function deleteResourceMtlsCertificate( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + deleteResourceMtlsCertificateParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId, mtlsCertificateId } = parsedParams.data; + + const [existing] = await db + .select() + .from(resourceMtlsCertificates) + .where( + and( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ), + eq(resourceMtlsCertificates.resourceId, resourceId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `CA certificate with ID ${mtlsCertificateId} not found` + ) + ); + } + + let mtlsDisabled = false; + await db.transaction(async (trx) => { + await trx + .delete(resourceMtlsCertificates) + .where( + eq( + resourceMtlsCertificates.mtlsCertificateId, + mtlsCertificateId + ) + ); + + const [{ remaining }] = await trx + .select({ remaining: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)); + + // With no CA left there is nothing to verify clients against, so + // leaving mTLS "enabled" would either lock everyone out or be + // silently ignored by the Traefik config builder. + if (remaining === 0) { + const updated = await trx + .update(resources) + .set({ mtlsEnabled: false }) + .where( + and( + eq(resources.resourceId, resourceId), + eq(resources.mtlsEnabled, true) + ) + ) + .returning({ resourceId: resources.resourceId }); + mtlsDisabled = updated.length > 0; + } + }); + + return response(res, { + data: { mtlsDisabled }, + success: true, + error: false, + message: "CA certificate removed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index a09cf0452..0dcb8099d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -21,6 +21,9 @@ export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; +export * from "./createResourceMtlsCertificate"; +export * from "./deleteResourceMtlsCertificate"; +export * from "./listResourceMtlsCertificates"; export * from "./getUserResources"; export * from "./listUserResourceAliases"; export * from "./setResourceHeaderAuth"; diff --git a/server/routers/resource/listResourceMtlsCertificates.ts b/server/routers/resource/listResourceMtlsCertificates.ts new file mode 100644 index 000000000..41ca46430 --- /dev/null +++ b/server/routers/resource/listResourceMtlsCertificates.ts @@ -0,0 +1,111 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resourceMtlsCertificates, resources } from "@server/db"; +import { asc, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listResourceMtlsCertificatesParamsSchema = z.strictObject({ + resourceId: z.coerce.number().int().positive() +}); + +function queryResourceMtlsCertificates(resourceId: number) { + return db + .select({ + mtlsCertificateId: resourceMtlsCertificates.mtlsCertificateId, + resourceId: resourceMtlsCertificates.resourceId, + name: resourceMtlsCertificates.name, + subject: resourceMtlsCertificates.subject, + issuer: resourceMtlsCertificates.issuer, + serialNumber: resourceMtlsCertificates.serialNumber, + fingerprint: resourceMtlsCertificates.fingerprint, + notBefore: resourceMtlsCertificates.notBefore, + notAfter: resourceMtlsCertificates.notAfter, + createdAt: resourceMtlsCertificates.createdAt + }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resourceId)) + .orderBy(asc(resourceMtlsCertificates.mtlsCertificateId)); +} + +export type ListResourceMtlsCertificatesResponse = { + certificates: Awaited>; +}; + +const description = "List the trusted CA certificates for a resource's mTLS."; + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResourceLegacy], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/public-resource/{resourceId}/mtls/certificates", + description, + tags: [OpenAPITags.PublicResource], + request: { + params: listResourceMtlsCertificatesParamsSchema + }, + responses: {} +}); + +export async function listResourceMtlsCertificates( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = + listResourceMtlsCertificatesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { resourceId } = parsedParams.data; + + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + const certificates = await queryResourceMtlsCertificates(resourceId); + + return response(res, { + data: { certificates }, + success: true, + error: false, + message: "CA certificates retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 22ef89115..2716d8556 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -6,6 +6,7 @@ import { loginPage, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, + resourceMtlsCertificates, resourcePassword, resourcePincode, resourceRules, @@ -24,7 +25,7 @@ import { resourcePolicies, resources } from "@server/db"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, count } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -117,6 +118,12 @@ const updateHttpResourceBodySchema = z maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), + mtlsEnabled: z + .boolean() + .optional() + .describe( + "Require clients to present a certificate signed by one of the resource's trusted CA certificates. Requires SSL and at least one CA certificate." + ), postAuthPath: z.string().nullable().optional(), // SSH settings pamMode: z.enum(["passthrough", "push"]).optional(), @@ -508,8 +515,58 @@ async function updateHttpResource( } } + const effectiveSsl = updateData.ssl ?? resource.ssl; + const effectiveMtlsEnabled = updateData.mtlsEnabled ?? resource.mtlsEnabled; + + if (updateData.mtlsEnabled === true) { + if (resource.mode !== "http") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS is only supported on HTTP resources." + ) + ); + } + + const isMtlsLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.mtls + ); + if (!isMtlsLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "mTLS is not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + + const [{ certificateCount }] = await db + .select({ certificateCount: count() }) + .from(resourceMtlsCertificates) + .where(eq(resourceMtlsCertificates.resourceId, resource.resourceId)); + + if (certificateCount === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Add at least one trusted CA certificate before enabling mTLS." + ) + ); + } + } + + if (effectiveMtlsEnabled && !effectiveSsl) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "mTLS requires SSL to be enabled for this resource. Disable mTLS before turning SSL off." + ) + ); + } + // catch when the resource policy changes or gets cleared - if (updateData.resourcePolicyId !== undefined && + if (updateData.resourcePolicyId !== undefined && resource.resourcePolicyId !== updateData.resourcePolicyId) { await clearResourceSpecificSettings( resource.resourceId, diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx index b5a36c56c..2bee1be7b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx @@ -111,6 +111,13 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { href: `/{orgId}/settings/resources/public/{niceId}/maintenance` }); } + + if (!env.flags.disableEnterpriseFeatures && resource.mode === "http") { + navItems.push({ + title: t("mtls"), + href: `/{orgId}/settings/resources/public/{niceId}/mtls` + }); + } } if (resource.mode === "inference") { diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx new file mode 100644 index 000000000..da0220e07 --- /dev/null +++ b/src/app/[orgId]/settings/resources/public/[niceId]/mtls/page.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { resourceQueries } from "@app/lib/queries"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { + CreateResourceMtlsCertificateResponse, + DeleteResourceMtlsCertificateResponse, + UpdateResourceResponse +} from "@server/routers/resource"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { AlertCircle, FileKey2, Info, ShieldCheck, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const EXPIRES_SOON_MS = 30 * DAY_MS; + +export default function ResourceMtlsPage() { + const params = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const { resource, updateResource } = useResourceContext(); + const t = useTranslations(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { isPaidUser } = usePaidStatus(); + + const supportsMtls = resource.mode === "http"; + const isMtlsDisabled = !isPaidUser(tierMatrix.mtls); + + useEffect(() => { + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + router.replace( + `/${params.orgId}/settings/resources/public/${resource.niceId}/general` + ); + } + }, [ + env.flags.disableEnterpriseFeatures, + params.orgId, + resource.niceId, + router, + supportsMtls + ]); + + const { data: certificates = [], isLoading: isLoadingCertificates } = + useQuery( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + + const [name, setName] = useState(""); + const [pem, setPem] = useState(""); + const [adding, setAdding] = useState(false); + const [removingId, setRemovingId] = useState(null); + const fileInputRef = useRef(null); + + const [mtlsEnabled, setMtlsEnabled] = useState(resource.mtlsEnabled); + const [saving, setSaving] = useState(false); + + // The server switches mTLS off when the last CA is removed. + useEffect(() => { + setMtlsEnabled(resource.mtlsEnabled); + }, [resource.mtlsEnabled]); + + if (env.flags.disableEnterpriseFeatures || !supportsMtls) { + return null; + } + + const hasCertificates = certificates.length > 0; + const canEnable = hasCertificates && resource.ssl; + + async function invalidateCertificates() { + await queryClient.invalidateQueries( + resourceQueries.resourceMtlsCertificates({ + resourceId: resource.resourceId + }) + ); + } + + async function onFileSelected(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + const text = await file.text(); + setPem((prev) => (prev.trim() ? `${prev.trim()}\n${text}` : text)); + if (!name && certificates.length === 0) { + setName(file.name.replace(/\.(pem|crt|cer|ca-bundle)$/i, "")); + } + } + + async function onAddCertificate() { + if (!pem.trim()) return; + setAdding(true); + try { + await api.put>( + `/resource/${resource.resourceId}/mtls/certificate`, + { + certificate: pem, + ...(name.trim() ? { name: name.trim() } : {}) + } + ); + setPem(""); + setName(""); + await invalidateCertificates(); + toast({ + title: t("mtlsCertificateAdded"), + description: t("mtlsCertificateAddedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorAdd"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorAddDescription") + ) + }); + } finally { + setAdding(false); + } + } + + async function onRemoveCertificate(mtlsCertificateId: number) { + setRemovingId(mtlsCertificateId); + try { + const res = await api.delete< + AxiosResponse + >( + `/resource/${resource.resourceId}/mtls/certificate/${mtlsCertificateId}` + ); + await invalidateCertificates(); + + if (res.data.data.mtlsDisabled) { + updateResource({ mtlsEnabled: false }); + toast({ + title: t("mtlsCertificateRemoved"), + description: t("mtlsLastCertificateRemoved") + }); + } else { + toast({ title: t("mtlsCertificateRemoved") }); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("mtlsCertificateErrorRemove"), + description: formatAxiosError( + e, + t("mtlsCertificateErrorRemoveDescription") + ) + }); + } finally { + setRemovingId(null); + } + } + + async function onSaveEnforcement() { + setSaving(true); + try { + await api.post>( + `/resource/${resource.resourceId}`, + { mtlsEnabled } + ); + updateResource({ mtlsEnabled }); + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + } finally { + setSaving(false); + } + } + + return ( + <> + +
+ + + + + {t("mtlsCaCertificates")} + + + {t("mtlsCaCertificatesDescription")} + + + + +
+ {isLoadingCertificates ? null : hasCertificates ? ( +
    + {certificates.map((cert) => { + const now = Date.now(); + const expired = + cert.notAfter != null && + cert.notAfter < now; + const expiresSoon = + !expired && + cert.notAfter != null && + cert.notAfter - now < + EXPIRES_SOON_MS; + return ( +
  • +
    + +
    +
    + + {cert.name || + cert.subject || + t( + "mtlsCaCertificates" + )} + + {expired && ( + + {t( + "mtlsExpired" + )} + + )} + {expiresSoon && ( + + {t( + "mtlsExpiresSoon" + )} + + )} +
    + {cert.name && + cert.subject && ( +

    + { + cert.subject + } +

    + )} + {cert.issuer && ( +

    + {t( + "mtlsIssuedBy", + { + issuer: cert.issuer + } + )} +

    + )} + {cert.notAfter != + null && ( +

    + {t( + "mtlsExpiresOn", + { + date: new Date( + cert.notAfter + ).toLocaleDateString() + } + )} +

    + )} + {cert.fingerprint && ( +

    + { + cert.fingerprint + } +

    + )} +
    +
    + +
  • + ); + })} +
+ ) : ( +

+ {t("mtlsNoCertificates")} +

+ )} + +
+
+ + + setName(e.target.value) + } + /> +
+
+ +