From 9804c0db289308003c95d4913bb2017cee66e93d Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 17:02:13 +0200 Subject: [PATCH 01/47] Ready for testing --- messages/bg-BG.json | 6 +- messages/cs-CZ.json | 6 +- messages/de-DE.json | 6 +- messages/en-US.json | 6 +- messages/es-ES.json | 6 +- messages/fr-FR.json | 6 +- messages/it-IT.json | 6 +- messages/ko-KR.json | 6 +- messages/nb-NO.json | 6 +- messages/nl-NL.json | 6 +- messages/pl-PL.json | 6 +- messages/pt-PT.json | 6 +- messages/ru-RU.json | 6 +- messages/tr-TR.json | 6 +- messages/zh-CN.json | 6 +- messages/zh-TW.json | 6 +- package-lock.json | 37 ++-------- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/lib/traefik/getTraefikConfig.ts | 51 +++++++++---- .../private/lib/traefik/getTraefikConfig.ts | 55 +++++++++----- server/routers/resource/getResource.ts | 9 ++- server/routers/resource/updateResource.ts | 71 +++++++++++-------- server/setup/migrationsPg.ts | 4 +- server/setup/migrationsSqlite.ts | 4 +- server/setup/scriptsPg/1.18.5.ts | 30 ++++++++ server/setup/scriptsSqlite/1.18.5.ts | 34 +++++++++ .../resources/proxy/[niceId]/proxy/page.tsx | 48 ++++++++++--- src/components/HealthCheckCredenza.tsx | 4 +- src/components/HealthCheckFormFields.tsx | 4 +- 30 files changed, 308 insertions(+), 145 deletions(-) create mode 100644 server/setup/scriptsPg/1.18.5.ts create mode 100644 server/setup/scriptsSqlite/1.18.5.ts diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 108229942..60f7225a9 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Опити за повторно", "expectedResponseCodes": "Очаквани кодове за отговор", "expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.", - "customHeaders": "Персонализирани заглавия", - "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", + "customRequestHeaders": "Персонализирани заглавия", + "customRequestHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.", "saveHealthCheck": "Запазване на проверка на здравето", "healthCheckSaved": "Проверка на здравето е запазена", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 7c118ff29..be171bf09 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Opakovat pokusy", "expectedResponseCodes": "Očekávané kódy odezvy", "expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.", - "customHeaders": "Vlastní záhlaví", - "customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota", + "customRequestHeaders": "Vlastní záhlaví", + "customRequestHeadersDescription": "Záhlaví oddělená nová řádka: hodnota", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Headers must be in the format: Header-Name: value.", "saveHealthCheck": "Uložit kontrolu stavu", "healthCheckSaved": "Kontrola stavu uložena", diff --git a/messages/de-DE.json b/messages/de-DE.json index 11d76dab5..d61bf24b2 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Wiederholungsversuche", "expectedResponseCodes": "Erwartete Antwortcodes", "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", - "customHeaders": "Eigene Kopfzeilen", - "customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert", + "customRequestHeaders": "Eigene Kopfzeilen", + "customRequestHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Header müssen im Format Header-Name: Wert sein.", "saveHealthCheck": "Gesundheits-Check speichern", "healthCheckSaved": "Gesundheits-Check gespeichert", diff --git a/messages/en-US.json b/messages/en-US.json index 027d9fc38..829aa0692 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1994,8 +1994,10 @@ "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", - "customHeaders": "Custom Request Headers", - "customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value", + "customRequestHeaders": "Custom Request Headers", + "customRequestHeadersDescription": "Request headers sent to the downstream targets. One per line: Header-Name: value", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Headers must be in the format: Header-Name: value", "saveHealthCheck": "Save Health Check", "healthCheckSaved": "Health Check Saved", diff --git a/messages/es-ES.json b/messages/es-ES.json index 9e5b6fc82..2b1e55381 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Intentos de Reintento", "expectedResponseCodes": "Códigos de respuesta esperados", "expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.", - "customHeaders": "Cabeceras personalizadas", - "customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor", + "customRequestHeaders": "Cabeceras personalizadas", + "customRequestHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.", "saveHealthCheck": "Guardar Chequeo de Salud", "healthCheckSaved": "Chequeo de Salud Guardado", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index da3350e46..d9a83e655 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Tentatives de réessai", "expectedResponseCodes": "Codes de réponse attendus", "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", - "customHeaders": "En-têtes personnalisés", - "customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur", + "customRequestHeaders": "En-têtes personnalisés", + "customRequestHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.", "saveHealthCheck": "Sauvegarder la vérification de l'état de santé", "healthCheckSaved": "Vérification de l'état de santé enregistrée", diff --git a/messages/it-IT.json b/messages/it-IT.json index 3ec9c0011..eac3ecde9 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Tentativi di Riprova", "expectedResponseCodes": "Codici di Risposta Attesi", "expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.", - "customHeaders": "Intestazioni Personalizzate", - "customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore", + "customRequestHeaders": "Intestazioni Personalizzate", + "customRequestHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.", "saveHealthCheck": "Salva Controllo Salute", "healthCheckSaved": "Controllo Salute Salvato", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d1bd16382..76facec3e 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1964,8 +1964,10 @@ "retryAttempts": "재시도 횟수", "expectedResponseCodes": "예상 응답 코드", "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", - "customHeaders": "사용자 정의 헤더", - "customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", + "customRequestHeaders": "사용자 정의 헤더", + "customRequestHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", "saveHealthCheck": "상태 확인 저장", "healthCheckSaved": "상태 확인이 저장되었습니다.", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index d76013d16..2aa54d52a 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Forsøk på nytt", "expectedResponseCodes": "Forventede svarkoder", "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.", - "customHeaders": "Egendefinerte topptekster", - "customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi", + "customRequestHeaders": "Egendefinerte topptekster", + "customRequestHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Topptekst må være i formatet: header-navn: verdi.", "saveHealthCheck": "Lagre Helsekontroll", "healthCheckSaved": "Helsekontroll Lagret", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index f989db342..29215eb93 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Herhaal Pogingen", "expectedResponseCodes": "Verwachte Reactiecodes", "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", - "customHeaders": "Aangepaste headers", - "customHeadersDescription": "Kopregeleinde: Header-Naam: waarde", + "customRequestHeaders": "Aangepaste headers", + "customRequestHeadersDescription": "Kopregeleinde: Header-Naam: waarde", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.", "saveHealthCheck": "Opslaan Gezondheidscontrole", "healthCheckSaved": "Gezondheidscontrole Opgeslagen", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 4d801023b..205b34ded 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Próby Ponowienia", "expectedResponseCodes": "Oczekiwane Kody Odpowiedzi", "expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.", - "customHeaders": "Niestandardowe nagłówki", - "customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość", + "customRequestHeaders": "Niestandardowe nagłówki", + "customRequestHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.", "saveHealthCheck": "Zapisz Kontrolę Zdrowia", "healthCheckSaved": "Kontrola Zdrowia Zapisana", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 0604c1caf..b3086aa1c 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Tentativas de Repetição", "expectedResponseCodes": "Códigos de Resposta Esperados", "expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.", - "customHeaders": "Cabeçalhos Personalizados", - "customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor", + "customRequestHeaders": "Cabeçalhos Personalizados", + "customRequestHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.", "saveHealthCheck": "Salvar Verificação de Saúde", "healthCheckSaved": "Verificação de Saúde Salva", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 0f3e48962..556d1d35c 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Количество попыток повторного запроса", "expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", - "customHeaders": "Пользовательские заголовки", - "customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", + "customRequestHeaders": "Пользовательские заголовки", + "customRequestHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", "saveHealthCheck": "Сохранить проверку здоровья", "healthCheckSaved": "Проверка здоровья сохранена", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index e1d965e8e..3b5a324e2 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1964,8 +1964,10 @@ "retryAttempts": "Tekrar Deneme Girişimleri", "expectedResponseCodes": "Beklenen Yanıt Kodları", "expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.", - "customHeaders": "Özel Başlıklar", - "customHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer", + "customRequestHeaders": "Özel Başlıklar", + "customRequestHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.", "saveHealthCheck": "Sağlık Kontrolünü Kaydet", "healthCheckSaved": "Sağlık Kontrolü Kaydedildi", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index a23647dba..dd01d78f8 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1964,8 +1964,10 @@ "retryAttempts": "重试次数", "expectedResponseCodes": "期望响应代码", "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", - "customHeaders": "自定义标题", - "customHeadersDescription": "头部新行分隔:头部名称:值", + "customRequestHeaders": "自定义标题", + "customRequestHeadersDescription": "头部新行分隔:头部名称:值", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "头部必须是格式:头部名称:值。", "saveHealthCheck": "保存健康检查", "healthCheckSaved": "健康检查已保存", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 532962593..e6c4a95f2 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1555,8 +1555,10 @@ "retryAttempts": "重試次數", "expectedResponseCodes": "期望響應代碼", "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", - "customHeaders": "自訂 Headers", - "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "customRequestHeaders": "自訂 Headers", + "customRequestHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "customResponseHeaders": "Custom Response Headers", + "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", "headersValidationError": "Header 必須是格式:Header 名稱:值。", "saveHealthCheck": "保存健康檢查", "healthCheckSaved": "健康檢查已保存", diff --git a/package-lock.json b/package-lock.json index 8c241554a..d06ae5554 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1058,7 +1058,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3034,7 +3033,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6981,7 +6979,6 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8442,7 +8439,6 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8558,7 +8554,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -8906,7 +8901,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9002,7 +8996,6 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -9030,7 +9023,6 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9056,7 +9048,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9067,7 +9058,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9154,7 +9144,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9228,7 +9219,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9702,7 +9692,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10152,7 +10141,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10224,7 +10212,6 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10353,7 +10340,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11260,7 +11246,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -11701,6 +11686,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "engines": { "node": ">=20" }, @@ -12335,7 +12321,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12421,7 +12406,6 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12558,7 +12542,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12952,7 +12935,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -15370,6 +15352,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15380,6 +15363,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15468,7 +15452,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.15", "@swc/helpers": "0.5.15", @@ -16428,7 +16411,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16936,7 +16918,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16968,7 +16949,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17261,7 +17241,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18723,8 +18702,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -19199,7 +19177,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19627,7 +19604,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19834,7 +19810,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 58e78735c..e8784a999 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -144,7 +144,8 @@ export const resources = pgTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "set null" }), - headers: text("headers"), // comma-separated list of headers to add to the request + requestHeaders: text("requestHeaders"), + responseHeaders: text("responseHeaders"), proxyProtocol: boolean("proxyProtocol").notNull().default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e3e83d222..54ac67969 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -161,7 +161,8 @@ export const resources = sqliteTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "set null" }), - headers: text("headers"), // comma-separated list of headers to add to the request + requestHeaders: text("requestHeaders"), + responseHeaders: text("responseHeaders"), proxyProtocol: integer("proxyProtocol", { mode: "boolean" }) .notNull() .default(false), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 7379cad7f..ee5c25ea9 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -65,7 +65,8 @@ export async function getTraefikConfig( tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, - headers: resources.headers, + requestHeaders: resources.requestHeaders, + responseHeaders: resources.responseHeaders, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, @@ -177,7 +178,8 @@ export async function getTraefikConfig( setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, targets: [], - headers: row.headers, + requestHeaders: row.requestHeaders, + responseHeaders: row.responseHeaders, proxyProtocol: row.proxyProtocol, proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path @@ -364,38 +366,59 @@ export async function getTraefikConfig( } // Handle custom headers middleware - if (resource.headers || resource.setHostHeader) { - const headersObj: { [key: string]: string } = {}; + if (resource.requestHeaders || resource.responseHeaders || resource.setHostHeader) { + const requestHeadersObj: { [key: string]: string } = {}; + const responseHeadersObj: { [key: string]: string } = {}; - if (resource.headers) { - let headersArr: { name: string; value: string }[] = []; + if (resource.requestHeaders) { + let requestHeadersArr: { name: string; value: string }[] = []; try { - headersArr = JSON.parse(resource.headers) as { + requestHeadersArr = JSON.parse(resource.requestHeaders) as { name: string; value: string; }[]; } catch (e) { logger.warn( - `Failed to parse headers for resource ${resource.resourceId}: ${e}` + `Failed to parse requestHeaders for resource ${resource.resourceId}: ${e}` ); } - - headersArr.forEach((header) => { - headersObj[header.name] = header.value; + requestHeadersArr.forEach((header) => { + requestHeadersObj[header.name] = header.value; }); } if (resource.setHostHeader) { - headersObj["Host"] = resource.setHostHeader; + requestHeadersObj["Host"] = resource.setHostHeader; } - if (Object.keys(headersObj).length > 0) { + if (resource.responseHeaders) { + let responseHeadersArr: { name: string; value: string }[] = []; + try { + responseHeadersArr = JSON.parse(resource.responseHeaders) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse responseHeaders for resource ${resource.resourceId}: ${e}` + ); + } + responseHeadersArr.forEach((header) => { + responseHeadersObj[header.name] = header.value; + }); + } + + const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0; + const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0; + + if (hasRequestHeaders || hasResponseHeaders) { if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[headersMiddlewareName] = { headers: { - customRequestHeaders: headersObj + ...(hasRequestHeaders && { customRequestHeaders: requestHeadersObj }), + ...(hasResponseHeaders && { customResponseHeaders: responseHeadersObj }) } }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 481192fb5..e35544dc9 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -105,7 +105,8 @@ export async function getTraefikConfig( tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, - headers: resources.headers, + requestHeaders: resources.requestHeaders, + responseHeaders: resources.responseHeaders, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, wildcard: resources.wildcard, @@ -237,7 +238,8 @@ export async function getTraefikConfig( setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, targets: [], - headers: row.headers, + requestHeaders: row.requestHeaders, + responseHeaders: row.responseHeaders, proxyProtocol: row.proxyProtocol, proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path @@ -648,40 +650,59 @@ export async function getTraefikConfig( } } - if (resource.headers || resource.setHostHeader) { - // if there are headers, parse them into an object - const headersObj: { [key: string]: string } = {}; - if (resource.headers) { - let headersArr: { name: string; value: string }[] = []; + if (resource.requestHeaders || resource.responseHeaders || resource.setHostHeader) { + const requestHeadersObj: { [key: string]: string } = {}; + const responseHeadersObj: { [key: string]: string } = {}; + + if (resource.requestHeaders) { + let requestHeadersArr: { name: string; value: string }[] = []; try { - headersArr = JSON.parse(resource.headers) as { + requestHeadersArr = JSON.parse(resource.requestHeaders) as { name: string; value: string; }[]; } catch (e) { logger.warn( - `Failed to parse headers for resource ${resource.resourceId}: ${e}` + `Failed to parse requestHeaders for resource ${resource.resourceId}: ${e}` ); } - - headersArr.forEach((header) => { - headersObj[header.name] = header.value; + requestHeadersArr.forEach((header) => { + requestHeadersObj[header.name] = header.value; }); } if (resource.setHostHeader) { - headersObj["Host"] = resource.setHostHeader; + requestHeadersObj["Host"] = resource.setHostHeader; } - // check if the object is not empty - if (Object.keys(headersObj).length > 0) { - // Add the headers middleware + if (resource.responseHeaders) { + let responseHeadersArr: { name: string; value: string }[] = []; + try { + responseHeadersArr = JSON.parse(resource.responseHeaders) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse responseHeaders for resource ${resource.resourceId}: ${e}` + ); + } + responseHeadersArr.forEach((header) => { + responseHeadersObj[header.name] = header.value; + }); + } + + const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0; + const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0; + + if (hasRequestHeaders || hasResponseHeaders) { if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[headersMiddlewareName] = { headers: { - customRequestHeaders: headersObj + ...(hasRequestHeaders && { customRequestHeaders: requestHeadersObj }), + ...(hasResponseHeaders && { customResponseHeaders: responseHeadersObj }) } }; diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 7a52c0a85..bbd58320c 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -105,9 +105,12 @@ export async function getResource( return response(res, { data: { ...resource, - headers: resource.headers - ? JSON.parse(resource.headers) - : resource.headers + requestHeaders: resource.requestHeaders + ? JSON.parse(resource.requestHeaders) + : resource.requestHeaders, + responseHeaders: resource.responseHeaders + ? JSON.parse(resource.responseHeaders) + : resource.responseHeaders }, success: true, error: false, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 0a7052dce..fc8416282 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -58,7 +58,11 @@ const updateHttpResourceBodySchema = z tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.int().positive().nullable().optional(), - headers: z + requestHeaders: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + responseHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() .optional(), @@ -111,12 +115,12 @@ const updateHttpResourceBodySchema = z ) .refine( (data) => { - if (data.headers) { - // HTTP header names must be valid token characters (RFC 7230) - const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; - return data.headers.every((h) => validHeaderName.test(h.name)); - } - return true; + const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; + const allHeaders = [ + ...(data.requestHeaders ?? []), + ...(data.responseHeaders ?? []) + ]; + return allHeaders.every((h) => validHeaderName.test(h.name)); }, { error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)." @@ -124,14 +128,12 @@ const updateHttpResourceBodySchema = z ) .refine( (data) => { - if (data.headers) { - // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) - const validHeaderValue = /^[\t\x20-\x7E]*$/; - return data.headers.every((h) => - validHeaderValue.test(h.value) - ); - } - return true; + const validHeaderValue = /^[\t\x20-\x7E]*$/; + const allHeaders = [ + ...(data.requestHeaders ?? []), + ...(data.responseHeaders ?? []) + ]; + return allHeaders.every((h) => validHeaderValue.test(h.value)); }, { error: "Header values may only contain printable ASCII characters and horizontal whitespace." @@ -139,16 +141,16 @@ const updateHttpResourceBodySchema = z ) .refine( (data) => { - if (data.headers) { - // Reject Traefik template syntax {{word}} in names or values - const templatePattern = /\{\{[^}]+\}\}/; - return data.headers.every( - (h) => - !templatePattern.test(h.name) && - !templatePattern.test(h.value) - ); - } - return true; + const templatePattern = /\{\{[^}]+\}\}/; + const allHeaders = [ + ...(data.requestHeaders ?? []), + ...(data.responseHeaders ?? []) + ]; + return allHeaders.every( + (h) => + !templatePattern.test(h.name) && + !templatePattern.test(h.value) + ); }, { error: "Header names and values must not contain template expressions such as {{value}}." @@ -467,11 +469,18 @@ async function updateHttpResource( } } - let headers = undefined; - if (updateData.headers) { - headers = JSON.stringify(updateData.headers); - } else if (updateData.headers === null) { - headers = null; + let requestHeaders = undefined; + if (updateData.requestHeaders) { + requestHeaders = JSON.stringify(updateData.requestHeaders); + } else if (updateData.requestHeaders === null) { + requestHeaders = null; + } + + let responseHeaders = undefined; + if (updateData.responseHeaders) { + responseHeaders = JSON.stringify(updateData.responseHeaders); + } else if (updateData.responseHeaders === null) { + responseHeaders = null; } const isLicensed = await isLicensedOrSubscribed( @@ -488,7 +497,7 @@ async function updateHttpResource( const updatedResource = await db .update(resources) - .set({ ...updateData, headers }) + .set({ ...updateData, requestHeaders, responseHeaders }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 0b8af06bc..1a7d530c4 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -25,6 +25,7 @@ import m16 from "./scriptsPg/1.17.0"; import m17 from "./scriptsPg/1.18.0"; import m18 from "./scriptsPg/1.18.3"; import m19 from "./scriptsPg/1.18.4"; +import m20 from "./scriptsPg/1.18.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -49,7 +50,8 @@ const migrations = [ { version: "1.17.0", run: m16 }, { version: "1.18.0", run: m17 }, { version: "1.18.3", run: m18 }, - { version: "1.18.4", run: m19 } + { version: "1.18.4", run: m19 }, + { version: "1.18.5", run: m20 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 837b039f7..75f414c75 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -43,6 +43,7 @@ import m37 from "./scriptsSqlite/1.17.0"; import m38 from "./scriptsSqlite/1.18.0"; import m39 from "./scriptsSqlite/1.18.3"; import m40 from "./scriptsSqlite/1.18.4"; +import m41 from "./scriptsSqlite/1.18.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -83,7 +84,8 @@ const migrations = [ { version: "1.17.0", run: m37 }, { version: "1.18.0", run: m38 }, { version: "1.18.3", run: m39 }, - { version: "1.18.4", run: m40 } + { version: "1.18.4", run: m40 }, + { version: "1.18.5", run: m41 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.5.ts b/server/setup/scriptsPg/1.18.5.ts new file mode 100644 index 000000000..5bceb41cf --- /dev/null +++ b/server/setup/scriptsPg/1.18.5.ts @@ -0,0 +1,30 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.18.5"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + ALTER TABLE "resources" RENAME COLUMN "headers" TO "requestHeaders"; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD COLUMN "responseHeaders" text; + `); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.18.5.ts b/server/setup/scriptsSqlite/1.18.5.ts new file mode 100644 index 000000000..2edd523f0 --- /dev/null +++ b/server/setup/scriptsSqlite/1.18.5.ts @@ -0,0 +1,34 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.18.5"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + `ALTER TABLE 'resources' RENAME COLUMN 'headers' TO 'requestHeaders';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' ADD 'responseHeaders' text;` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log("Migrated database"); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 823c0f957..fd491354b 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -1097,7 +1097,10 @@ function ProxyResourceHttpForm({ message: t("proxyErrorInvalidHeader") } ), - headers: z + requestHeaders: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + responseHeaders: z .array(z.object({ name: z.string(), value: z.string() })) .nullable(), proxyProtocol: z.boolean().optional(), @@ -1108,7 +1111,8 @@ function ProxyResourceHttpForm({ resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers, + requestHeaders: resource.requestHeaders, + responseHeaders: resource.responseHeaders, proxyProtocol: resource.proxyProtocol || false, proxyProtocolVersion: resource.proxyProtocolVersion || 1 } @@ -1148,7 +1152,8 @@ function ProxyResourceHttpForm({ ssl: tlsData.ssl, tlsServerName: tlsData.tlsServerName || null, setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null + requestHeaders: proxyData.requestHeaders || null, + responseHeaders: proxyData.responseHeaders || null }; // Single API call to update all settings @@ -1161,7 +1166,8 @@ function ProxyResourceHttpForm({ ssl: tlsData.ssl, tlsServerName: tlsData.tlsServerName || null, setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null + requestHeaders: proxyData.requestHeaders || null, + responseHeaders: proxyData.responseHeaders || null }); toast({ @@ -1307,11 +1313,11 @@ function ProxyResourceHttpForm({ /> ( - {t("customHeaders")} + {t("customRequestHeaders")} - {t("customHeadersDescription")} + {t("customRequestHeadersDescription")} + + + + )} + /> + ( + + + {t("customResponseHeaders")} + + + { + field.onChange(value); + }} + rows={4} + /> + + + {t("customResponseHeadersDescription")} @@ -1369,7 +1399,7 @@ function ProxyResourceProtocolForm({ message: t("proxyErrorInvalidHeader") } ), - headers: z + requestHeaders: z .array(z.object({ name: z.string(), value: z.string() })) .nullable(), proxyProtocol: z.boolean().optional(), @@ -1380,7 +1410,7 @@ function ProxyResourceProtocolForm({ resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers, + requestHeaders: resource.requestHeaders, proxyProtocol: resource.proxyProtocol || false, proxyProtocolVersion: resource.proxyProtocolVersion || 1 } diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index 0360a15e7..2bd7a27b1 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -1317,7 +1317,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { {t( - "customHeaders" + "customRequestHeaders" )} @@ -1341,7 +1341,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { {t( - "customHeadersDescription" + "customRequestHeadersDescription" )} diff --git a/src/components/HealthCheckFormFields.tsx b/src/components/HealthCheckFormFields.tsx index 6f5d528db..5b43e8a59 100644 --- a/src/components/HealthCheckFormFields.tsx +++ b/src/components/HealthCheckFormFields.tsx @@ -733,7 +733,7 @@ export function HealthCheckFormFields({ render={({ field }) => ( - {t("customHeaders")} + {t("customRequestHeaders")} {t( - "customHeadersDescription" + "customRequestHeadersDescription" )} From c8357e8653d825c266e41d6a6094018713943daa Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:03:58 +0200 Subject: [PATCH 02/47] tested and translated! --- messages/bg-BG.json | 8 ++++---- messages/cs-CZ.json | 8 ++++---- messages/de-DE.json | 8 ++++---- messages/es-ES.json | 8 ++++---- messages/fr-FR.json | 8 ++++---- messages/it-IT.json | 8 ++++---- messages/ko-KR.json | 8 ++++---- messages/nb-NO.json | 8 ++++---- messages/nl-NL.json | 8 ++++---- messages/pl-PL.json | 8 ++++---- messages/pt-PT.json | 8 ++++---- messages/ru-RU.json | 8 ++++---- messages/tr-TR.json | 8 ++++---- messages/zh-CN.json | 8 ++++---- messages/zh-TW.json | 8 ++++---- 15 files changed, 60 insertions(+), 60 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 60f7225a9..9b6dd6986 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Опити за повторно", "expectedResponseCodes": "Очаквани кодове за отговор", "expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.", - "customRequestHeaders": "Персонализирани заглавия", - "customRequestHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Персонализирани заглавия на заявката", + "customRequestHeadersDescription": "Заглавия на заявката, изпратени до целевите сървъри. По едно на ред: Заглавие-Име: стойност", + "customResponseHeaders": "Персонализирани заглавия на отговора", + "customResponseHeadersDescription": "Заглавия на отговора, върнати на клиента. По едно на ред: Заглавие-Име: стойност", "headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.", "saveHealthCheck": "Запазване на проверка на здравето", "healthCheckSaved": "Проверка на здравето е запазена", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index be171bf09..0effc0c83 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Opakovat pokusy", "expectedResponseCodes": "Očekávané kódy odezvy", "expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.", - "customRequestHeaders": "Vlastní záhlaví", - "customRequestHeadersDescription": "Záhlaví oddělená nová řádka: hodnota", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Vlastní záhlaví požadavku", + "customRequestHeadersDescription": "Záhlaví požadavku odeslaná do cílů. Jedno na řádek: Název-záhlaví: hodnota", + "customResponseHeaders": "Vlastní záhlaví odpovědi", + "customResponseHeadersDescription": "Záhlaví odpovědi vrácená klientovi. Jedno na řádek: Název-záhlaví: hodnota", "headersValidationError": "Headers must be in the format: Header-Name: value.", "saveHealthCheck": "Uložit kontrolu stavu", "healthCheckSaved": "Kontrola stavu uložena", diff --git a/messages/de-DE.json b/messages/de-DE.json index d61bf24b2..c009d2fd9 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Wiederholungsversuche", "expectedResponseCodes": "Erwartete Antwortcodes", "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", - "customRequestHeaders": "Eigene Kopfzeilen", - "customRequestHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Eigene Anfrage-Header", + "customRequestHeadersDescription": "Anfrage-Header, die an die Ziele gesendet werden. Eine pro Zeile: Header-Name: Wert", + "customResponseHeaders": "Eigene Antwort-Header", + "customResponseHeadersDescription": "Antwort-Header, die an den Client zurückgesendet werden. Eine pro Zeile: Header-Name: Wert", "headersValidationError": "Header müssen im Format Header-Name: Wert sein.", "saveHealthCheck": "Gesundheits-Check speichern", "healthCheckSaved": "Gesundheits-Check gespeichert", diff --git a/messages/es-ES.json b/messages/es-ES.json index 2b1e55381..08ba73040 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Intentos de Reintento", "expectedResponseCodes": "Códigos de respuesta esperados", "expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.", - "customRequestHeaders": "Cabeceras personalizadas", - "customRequestHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Cabeceras de solicitud personalizadas", + "customRequestHeadersDescription": "Cabeceras de solicitud enviadas a los destinos. Una por línea: Nombre-Cabecera: valor", + "customResponseHeaders": "Cabeceras de respuesta personalizadas", + "customResponseHeadersDescription": "Cabeceras de respuesta devueltas al cliente. Una por línea: Nombre-Cabecera: valor", "headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.", "saveHealthCheck": "Guardar Chequeo de Salud", "healthCheckSaved": "Chequeo de Salud Guardado", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index d9a83e655..194e798ab 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Tentatives de réessai", "expectedResponseCodes": "Codes de réponse attendus", "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", - "customRequestHeaders": "En-têtes personnalisés", - "customRequestHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "En-têtes de requête personnalisés", + "customRequestHeadersDescription": "En-têtes de requête envoyés aux cibles. Un par ligne : Nom-En-tête : valeur", + "customResponseHeaders": "En-têtes de réponse personnalisés", + "customResponseHeadersDescription": "En-têtes de réponse renvoyés au client. Un par ligne : Nom-En-tête : valeur", "headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.", "saveHealthCheck": "Sauvegarder la vérification de l'état de santé", "healthCheckSaved": "Vérification de l'état de santé enregistrée", diff --git a/messages/it-IT.json b/messages/it-IT.json index eac3ecde9..7e8cd68d7 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Tentativi di Riprova", "expectedResponseCodes": "Codici di Risposta Attesi", "expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.", - "customRequestHeaders": "Intestazioni Personalizzate", - "customRequestHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Intestazioni di richiesta personalizzate", + "customRequestHeadersDescription": "Intestazioni di richiesta inviate ai target. Una per riga: Nome-Intestazione: valore", + "customResponseHeaders": "Intestazioni di risposta personalizzate", + "customResponseHeadersDescription": "Intestazioni di risposta restituite al client. Una per riga: Nome-Intestazione: valore", "headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.", "saveHealthCheck": "Salva Controllo Salute", "healthCheckSaved": "Controllo Salute Salvato", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 76facec3e..1f45379a6 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1964,10 +1964,10 @@ "retryAttempts": "재시도 횟수", "expectedResponseCodes": "예상 응답 코드", "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", - "customRequestHeaders": "사용자 정의 헤더", - "customRequestHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "사용자 정의 요청 헤더", + "customRequestHeadersDescription": "다운스트림 대상으로 전송되는 요청 헤더. 한 줄에 하나씩: 헤더-이름: 값", + "customResponseHeaders": "사용자 정의 응답 헤더", + "customResponseHeadersDescription": "클라이언트로 반환되는 응답 헤더. 한 줄에 하나씩: 헤더-이름: 값", "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", "saveHealthCheck": "상태 확인 저장", "healthCheckSaved": "상태 확인이 저장되었습니다.", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 2aa54d52a..0fd21f858 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Forsøk på nytt", "expectedResponseCodes": "Forventede svarkoder", "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.", - "customRequestHeaders": "Egendefinerte topptekster", - "customRequestHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Egendefinerte forespørselshoder", + "customRequestHeadersDescription": "Forespørselshoder sendt til målene. Én per linje: Header-Navn: verdi", + "customResponseHeaders": "Egendefinerte svarhoder", + "customResponseHeadersDescription": "Svarhoder sendt tilbake til klienten. Én per linje: Header-Navn: verdi", "headersValidationError": "Topptekst må være i formatet: header-navn: verdi.", "saveHealthCheck": "Lagre Helsekontroll", "healthCheckSaved": "Helsekontroll Lagret", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 29215eb93..fc1e96aba 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Herhaal Pogingen", "expectedResponseCodes": "Verwachte Reactiecodes", "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", - "customRequestHeaders": "Aangepaste headers", - "customRequestHeadersDescription": "Kopregeleinde: Header-Naam: waarde", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Aangepaste verzoekheaders", + "customRequestHeadersDescription": "Verzoekheaders die worden doorgestuurd naar het doel. Per regel één: Header-Naam: waarde", + "customResponseHeaders": "Aangepaste antwoordheaders", + "customResponseHeadersDescription": "Antwoordheaders die worden teruggestuurd naar de client. Per regel één: Header-Naam: waarde", "headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.", "saveHealthCheck": "Opslaan Gezondheidscontrole", "healthCheckSaved": "Gezondheidscontrole Opgeslagen", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 205b34ded..ebb8f0c28 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Próby Ponowienia", "expectedResponseCodes": "Oczekiwane Kody Odpowiedzi", "expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.", - "customRequestHeaders": "Niestandardowe nagłówki", - "customRequestHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Niestandardowe nagłówki żądania", + "customRequestHeadersDescription": "Nagłówki żądania wysyłane do celów. Jeden w wierszu: Nazwa-Nagłówka: wartość", + "customResponseHeaders": "Niestandardowe nagłówki odpowiedzi", + "customResponseHeadersDescription": "Nagłówki odpowiedzi zwracane do klienta. Jeden w wierszu: Nazwa-Nagłówka: wartość", "headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.", "saveHealthCheck": "Zapisz Kontrolę Zdrowia", "healthCheckSaved": "Kontrola Zdrowia Zapisana", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index b3086aa1c..697280c64 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Tentativas de Repetição", "expectedResponseCodes": "Códigos de Resposta Esperados", "expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.", - "customRequestHeaders": "Cabeçalhos Personalizados", - "customRequestHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Cabeçalhos de Pedido Personalizados", + "customRequestHeadersDescription": "Cabeçalhos de pedido enviados para os destinos. Um por linha: Nome-Cabeçalho: valor", + "customResponseHeaders": "Cabeçalhos de Resposta Personalizados", + "customResponseHeadersDescription": "Cabeçalhos de resposta enviados de volta ao cliente. Um por linha: Nome-Cabeçalho: valor", "headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.", "saveHealthCheck": "Salvar Verificação de Saúde", "healthCheckSaved": "Verificação de Saúde Salva", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 556d1d35c..068762903 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Количество попыток повторного запроса", "expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", - "customRequestHeaders": "Пользовательские заголовки", - "customRequestHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Пользовательские заголовки запроса", + "customRequestHeadersDescription": "Заголовки запроса, отправляемые целевым серверам. По одному в строке: Имя-Заголовка: значение", + "customResponseHeaders": "Пользовательские заголовки ответа", + "customResponseHeadersDescription": "Заголовки ответа, возвращаемые клиенту. По одному в строке: Имя-Заголовка: значение", "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", "saveHealthCheck": "Сохранить проверку здоровья", "healthCheckSaved": "Проверка здоровья сохранена", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 3b5a324e2..00cc9f4b8 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1964,10 +1964,10 @@ "retryAttempts": "Tekrar Deneme Girişimleri", "expectedResponseCodes": "Beklenen Yanıt Kodları", "expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.", - "customRequestHeaders": "Özel Başlıklar", - "customRequestHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "Özel İstek Başlıkları", + "customRequestHeadersDescription": "Hedeflere gönderilen istek başlıkları. Satır başına bir tane: Başlık-Adı: değer", + "customResponseHeaders": "Özel Yanıt Başlıkları", + "customResponseHeadersDescription": "İstemciye geri gönderilen yanıt başlıkları. Satır başına bir tane: Başlık-Adı: değer", "headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.", "saveHealthCheck": "Sağlık Kontrolünü Kaydet", "healthCheckSaved": "Sağlık Kontrolü Kaydedildi", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index dd01d78f8..95b881ac3 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1964,10 +1964,10 @@ "retryAttempts": "重试次数", "expectedResponseCodes": "期望响应代码", "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", - "customRequestHeaders": "自定义标题", - "customRequestHeadersDescription": "头部新行分隔:头部名称:值", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "自定义请求标头", + "customRequestHeadersDescription": "转发到目标的请求标头。每行一个:标头名称:值", + "customResponseHeaders": "自定义响应标头", + "customResponseHeadersDescription": "返回给客户端的响应标头。每行一个:标头名称:值", "headersValidationError": "头部必须是格式:头部名称:值。", "saveHealthCheck": "保存健康检查", "healthCheckSaved": "健康检查已保存", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index e6c4a95f2..47d803b5d 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1555,10 +1555,10 @@ "retryAttempts": "重試次數", "expectedResponseCodes": "期望響應代碼", "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", - "customRequestHeaders": "自訂 Headers", - "customRequestHeadersDescription": "Header 斷行分隔:Header 名稱:值", - "customResponseHeaders": "Custom Response Headers", - "customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value", + "customRequestHeaders": "自訂請求 Headers", + "customRequestHeadersDescription": "轉發至目標的請求標頭。每行一個:Header-名稱:值", + "customResponseHeaders": "自訂回應 Headers", + "customResponseHeadersDescription": "回傳給客戶端的回應標頭。每行一個:Header-名稱:值", "headersValidationError": "Header 必須是格式:Header 名稱:值。", "saveHealthCheck": "保存健康檢查", "healthCheckSaved": "健康檢查已保存", From 6c7d345f033ca670f4d80806dfce573c4d5856dd Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:05:43 +0200 Subject: [PATCH 03/47] Reverted package-lock.json --- package-lock.json | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index d06ae5554..8c241554a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1058,6 +1058,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3033,6 +3034,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6979,6 +6981,7 @@ "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.0.0" }, @@ -8439,6 +8442,7 @@ "version": "5.90.21", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.20" }, @@ -8554,6 +8558,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8901,6 +8906,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8996,6 +9002,7 @@ "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -9023,6 +9030,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9048,6 +9056,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9058,6 +9067,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9144,8 +9154,7 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -9219,6 +9228,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -9692,6 +9702,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10141,6 +10152,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -10212,6 +10224,7 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10340,6 +10353,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11246,6 +11260,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -11686,7 +11701,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "engines": { "node": ">=20" }, @@ -12321,6 +12335,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12406,6 +12421,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -12542,6 +12558,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12935,6 +12952,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -15352,7 +15370,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -15363,7 +15380,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15452,6 +15468,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz", "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.15", "@swc/helpers": "0.5.15", @@ -16411,6 +16428,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -16918,6 +16936,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16949,6 +16968,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -17241,6 +17261,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18702,7 +18723,8 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -19177,6 +19199,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -19604,6 +19627,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -19810,6 +19834,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 9d6062619c2dd1e2dc438a4377e40b91378b00fc Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:21:52 +0200 Subject: [PATCH 04/47] Forgot about blueprints --- server/lib/blueprints/proxyResources.ts | 18 ++++++++++++++---- server/lib/blueprints/types.ts | 4 +++- server/routers/resource/getResource.ts | 5 +++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 178991962..6fac02122 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -210,8 +210,16 @@ export async function updateProxyResources( ? true : resourceData.ssl; let headers = ""; - if (resourceData.headers) { - headers = JSON.stringify(resourceData.headers); + const requestHeadersData = [ + ...(resourceData.headers ?? []), + ...(resourceData.requestHeaders ?? []) + ]; + if (requestHeadersData.length > 0) { + headers = JSON.stringify(requestHeadersData); + } + let responseHeaders = ""; + if (resourceData.responseHeaders) { + responseHeaders = JSON.stringify(resourceData.responseHeaders); } if (existingResource) { @@ -265,7 +273,8 @@ export async function updateProxyResources( ] ? resourceData.auth["whitelist-users"].length > 0 : false, - headers: headers || null, + requestHeaders: headers || null, + responseHeaders: responseHeaders || null, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: @@ -724,7 +733,8 @@ export async function updateProxyResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, - headers: headers || null, + requestHeaders: headers || null, + responseHeaders: responseHeaders || null, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: resourceData.maintenance?.enabled, diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 13f4caa8f..2bff77d10 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -175,7 +175,9 @@ export const ResourceSchema = z auth: AuthSchema.optional(), "host-header": z.string().optional(), "tls-server-name": z.string().optional(), - headers: z.array(HeaderSchema).optional(), + headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders + requestHeaders: z.array(HeaderSchema).optional(), + responseHeaders: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional() }) diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index bbd58320c..d63012f18 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -43,9 +43,10 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) { export type GetResourceResponse = Omit< NonNullable>>, - "headers" + "requestHeaders" | "responseHeaders" > & { - headers: { name: string; value: string }[] | null; + requestHeaders: { name: string; value: string }[] | null; + responseHeaders: { name: string; value: string }[] | null; }; registry.registerPath({ From 382f5d52184dcb7b712d15427b8cd87a4b625eef Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:36:33 +0200 Subject: [PATCH 05/47] Fixed copilot issue. --- server/lib/blueprints/proxyResources.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 6fac02122..f0194c650 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -209,18 +209,17 @@ export async function updateProxyResources( resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; - let headers = ""; const requestHeadersData = [ ...(resourceData.headers ?? []), ...(resourceData.requestHeaders ?? []) ]; - if (requestHeadersData.length > 0) { - headers = JSON.stringify(requestHeadersData); - } - let responseHeaders = ""; - if (resourceData.responseHeaders) { - responseHeaders = JSON.stringify(resourceData.responseHeaders); - } + const requestHeadersJson = + requestHeadersData.length > 0 + ? JSON.stringify(requestHeadersData) + : null; + const responseHeadersJson = resourceData.responseHeaders + ? JSON.stringify(resourceData.responseHeaders) + : null; if (existingResource) { let domain; @@ -273,8 +272,8 @@ export async function updateProxyResources( ] ? resourceData.auth["whitelist-users"].length > 0 : false, - requestHeaders: headers || null, - responseHeaders: responseHeaders || null, + requestHeaders: requestHeadersJson, + responseHeaders: responseHeadersJson, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: @@ -733,8 +732,8 @@ export async function updateProxyResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, - requestHeaders: headers || null, - responseHeaders: responseHeaders || null, + requestHeaders: requestHeadersJson, + responseHeaders: responseHeadersJson, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: resourceData.maintenance?.enabled, From 8dee505eb25ec517f4515df4d5f2da7d590bfbf4 Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:38:56 +0200 Subject: [PATCH 06/47] Fixed copilot issue. --- server/setup/scriptsSqlite/1.18.5.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/server/setup/scriptsSqlite/1.18.5.ts b/server/setup/scriptsSqlite/1.18.5.ts index 2edd523f0..d72d801a8 100644 --- a/server/setup/scriptsSqlite/1.18.5.ts +++ b/server/setup/scriptsSqlite/1.18.5.ts @@ -15,19 +15,20 @@ export default async function migration() { db.transaction(() => { db.prepare( - `ALTER TABLE 'resources' RENAME COLUMN 'headers' TO 'requestHeaders';` + `ALTER TABLE "resources" RENAME COLUMN "headers" TO "requestHeaders";` ).run(); db.prepare( - `ALTER TABLE 'resources' ADD 'responseHeaders' text;` + `ALTER TABLE "resources" ADD "responseHeaders" text;` ).run(); })(); - db.pragma("foreign_keys = ON"); - console.log("Migrated database"); } catch (e) { console.log("Failed to migrate db:", e); throw e; + } finally { + db.pragma("foreign_keys = ON"); + db.close(); } console.log(`${version} migration complete`); From 6952d3bee7cde9409d5a51fd68960bc17cca5a5d Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 18:42:00 +0200 Subject: [PATCH 07/47] Fixed copilot issue. --- server/routers/resource/updateResource.ts | 28 +++++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index fc8416282..97513dcb1 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -24,7 +24,10 @@ import { import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; +import { + validateAndConstructDomain, + checkWildcardDomainConflict +} from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -58,6 +61,10 @@ const updateHttpResourceBodySchema = z tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.int().positive().nullable().optional(), + headers: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), // deprecated alias for requestHeaders requestHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() @@ -117,6 +124,7 @@ const updateHttpResourceBodySchema = z (data) => { const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; const allHeaders = [ + ...(data.headers ?? []), ...(data.requestHeaders ?? []), ...(data.responseHeaders ?? []) ]; @@ -130,6 +138,7 @@ const updateHttpResourceBodySchema = z (data) => { const validHeaderValue = /^[\t\x20-\x7E]*$/; const allHeaders = [ + ...(data.headers ?? []), ...(data.requestHeaders ?? []), ...(data.responseHeaders ?? []) ]; @@ -143,6 +152,7 @@ const updateHttpResourceBodySchema = z (data) => { const templatePattern = /\{\{[^}]+\}\}/; const allHeaders = [ + ...(data.headers ?? []), ...(data.requestHeaders ?? []), ...(data.responseHeaders ?? []) ]; @@ -470,10 +480,18 @@ async function updateHttpResource( } let requestHeaders = undefined; - if (updateData.requestHeaders) { - requestHeaders = JSON.stringify(updateData.requestHeaders); - } else if (updateData.requestHeaders === null) { - requestHeaders = null; + const mergedRequestHeaders = [ + ...(updateData.headers ?? []), + ...(updateData.requestHeaders ?? []) + ]; + if ( + updateData.headers !== undefined || + updateData.requestHeaders !== undefined + ) { + requestHeaders = + mergedRequestHeaders.length > 0 + ? JSON.stringify(mergedRequestHeaders) + : null; } let responseHeaders = undefined; From 713e24503db0562f81eada4e0062466d061a440e Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Wed, 27 May 2026 20:33:10 +0200 Subject: [PATCH 08/47] Removed migrations --- server/setup/migrationsPg.ts | 4 +--- server/setup/migrationsSqlite.ts | 4 +--- server/setup/scriptsPg/1.18.5.ts | 30 ------------------------ server/setup/scriptsSqlite/1.18.5.ts | 35 ---------------------------- 4 files changed, 2 insertions(+), 71 deletions(-) delete mode 100644 server/setup/scriptsPg/1.18.5.ts delete mode 100644 server/setup/scriptsSqlite/1.18.5.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1a7d530c4..0b8af06bc 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -25,7 +25,6 @@ import m16 from "./scriptsPg/1.17.0"; import m17 from "./scriptsPg/1.18.0"; import m18 from "./scriptsPg/1.18.3"; import m19 from "./scriptsPg/1.18.4"; -import m20 from "./scriptsPg/1.18.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -50,8 +49,7 @@ const migrations = [ { version: "1.17.0", run: m16 }, { version: "1.18.0", run: m17 }, { version: "1.18.3", run: m18 }, - { version: "1.18.4", run: m19 }, - { version: "1.18.5", run: m20 } + { version: "1.18.4", run: m19 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 75f414c75..837b039f7 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -43,7 +43,6 @@ import m37 from "./scriptsSqlite/1.17.0"; import m38 from "./scriptsSqlite/1.18.0"; import m39 from "./scriptsSqlite/1.18.3"; import m40 from "./scriptsSqlite/1.18.4"; -import m41 from "./scriptsSqlite/1.18.5"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -84,8 +83,7 @@ const migrations = [ { version: "1.17.0", run: m37 }, { version: "1.18.0", run: m38 }, { version: "1.18.3", run: m39 }, - { version: "1.18.4", run: m40 }, - { version: "1.18.5", run: m41 } + { version: "1.18.4", run: m40 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.18.5.ts b/server/setup/scriptsPg/1.18.5.ts deleted file mode 100644 index 5bceb41cf..000000000 --- a/server/setup/scriptsPg/1.18.5.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { db } from "@server/db/pg/driver"; -import { sql } from "drizzle-orm"; - -const version = "1.18.5"; - -export default async function migration() { - console.log(`Running setup script ${version}...`); - - try { - await db.execute(sql`BEGIN`); - - await db.execute(sql` - ALTER TABLE "resources" RENAME COLUMN "headers" TO "requestHeaders"; - `); - - await db.execute(sql` - ALTER TABLE "resources" ADD COLUMN "responseHeaders" text; - `); - - await db.execute(sql`COMMIT`); - console.log("Migrated database"); - } catch (e) { - await db.execute(sql`ROLLBACK`); - console.log("Unable to migrate database"); - console.log(e); - throw e; - } - - console.log(`${version} migration complete`); -} diff --git a/server/setup/scriptsSqlite/1.18.5.ts b/server/setup/scriptsSqlite/1.18.5.ts deleted file mode 100644 index d72d801a8..000000000 --- a/server/setup/scriptsSqlite/1.18.5.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { APP_PATH } from "@server/lib/consts"; -import Database from "better-sqlite3"; -import path from "path"; - -const version = "1.18.5"; - -export default async function migration() { - console.log(`Running setup script ${version}...`); - - const location = path.join(APP_PATH, "db", "db.sqlite"); - const db = new Database(location); - - try { - db.pragma("foreign_keys = OFF"); - - db.transaction(() => { - db.prepare( - `ALTER TABLE "resources" RENAME COLUMN "headers" TO "requestHeaders";` - ).run(); - db.prepare( - `ALTER TABLE "resources" ADD "responseHeaders" text;` - ).run(); - })(); - - console.log("Migrated database"); - } catch (e) { - console.log("Failed to migrate db:", e); - throw e; - } finally { - db.pragma("foreign_keys = ON"); - db.close(); - } - - console.log(`${version} migration complete`); -} From a8c1ddb4481d39c37700d7b5f968fe80b506cab4 Mon Sep 17 00:00:00 2001 From: Julian van der Horst Date: Tue, 7 Jul 2026 11:57:43 +0200 Subject: [PATCH 09/47] Merge should be complete --- server/lib/blueprints/proxyResources.ts | 0 server/lib/blueprints/publicResources.ts | 25 +++++++++++++------ server/routers/resource/updateResource.ts | 6 ++++- .../resources/proxy/[niceId]/proxy/page.tsx | 0 4 files changed, 23 insertions(+), 8 deletions(-) delete mode 100644 server/lib/blueprints/proxyResources.ts delete mode 100644 src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 92d6239fa..03e3a724a 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -237,10 +237,18 @@ export async function updatePublicResources( resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; - let headers = ""; - if (resourceData.headers) { - headers = JSON.stringify(resourceData.headers); - } + // `headers` is a deprecated alias for `requestHeaders` + const mergedRequestHeaders = [ + ...(resourceData.headers ?? []), + ...(resourceData.requestHeaders ?? []) + ]; + const requestHeaders = + mergedRequestHeaders.length > 0 + ? JSON.stringify(mergedRequestHeaders) + : null; + const responseHeaders = resourceData.responseHeaders?.length + ? JSON.stringify(resourceData.responseHeaders) + : null; if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) { const isLicensed = await isLicensedOrSubscribed( @@ -380,7 +388,8 @@ export async function updatePublicResources( ? resourceData.auth["whitelist-users"].length > 0 : false, - headers: headers || null, + requestHeaders, + responseHeaders, applyRules: resourceData.rules && resourceData.rules.length > 0, @@ -566,7 +575,8 @@ export async function updatePublicResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, - headers: headers || null, + requestHeaders, + responseHeaders, maintenanceModeEnabled: resourceData.maintenance?.enabled, maintenanceModeType: resourceData.maintenance?.type, @@ -1152,7 +1162,8 @@ export async function updatePublicResources( setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, - headers: headers || null, + requestHeaders, + responseHeaders, applyRules: resourceData.rules && resourceData.rules.length > 0, pamMode: resourceData["auth-daemon"]?.pam || "passthrough", diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 532ba2b25..fd2e8c37e 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -663,6 +663,10 @@ async function updateHttpResource( responseHeaders = null; } + updateData.headers = undefined; + updateData.requestHeaders = undefined; + updateData.responseHeaders = undefined; + if (!isLicensed) { updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeType = undefined; @@ -715,7 +719,7 @@ async function updateHttpResource( const updatedResource = await db .update(resources) - .set({ ...resourceOnlyData, headers }) + .set({ ...resourceOnlyData, requestHeaders, responseHeaders }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx deleted file mode 100644 index e69de29bb..000000000 From a7d4745f93a0c0326f4554b5d902a25fb261dca4 Mon Sep 17 00:00:00 2001 From: Jan Kahmen <36455663+kah-ja@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:57:22 +0000 Subject: [PATCH 10/47] Only accept http(s) targets for the resource auth redirect The resource auth page copies the redirect query parameter into redirectUrl when its host matches the resource host (src/app/auth/resource/[resourceGuid]/page.tsx:121-150). URL parses a host out of every scheme that uses "//", so a target such as javascript://resource-host/... passes that comparison. The value is handed to ResourceAuthPortal as the redirect prop and assigned to window.location.href after a successful login (src/components/ResourceAuthPortal.tsx:213,247,281). Parse the target once and require http: or https: before the host comparisons. The three branches that assigned the same value are folded into one condition; the accepted set of http(s) targets is unchanged. --- src/app/auth/resource/[resourceGuid]/page.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index 318d63517..53217dcfd 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -122,11 +122,20 @@ export default async function ResourceAuthPage(props: { if (searchParams.redirect) { try { + const redirectTarget = new URL(searchParams.redirect); const serverResourceHost = new URL(authInfo.url).host; - const redirectHost = new URL(searchParams.redirect).host; - const redirectPort = new URL(searchParams.redirect).port; + const redirectHost = redirectTarget.host; + const redirectPort = redirectTarget.port; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; + // URL parses a host out of any scheme that uses "//", so a target + // like javascript://resource-host/... matches the comparisons + // below. The target is later assigned to window.location, so only + // http(s) is accepted here. + const isHttpTarget = + redirectTarget.protocol === "http:" || + redirectTarget.protocol === "https:"; + const wildcardMatchesRedirect = ( wildcardDomain: string, host: string @@ -136,14 +145,16 @@ export default async function ResourceAuthPage(props: { return host.endsWith(suffix) && host.length > suffix.length; }; - if (serverResourceHost === redirectHost) { - redirectUrl = searchParams.redirect; - } else if (serverResourceHostWithPort === redirectHost) { - redirectUrl = searchParams.redirect; - } else if ( - authInfo.wildcard && - authInfo.fullDomain && - wildcardMatchesRedirect(authInfo.fullDomain, redirectHost) + if ( + isHttpTarget && + (serverResourceHost === redirectHost || + serverResourceHostWithPort === redirectHost || + (authInfo.wildcard && + authInfo.fullDomain && + wildcardMatchesRedirect( + authInfo.fullDomain, + redirectHost + ))) ) { redirectUrl = searchParams.redirect; } From 1f453dc04f3ce3aea76153acb4b0a3a6fef42842 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 17 Sep 2026 09:24:15 -0400 Subject: [PATCH 11/47] Send out of address space errors to sites and clients --- server/routers/newt/error.ts | 24 +++++++++++++++++++ .../routers/newt/handleNewtRegisterMessage.ts | 2 ++ server/routers/olm/error.ts | 5 ++++ .../routers/olm/handleOlmRegisterMessage.ts | 1 + 4 files changed, 32 insertions(+) create mode 100644 server/routers/newt/error.ts diff --git a/server/routers/newt/error.ts b/server/routers/newt/error.ts new file mode 100644 index 000000000..d2ab8937c --- /dev/null +++ b/server/routers/newt/error.ts @@ -0,0 +1,24 @@ +import { sendToClient } from "#dynamic/routers/ws"; + +// Error codes for registration failures +export const NewtErrorCodes = { + NO_AVAILABLE_SUBNET: { + code: "NO_AVAILABLE_SUBNET", + message: + "No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet." + } +} as const; + +// Helper function to send registration error +export async function sendNewtError( + error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes], + newtId: string +) { + sendToClient(newtId, { + type: "newt/error", + data: { + code: error.code, + message: error.message + } + }); +} diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 7adc74a1b..43dee26f5 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; +import { NewtErrorCodes, sendNewtError } from "./error"; export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { logger.error( `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` ); + sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId); return; } diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts index d9058a3f6..b0fd90993 100644 --- a/server/routers/olm/error.ts +++ b/server/routers/olm/error.ts @@ -94,6 +94,11 @@ export const OlmErrorCodes = { HOLEPUNCH_MISSING: { code: "HOLEPUNCH_MISSING", message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.` + }, + NO_AVAILABLE_SUBNET: { + code: "NO_AVAILABLE_SUBNET", + message: + "No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet." } } as const; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 693920950..988e68afd 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { `[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`, { orgId: client.orgId, clientId: client.clientId } ); + sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId); return; } From 5ca08d71f02e6498ef85424685411ede5551dc8c Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 17 Sep 2026 13:30:46 -0400 Subject: [PATCH 12/47] change restart site toast text --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 003595b7b..664098b8b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -132,7 +132,7 @@ "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for {name}? The site will briefly lose connectivity.", "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", "siteRestarted": "Site restarted", - "siteRestartedDescription": "The WireGuard tunnel has been restarted.", + "siteRestartedDescription": "The site has been restarted.", "siteErrorRestart": "Failed to restart site", "siteErrorRestartDescription": "An error occurred while restarting the site.", "siteSettingDescription": "Configure the settings on the site", From 032eeb26565be87f057b40db01198d7cab5a18f2 Mon Sep 17 00:00:00 2001 From: Alex Benthem Date: Sat, 19 Sep 2026 12:28:16 +0200 Subject: [PATCH 13/47] fix: use idp variant for resource auth login page icons The resource auth login page (auth/resource/[resourceGuid]) loads IdPs via the global /idp list in the non-saas/non-org path and passed idp.type as the icon variant. Since type is always 'oidc' for OIDC-backed providers (Google, Azure), the branded logos were never selected, showing the generic OIDC icon instead. Use idp.variant (with type as fallback), matching the fix already applied to the main login page (auth/login) and org login page (auth/org/[orgId]). Fixes #3631 --- src/app/auth/resource/[resourceGuid]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index 318d63517..4d6f101de 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -283,7 +283,7 @@ export default async function ResourceAuthPage(props: { loginIdps = idpsRes.data.data.idps.map((idp) => ({ idpId: idp.idpId, name: idp.name, - variant: idp.type + variant: idp.variant ?? idp.type })) as LoginFormIDP[]; } From 8e2f9ea5ef991ca12784f7f1c9e26574895360a0 Mon Sep 17 00:00:00 2001 From: Blacks-Army <104644957+Blacks-Army@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:10:44 +0200 Subject: [PATCH 14/47] Add HTTP method matching to resource rules Resolves #1408. A rule with match "METHOD" carries a comma-separated list of HTTP methods in its value, e.g. "POST,PUT", and applies when the request method is in that list. This makes it possible to leave GET public while sending POST and PUT to auth, which rules could not express before because both share the same path. No new columns: the methods live in the existing rule value, so this needs no migration and every existing rule keeps working unchanged. The UI offers the ten registered methods. Blueprints and the API accept any method token, so extension methods such as the WebDAV verbs can be targeted too, and the UI preserves them when a rule set that way is edited later. --- messages/en-US.json | 7 +- server/db/pg/schema/schema.ts | 2 + server/db/sqlite/schema/schema.ts | 2 + server/lib/blueprints/publicResources.ts | 16 ++- server/lib/blueprints/resourcePolicies.ts | 28 +++-- server/lib/blueprints/types.ts | 25 +++- server/lib/validators.test.ts | 43 ++++++- server/lib/validators.ts | 43 ++++++- server/routers/badger/verifySession.ts | 20 +++- .../PolicyAccessRulesTable.tsx | 108 +++++++++++++++--- .../policy-access-rule-validation.ts | 8 +- 11 files changed, 267 insertions(+), 35 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 664098b8b..ed0ca7dba 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -834,7 +834,7 @@ "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorValidation": "Invalid rules", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", - "rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).", + "rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, ASN, or method).", "rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountryDescription": "Select a valid country.", @@ -4400,5 +4400,8 @@ "sessionToolbarShow": "Show toolbar", "sessionToolbarHide": "Hide toolbar", "actionUpdateSiteApprovals": "Update Site Approvals", - "check": "Check" + "check": "Check", + "rulesErrorInvalidMethod": "Invalid HTTP method", + "rulesErrorInvalidMethodDescription": "Select at least one HTTP method.", + "rulesSelectMethods": "Select methods" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 744e88b98..6569d48b9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1137,6 +1137,7 @@ export const resourceRules = pgTable("resourceRules", { | "COUNTRY_IS_NOT" | "ASN" | "REGION" + | "METHOD" >() .notNull(), // CIDR, PATH, IP value: varchar("value").notNull() @@ -1161,6 +1162,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { | "COUNTRY_IS_NOT" | "ASN" | "REGION" + | "METHOD" >() .notNull(), value: varchar("value").notNull() diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e30bc678e..6201e695d 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1409,6 +1409,7 @@ export const resourceRules = sqliteTable("resourceRules", { | "COUNTRY_IS_NOT" | "ASN" | "REGION" + | "METHOD" >() .notNull(), // CIDR, PATH, IP value: text("value").notNull() @@ -1465,6 +1466,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { | "COUNTRY_IS_NOT" | "ASN" | "REGION" + | "METHOD" >() .notNull(), value: text("value").notNull() diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 4bd42ed0b..55e0013ae 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { pickPort } from "@server/routers/target/helpers"; import { and, asc, eq, isNotNull, ne } from "drizzle-orm"; import { tierMatrix } from "../billing/tierMatrix"; -import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { + isValidCIDR, + isValidHttpMethodList, + isValidIP, + isValidUrlGlobPattern, + parseHttpMethodList +} from "../validators"; import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import { findOrgUsersByIdentifier } from "./findOrgUser"; @@ -1453,6 +1459,10 @@ function getRuleValue(match: string, value: string) { if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") { return value.toUpperCase(); } + // normalize the method list so it is stored as "POST,PUT" + if (match === "METHOD") { + return parseHttpMethodList(value).join(","); + } return value; } @@ -1473,6 +1483,10 @@ function validateRule(rule: any) { if (!isValidRegionId(rule.value)) { throw new Error(`Invalid region ID provided: ${rule.value}`); } + } else if (rule.match === "method") { + if (!isValidHttpMethodList(rule.value)) { + throw new Error(`Invalid HTTP method provided: ${rule.value}`); + } } } diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts index d8c744cdb..babb1c10a 100644 --- a/server/lib/blueprints/resourcePolicies.ts +++ b/server/lib/blueprints/resourcePolicies.ts @@ -19,7 +19,13 @@ import logger from "@server/logger"; import { getUniqueResourcePolicyName } from "@server/db/names"; import { hashPassword } from "@server/auth/password"; import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; -import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { + isValidCIDR, + isValidHttpMethodList, + isValidIP, + isValidUrlGlobPattern, + ResourceRuleMatchType +} from "../validators"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; import { findOrgUsersByIdentifier } from "./findOrgUser"; @@ -66,6 +72,13 @@ export async function updateResourcePolicies( throw new Error( `Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}` ); + } else if ( + rule.match === "method" && + !isValidHttpMethodList(rule.value) + ) { + throw new Error( + `Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}` + ); } } @@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" { return "PASS"; } -function getRuleMatch( - input: string -): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" { - return input.toUpperCase() as - | "CIDR" - | "IP" - | "PATH" - | "COUNTRY" - | "COUNTRY_IS_NOT" - | "ASN" - | "REGION"; +function getRuleMatch(input: string): ResourceRuleMatchType { + return input.toUpperCase() as ResourceRuleMatchType; } async function syncRolePolicies( diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 238ef49c4..80c4aeb87 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; +import { isValidHttpMethodList } from "@server/lib/validators"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { @@ -127,7 +128,16 @@ export const AuthSchema = z.object({ export const RuleSchema = z .object({ action: z.enum(["allow", "deny", "pass"]), - match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]), + match: z.enum([ + "cidr", + "path", + "ip", + "country", + "country_is_not", + "asn", + "region", + "method" + ]), value: z.coerce.string(), priority: z.int().optional(), enabled: z.boolean().optional().default(true) @@ -207,6 +217,19 @@ export const RuleSchema = z message: "Value must be a valid UN M.49 region or subregion ID when match is 'region'" } + ) + .refine( + (rule) => { + if (rule.match === "method") { + return isValidHttpMethodList(rule.value); + } + return true; + }, + { + path: ["value"], + message: + "Value must be a comma-separated list of HTTP methods when match is 'method', e.g. 'POST,PUT'" + } ); export const HeaderSchema = z.object({ diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 5ce95f45c..c17181c6a 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -1,9 +1,10 @@ import { getResourceRuleValueValidationError, isValidDomain, - isValidUrlGlobPattern + isValidUrlGlobPattern, + parseHttpMethodList } from "./validators"; -import { assertEquals } from "@test/assert"; +import { assertEquals, assertEqualsObj } from "@test/assert"; function runTests() { console.log("Running domain validation tests..."); @@ -295,6 +296,44 @@ function runTests() { "Invalid ASN should return an error" ); + // HTTP method validation tests + assertEquals( + getResourceRuleValueValidationError("METHOD", "POST"), + null, + "Single HTTP method should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("METHOD", " post , Put "), + null, + "Method list should be valid with mixed case and whitespace" + ); + assertEquals( + getResourceRuleValueValidationError("METHOD", "PROPFIND"), + null, + "Extension methods such as the WebDAV verbs should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("METHOD", ""), + "Invalid HTTP method provided", + "Empty method list should return an error" + ); + assertEquals( + getResourceRuleValueValidationError("METHOD", ",,"), + "Invalid HTTP method provided", + "Method list of only separators should return an error" + ); + assertEquals( + getResourceRuleValueValidationError("METHOD", "GET POST"), + "Invalid HTTP method provided", + "Space separated methods should return an error" + ); + + assertEqualsObj( + parseHttpMethodList(" get ,post, "), + ["GET", "POST"], + "Method list should be normalized to uppercase without empty entries" + ); + console.log("All tests passed!"); } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 872ced221..d5bf5d2cc 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [ "COUNTRY", "COUNTRY_IS_NOT", "ASN", - "REGION" + "REGION", + "METHOD" ] as const; +// The methods offered in the UI: the eight from RFC 9110 plus PATCH (RFC 5789) +// and QUERY (RFC 10008). A METHOD rule is not limited to these, since +// isValidHttpMethodList accepts any method token, so blueprints and the API can +// also target extension methods such as the WebDAV verbs. +export const HTTP_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "TRACE", + "CONNECT", + "QUERY" +] as const; + +// RFC 9110 token, minus the characters that would collide with the +// comma-separated list encoding. +const HTTP_METHOD_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +export function parseHttpMethodList(value: string): string[] { + return value + .split(",") + .map((method) => method.trim().toUpperCase()) + .filter((method) => method.length > 0); +} + +export function isValidHttpMethodList(value: string): boolean { + const methods = parseHttpMethodList(value); + return ( + methods.length > 0 && + methods.every((method) => HTTP_METHOD_REGEX.test(method)) + ); +} + export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number]; export function getResourceRuleValueValidationError( @@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError( return COUNTRIES.some((country) => country.code === value) ? null : "Invalid country code provided"; + case "METHOD": + return isValidHttpMethodList(value) + ? null + : "Invalid HTTP method provided"; case "ASN": const normalizedValue = value.trim().toUpperCase(); return /^AS\d+$/.test(normalizedValue) || diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 486feb2b7..b8ecf6b9e 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -40,6 +40,7 @@ import { import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isPathAllowed } from "@server/lib/pathMatch"; +import { parseHttpMethodList } from "@server/lib/validators"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -163,6 +164,7 @@ export async function verifyResourceSession( path, headers, query, + method, badgerVersion } = parsedBody.data; @@ -293,7 +295,8 @@ export async function verifyResourceSession( clientIp, path, ipCC, - ipAsn + ipAsn, + method ); if (action == "ACCEPT") { @@ -1429,7 +1432,8 @@ async function checkRules( clientIp: string | undefined, path: string | undefined, ipCC?: string, - ipAsn?: number + ipAsn?: number, + method?: string ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -1504,12 +1508,24 @@ async function checkRules( (await isIpInRegion(ipCC, rule.value)) ) { return rule.action as any; + } else if ( + method && + rule.match == "METHOD" && + isMethodAllowed(rule.value, method) + ) { + return rule.action as any; } } return; } +// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT". +function isMethodAllowed(ruleValue: string, method: string): boolean { + const requestMethod = method.toUpperCase(); + return parseHttpMethodList(ruleValue).includes(requestMethod); +} + export { isPathAllowed }; async function isIpInGeoIP( diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index 2ff2e1915..dba282342 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn"; import { MAJOR_ASNS } from "@server/db/asns"; import { COUNTRIES } from "@server/db/countries"; import { REGIONS, getRegionNameById } from "@server/db/regions"; +import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators"; import { ColumnDef, flexRender, @@ -63,7 +64,8 @@ import { } from "react"; import { validatePolicyRulePriority, - validatePolicyRuleValue + validatePolicyRuleValue, + type PolicyRuleMatchType } from "./policy-access-rule-validation"; import { buildDisplayPrioritiesForResourceOverlay, @@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) { return ""; } +// A METHOD rule stores its methods as a comma-separated list in rule.value, +// e.g. "POST,PUT". Only the common methods are offered here; a value set +// through a blueprint or the API may contain other methods (the WebDAV verbs, +// for instance), so those are kept and shown rather than dropped on edit. +function RuleMethodSelect({ + value, + disabled, + placeholder, + onChange +}: { + value: string; + disabled: boolean; + placeholder: string; + onChange: (value: string) => void; +}) { + const selected = parseHttpMethodList(value); + const knownMethods: readonly string[] = HTTP_METHODS; + const options = [ + ...knownMethods, + ...selected.filter((method) => !knownMethods.includes(method)) + ]; + + function toggle(method: string) { + const next = selected.includes(method) + ? selected.filter((m) => m !== method) + : [...selected, method]; + + // keep a stable order so the stored value does not churn on every edit + onChange(options.filter((m) => next.includes(m)).join(",")); + } + + return ( + + + + + + + + + {options.map((method) => ( + toggle(method)} + > + + {method} + + ))} + + + + + + ); +} + export function PolicyAccessRulesTable({ rules, onRulesChange, @@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({ COUNTRY: t("country"), COUNTRY_IS_NOT: t("countryIsNot"), ASN: "ASN", - REGION: t("region") + REGION: t("region"), + METHOD: t("method") }), [t] ); @@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({ COUNTRIES.some((country) => country.code === value), { message: t("rulesErrorInvalidCountryDescription") } ); + case "METHOD": + return required.refine(isValidHttpMethodList, { + message: t("rulesErrorInvalidMethodDescription") + }); case "ASN": return required.refine( (value) => { From c2902398942703e84e0166f5fde5a52602b5ba3a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Sep 2026 09:14:16 -0400 Subject: [PATCH 15/47] Use postgresql Fix #3794 --- config/ha-reference/node1/docker-compose.yml | 2 +- config/ha-reference/node2/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/ha-reference/node1/docker-compose.yml b/config/ha-reference/node1/docker-compose.yml index c8f80d603..a473ee8d4 100644 --- a/config/ha-reference/node1/docker-compose.yml +++ b/config/ha-reference/node1/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:ee-latest + image: docker.io/fosrl/pangolin:ee-postgresql-latest container_name: pangolin restart: unless-stopped volumes: diff --git a/config/ha-reference/node2/docker-compose.yml b/config/ha-reference/node2/docker-compose.yml index 8a2e6d4d7..fa06f1404 100644 --- a/config/ha-reference/node2/docker-compose.yml +++ b/config/ha-reference/node2/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:ee-latest + image: docker.io/fosrl/pangolin:ee-postgresql-latest container_name: pangolin restart: unless-stopped volumes: From d4488ec12510f27ec02395d03fa45e85b6c46092 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 21 Sep 2026 09:57:03 -0400 Subject: [PATCH 16/47] update ios identifiers --- server/db/ios_models.json | 125 +++++++++++++++++++++++--------------- 1 file changed, 77 insertions(+), 48 deletions(-) diff --git a/server/db/ios_models.json b/server/db/ios_models.json index 99fcbea17..43dfe5687 100644 --- a/server/db/ios_models.json +++ b/server/db/ios_models.json @@ -4,42 +4,18 @@ "iPad2,2": "iPad 2", "iPad2,3": "iPad 2", "iPad2,4": "iPad 2", - "iPad3,1": "iPad 3rd Gen", - "iPad3,3": "iPad 3rd Gen", - "iPad3,2": "iPad 3rd Gen", - "iPad3,4": "iPad 4th Gen", - "iPad3,5": "iPad 4th Gen", - "iPad3,6": "iPad 4th Gen", - "iPad6,11": "iPad 9.7 5th Gen", - "iPad6,12": "iPad 9.7 5th Gen", - "iPad7,5": "iPad 9.7 6th Gen", - "iPad7,6": "iPad 9.7 6th Gen", - "iPad7,11": "iPad 10.2 7th Gen", - "iPad7,12": "iPad 10.2 7th Gen", - "iPad11,6": "iPad 10.2 8th Gen", - "iPad11,7": "iPad 10.2 8th Gen", - "iPad12,1": "iPad 10.2 9th Gen", - "iPad12,2": "iPad 10.2 9th Gen", - "iPad13,18": "iPad 10.9 10th Gen", - "iPad13,19": "iPad 10.9 10th Gen", - "iPad4,1": "iPad Air", - "iPad4,2": "iPad Air", - "iPad4,3": "iPad Air", - "iPad5,3": "iPad Air 2", - "iPad5,4": "iPad Air 2", - "iPad11,3": "iPad Air 3rd Gen", - "iPad11,4": "iPad Air 3rd Gen", - "iPad13,1": "iPad Air 4th Gen", - "iPad13,2": "iPad Air 4th Gen", - "iPad13,16": "iPad Air 5th Gen", - "iPad13,17": "iPad Air 5th Gen", - "iPad14,8": "iPad Air M2 11", - "iPad14,9": "iPad Air M2 11", - "iPad14,10": "iPad Air M2 13", - "iPad14,11": "iPad Air M2 13", "iPad2,5": "iPad mini", "iPad2,6": "iPad mini", "iPad2,7": "iPad mini", + "iPad3,1": "iPad 3rd Gen", + "iPad3,2": "iPad 3rd Gen", + "iPad3,3": "iPad 3rd Gen", + "iPad3,4": "iPad 4th Gen", + "iPad3,5": "iPad 4th Gen", + "iPad3,6": "iPad 4th Gen", + "iPad4,1": "iPad Air", + "iPad4,2": "iPad Air", + "iPad4,3": "iPad Air", "iPad4,4": "iPad mini 2", "iPad4,5": "iPad mini 2", "iPad4,6": "iPad mini 2", @@ -48,18 +24,22 @@ "iPad4,9": "iPad mini 3", "iPad5,1": "iPad mini 4", "iPad5,2": "iPad mini 4", - "iPad11,1": "iPad mini 5th Gen", - "iPad11,2": "iPad mini 5th Gen", - "iPad14,1": "iPad mini 6th Gen", - "iPad14,2": "iPad mini 6th Gen", - "iPad6,7": "iPad Pro 12.9", - "iPad6,8": "iPad Pro 12.9", + "iPad5,3": "iPad Air 2", + "iPad5,4": "iPad Air 2", "iPad6,3": "iPad Pro 9.7", "iPad6,4": "iPad Pro 9.7", - "iPad7,3": "iPad Pro 10.5", - "iPad7,4": "iPad Pro 10.5", + "iPad6,7": "iPad Pro 12.9", + "iPad6,8": "iPad Pro 12.9", + "iPad6,11": "iPad 9.7 5th Gen", + "iPad6,12": "iPad 9.7 5th Gen", "iPad7,1": "iPad Pro 12.9", "iPad7,2": "iPad Pro 12.9", + "iPad7,3": "iPad Pro 10.5", + "iPad7,4": "iPad Pro 10.5", + "iPad7,5": "iPad 9.7 6th Gen", + "iPad7,6": "iPad 9.7 6th Gen", + "iPad7,11": "iPad 10.2 7th Gen", + "iPad7,12": "iPad 10.2 7th Gen", "iPad8,1": "iPad Pro 11", "iPad8,2": "iPad Pro 11", "iPad8,3": "iPad Pro 11", @@ -72,6 +52,16 @@ "iPad8,10": "iPad Pro 11", "iPad8,11": "iPad Pro 12.9", "iPad8,12": "iPad Pro 12.9", + "iPad11,1": "iPad mini 5th Gen", + "iPad11,2": "iPad mini 5th Gen", + "iPad11,3": "iPad Air 3rd Gen", + "iPad11,4": "iPad Air 3rd Gen", + "iPad11,6": "iPad 10.2 8th Gen", + "iPad11,7": "iPad 10.2 8th Gen", + "iPad12,1": "iPad 10.2 9th Gen", + "iPad12,2": "iPad 10.2 9th Gen", + "iPad13,1": "iPad Air 4th Gen", + "iPad13,2": "iPad Air 4th Gen", "iPad13,4": "iPad Pro 11", "iPad13,5": "iPad Pro 11", "iPad13,6": "iPad Pro 11", @@ -80,14 +70,40 @@ "iPad13,9": "iPad Pro 12.9", "iPad13,10": "iPad Pro 12.9", "iPad13,11": "iPad Pro 12.9", + "iPad13,16": "iPad Air M1 5th Gen", + "iPad13,17": "iPad Air M1 5th Gen", + "iPad13,18": "iPad 10.9 10th Gen", + "iPad13,19": "iPad 10.9 10th Gen", + "iPad14,1": "iPad mini 6th Gen", + "iPad14,2": "iPad mini 6th Gen", "iPad14,3": "iPad Pro 11", "iPad14,4": "iPad Pro 11", "iPad14,5": "iPad Pro 12.9", "iPad14,6": "iPad Pro 12.9", + "iPad14,8": "iPad Air M2 11", + "iPad14,9": "iPad Air M2 11", + "iPad14,10": "iPad Air M2 13", + "iPad14,11": "iPad Air M2 13", + "iPad15,3": "iPad Air M3 11", + "iPad15,4": "iPad Air M3 11", + "iPad15,5": "iPad Air M3 13", + "iPad15,6": "iPad Air M3 13", + "iPad15,7": "iPad A16 - 11th Gen", + "iPad15,8": "iPad A16 - 11th Gen", + "iPad16,1": "iPad mini A17 Pro - 7th Gen", + "iPad16,2": "iPad mini A17 Pro - 7th Gen", "iPad16,3": "iPad Pro M4 11", "iPad16,4": "iPad Pro M4 11", "iPad16,5": "iPad Pro M4 13", "iPad16,6": "iPad Pro M4 13", + "iPad16,8": "iPad Air M4 11", + "iPad16,9": "iPad Air M4 11", + "iPad16,10": "iPad Air M4 13", + "iPad16,11": "iPad Air M4 13", + "iPad17,1": "iPad Pro M5 11", + "iPad17,2": "iPad Pro M5 11", + "iPad17,3": "iPad Pro M5 13", + "iPad17,4": "iPad Pro M5 13", "iPhone1,1": "iPhone", "iPhone1,2": "iPhone 3G", "iPhone2,1": "iPhone 3GS", @@ -101,20 +117,20 @@ "iPhone5,4": "iPhone 5c", "iPhone6,1": "iPhone 5s", "iPhone6,2": "iPhone 5s", - "iPhone7,2": "iPhone 6", "iPhone7,1": "iPhone 6 Plus", + "iPhone7,2": "iPhone 6", "iPhone8,1": "iPhone 6s", "iPhone8,2": "iPhone 6s Plus", "iPhone8,4": "iPhone SE", "iPhone9,1": "iPhone 7", - "iPhone9,3": "iPhone 7", "iPhone9,2": "iPhone 7 Plus", + "iPhone9,3": "iPhone 7", "iPhone9,4": "iPhone 7 Plus", "iPhone10,1": "iPhone 8", - "iPhone10,4": "iPhone 8", "iPhone10,2": "iPhone 8 Plus", - "iPhone10,5": "iPhone 8 Plus", "iPhone10,3": "iPhone X", + "iPhone10,4": "iPhone 8", + "iPhone10,5": "iPhone 8 Plus", "iPhone10,6": "iPhone X", "iPhone11,2": "iPhone Xs", "iPhone11,6": "iPhone Xs Max", @@ -127,10 +143,10 @@ "iPhone13,2": "iPhone 12", "iPhone13,3": "iPhone 12 Pro", "iPhone13,4": "iPhone 12 Pro Max", - "iPhone14,4": "iPhone 13 mini", - "iPhone14,5": "iPhone 13", "iPhone14,2": "iPhone 13 Pro", "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", "iPhone14,6": "iPhone SE", "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", @@ -140,6 +156,19 @@ "iPhone15,5": "iPhone 15 Plus", "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "iPhone17,5": "iPhone 16e", + "iPhone18,1": "iPhone 17 Pro", + "iPhone18,2": "iPhone 17 Pro Max", + "iPhone18,3": "iPhone 17", + "iPhone18,4": "iPhone Air", + "iPhone18,5": "iPhone 17e", + "iPhone19,2": "iPhone 18 Pro", + "iPhone19,3": "iPhone 18 Pro Max", + "iPhone19,7": "iPhone 18 Pro Max", "iPod1,1": "iPod touch Original", "iPod2,1": "iPod touch 2nd", "iPod3,1": "iPod touch 3rd Gen", @@ -147,4 +176,4 @@ "iPod5,1": "iPod touch 5th", "iPod7,1": "iPod touch 6th Gen", "iPod9,1": "iPod touch 7th Gen" -} \ No newline at end of file +} From 6f3e0cf5a636621c382a3dcfb0272cf3c557f999 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Sep 2026 10:18:45 -0400 Subject: [PATCH 17/47] Time is un utc --- server/emails/templates/AlertNotification.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index c81cf60da..dc6d60131 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => { } ] : []), - { label: "Time", value: new Date().toUTCString() }, + { label: "Time (UTC)", value: new Date().toUTCString() }, ...dataItems ]; @@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => { {isTestAlert && ( - This is a test alert. No action is required, - and no real event has occurred. + This is a test alert. No action is required, and + no real event has occurred. )} From 887c6e4e8b6e0b44c7630501b1495f434b8d78d9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Sep 2026 11:10:37 -0400 Subject: [PATCH 18/47] Remove linting until 7.1 release --- .github/workflows/linting.yml | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 4bd0d2a90..451d2eb45 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -3,18 +3,25 @@ name: ESLint permissions: contents: read +# Disabled from running on PRs: typescript-eslint does not yet support +# TypeScript 7 (which this repo is on), so eslint currently crashes on +# every run. Kept as workflow_dispatch so it can still be triggered +# manually, and re-enabled on pull_request once upstream support lands. +# https://github.com/typescript-eslint/typescript-eslint/issues/10940 +# on: +# pull_request: +# paths: +# - '**/*.js' +# - '**/*.jsx' +# - '**/*.ts' +# - '**/*.tsx' +# - '.eslintrc*' +# - 'package.json' +# - 'yarn.lock' +# - 'pnpm-lock.yaml' +# - 'package-lock.json' on: - pull_request: - paths: - - '**/*.js' - - '**/*.jsx' - - '**/*.ts' - - '**/*.tsx' - - '.eslintrc*' - - 'package.json' - - 'yarn.lock' - - 'pnpm-lock.yaml' - - 'package-lock.json' + workflow_dispatch: jobs: Linter: From 0b3329939bc849e5e55b0fd4c9c79dbd47d4d8a7 Mon Sep 17 00:00:00 2001 From: Dhananjay Maurya Date: Mon, 21 Sep 2026 23:32:01 +0530 Subject: [PATCH 19/47] 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 750f26745172cb0b27293288b89640062d0d5f3c Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 21 Sep 2026 16:25:58 -0400 Subject: [PATCH 20/47] Try to link email to org when creating for first time --- server/lib/billing/index.ts | 1 + server/lib/billing/linkEmailOrg.ts | 6 +++ server/private/lib/billing/index.ts | 1 + server/private/lib/billing/linkEmailOrg.ts | 56 ++++++++++++++++++++++ server/routers/org/createOrg.ts | 3 +- 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 server/lib/billing/linkEmailOrg.ts create mode 100644 server/private/lib/billing/linkEmailOrg.ts diff --git a/server/lib/billing/index.ts b/server/lib/billing/index.ts index 54c9ee2e0..1bc354f7e 100644 --- a/server/lib/billing/index.ts +++ b/server/lib/billing/index.ts @@ -3,3 +3,4 @@ export * from "./features"; export * from "./limitsService"; export * from "./getOrgTierData"; export * from "./createCustomer"; +export * from "./linkEmailOrg"; diff --git a/server/lib/billing/linkEmailOrg.ts b/server/lib/billing/linkEmailOrg.ts new file mode 100644 index 000000000..df67d2ed8 --- /dev/null +++ b/server/lib/billing/linkEmailOrg.ts @@ -0,0 +1,6 @@ +export async function linkEmailOrg( + orgId: string, + email: string | null | undefined +): Promise { + return; +} diff --git a/server/private/lib/billing/index.ts b/server/private/lib/billing/index.ts index 4d52668c0..0223b8aca 100644 --- a/server/private/lib/billing/index.ts +++ b/server/private/lib/billing/index.ts @@ -13,3 +13,4 @@ export * from "./getOrgTierData"; export * from "./createCustomer"; +export * from "./linkEmailOrg"; diff --git a/server/private/lib/billing/linkEmailOrg.ts b/server/private/lib/billing/linkEmailOrg.ts new file mode 100644 index 000000000..c14485a3d --- /dev/null +++ b/server/private/lib/billing/linkEmailOrg.ts @@ -0,0 +1,56 @@ +/* + * 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 logger from "@server/logger"; +import privateConfig from "#private/lib/config"; +import { build } from "@server/build"; + +export async function linkEmailOrg( + orgId: string, + email: string | null | undefined +): Promise { + if (build !== "saas") { + return; + } + + if (!email) { + return; + } + + try { + const response = await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/link-email-org`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ email, orgId }) + } + ); + + if (!response.ok && response.status !== 404) { + logger.error( + `Fossorial API returned ${response.status} when linking email ${email} to orgId ${orgId}: ${await response.text()}` + ); + } + } catch (error) { + logger.error( + `Error notifying Fossorial API of email/org link for orgId ${orgId}:`, + error + ); + } +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index a4efc991d..6abc589d6 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -25,7 +25,7 @@ import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; -import { createCustomer } from "#dynamic/lib/billing"; +import { createCustomer, linkEmailOrg } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing"; import { build } from "@server/build"; @@ -425,6 +425,7 @@ export async function createOrg( customerId ); // Only 1 because we are creating the org } + await linkEmailOrg(orgId, req.user?.email); } if (numOrgs) { From d134304159af114b3c62a690d1205904ad293931 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 09:33:43 -0400 Subject: [PATCH 21/47] Add alpine install commands --- src/components/newt-install-commands.tsx | 82 +++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index e1c7422f6..5d24f76dc 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -20,7 +20,7 @@ import { FaWindows } from "react-icons/fa"; import { Download, ExternalLink } from "lucide-react"; -import { SiKubernetes, SiNixos } from "react-icons/si"; +import { SiAlpinelinux, SiKubernetes, SiNixos } from "react-icons/si"; import { useEnvContext } from "@app/hooks/useEnvContext"; export type CommandItem = @@ -30,6 +30,7 @@ export type CommandItem = const PLATFORMS = [ "linux", + "alpine", "macos", "docker", "kubernetes", @@ -65,7 +66,8 @@ export function NewtSiteInstallCommands({ ); const showSiteConfiguration = platform !== "advantech"; - const supportsSshOption = platform === "linux" || platform === "nixos"; + const supportsSshOption = + platform === "linux" || platform === "nixos" || platform === "alpine"; const acceptClientsFlag = !acceptClients ? " --disable-clients" : ""; const acceptClientsEnv = !acceptClients @@ -168,6 +170,76 @@ sudo systemctl enable --now pangolin-site` } ] }, + alpine: { + Run: [ + { + title: t("install"), + command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash` + }, + { + title: t("run"), + command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}` + } + ], + "Manual OpenRC Service": [ + { + title: t("install"), + command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash` + }, + { + title: t("envFile"), + command: `sudo tee /etc/conf.d/pangolin-site > /dev/null << 'EOF' +export SITE_ID=${id} +export SITE_SECRET=${secret} +export PANGOLIN_ENDPOINT=${endpoint}${ + !acceptClients + ? ` +export DISABLE_CLIENTS=true` + : "" + }${ + !allowPangolinSsh + ? ` +export DISABLE_SSH=true` + : "" + } +EOF +sudo chmod 600 /etc/conf.d/pangolin-site` + }, + { + title: t("serviceFile"), + command: `sudo tee /etc/init.d/pangolin-site > /dev/null << 'EOF' +#!/sbin/openrc-run + +name="pangolin-site" +description="Pangolin Site" + +command="/usr/local/bin/pangolin" +command_args="up site" +command_background="yes" +supervisor="supervise-daemon" + +pidfile="/run/pangolin-site.pid" +output_log="/var/log/pangolin-site.log" +error_log="/var/log/pangolin-site.err" + +depend() { + need net + after firewall +} +EOF +sudo chmod +x /etc/init.d/pangolin-site` + }, + { + title: t("enableAndStart"), + command: `sudo rc-update add pangolin-site default +sudo rc-service pangolin-site start` + }, + { + title: t("check"), + command: `sudo rc-service pangolin-site status` + } + ] + }, macos: { Run: [ { @@ -478,6 +550,8 @@ function getPlatformIcon(platformName: Platform) { return ; case "linux": return ; + case "alpine": + return ; case "macos": return ; case "docker": @@ -501,6 +575,8 @@ function getPlatformName(platformName: Platform) { return "Windows"; case "linux": return "Linux"; + case "alpine": + return "Alpine Linux"; case "macos": return "macOS"; case "docker": @@ -522,6 +598,8 @@ function getArchitectures(platform: Platform) { switch (platform) { case "linux": return ["Run", "Systemd Service", "Manual Systemd Service"]; + case "alpine": + return ["Run", "Manual OpenRC Service"]; case "macos": return ["Run", "Service"]; case "windows": From 9de91e2f81dd1347d36903ed8f8376c32b394ac7 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 09:39:17 -0400 Subject: [PATCH 22/47] Display the quantities again --- messages/en-US.json | 8 + src/app/admin/license/page.tsx | 210 ++++++++++++++++-------- src/components/LicenseKeysDataTable.tsx | 48 ++++++ 3 files changed, 201 insertions(+), 65 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6be431209..37d1390e6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -590,6 +590,14 @@ "licensePurchaseSites": "Purchase Additional Sites", "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", + "licenseUsage": "License Usage", + "licenseUsageDescription": "View the number of users and sites licensed for this host.", + "licenseUsageSites": "Sites", + "licenseUsageUsers": "Users", + "licenseNoUserLimit": "There is no limit on the number of users using an unlicensed host.", + "licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used", + "licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}} in system.", + "licenseUnlimited": "Unlimited", "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", "licenseFee": "License fee", "licensePriceSite": "Price per site", diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index e3051664d..33cf86d96 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -41,6 +41,7 @@ import { SettingsSectionHeader, SettingsSectionFooter } from "@app/components/Settings"; +import { Progress } from "@app/components/ui/progress"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ArrowRight, @@ -64,13 +65,6 @@ const ENTERPRISE_DOCS_URL = "https://docs.pangolin.net/self-host/enterprise-edition"; const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted"; -function obfuscateLicenseKey(key: string): string { - if (key.length <= 8) return key; - const firstPart = key.substring(0, 4); - const lastPart = key.substring(key.length - 4); - return `${firstPart}••••••••••••••••••••${lastPart}`; -} - export default function LicensePage() { const api = createApiClient(useEnvContext()); const [rows, setRows] = useState([]); @@ -80,7 +74,6 @@ export default function LicensePage() { useState(null); const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); - const [hostLicense, setHostLicense] = useState(null); const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false); const [purchaseMode, setPurchaseMode] = useState<"license">("license"); @@ -128,12 +121,6 @@ export default function LicensePage() { ); const keys = response.data.data; setRows(keys); - const hostKey = keys.find((key) => key.type === "host"); - if (hostKey) { - setHostLicense(hostKey.licenseKey); - } else { - setHostLicense(null); - } } catch (e) { toast({ title: t("licenseErrorKeyLoad"), @@ -399,62 +386,155 @@ export default function LicensePage() { {/* */} - - - {t("licenseHost")} - - {t("licenseHostDescription")} - - -
-
- {licenseStatus?.isLicenseValid ? ( -
-
- - {t("licensed") + - `${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`} -
+ + + + {t("licenseUsage")} + + {t("licenseUsageDescription")} + + +
+
+
+ {t("licenseUsageSites")}
- ) : (
- {t("unlicensed")} + {t("licenseSitesUsed", { + count: licenseStatus?.usedSites || 0 + })} +
+ {licenseStatus?.maxSites ? ( +
+
+ + {t("licenseSitesUsedMax", { + usedSites: + licenseStatus.usedSites || + 0, + maxSites: + licenseStatus.maxSites + })} + + + {Math.round( + ((licenseStatus.usedSites || + 0) / + licenseStatus.maxSites) * + 100 + )} + % + +
+ +
+ ) : ( +
+ {t("licenseNoSiteLimit")} +
+ )} +
+
+
+ {t("licenseUsageUsers")} +
+
+ {t("licenseUsersUsed", { + count: licenseStatus?.usedUsers || 0 + })} +
+ {licenseStatus?.maxUsers ? ( +
+
+ + {t("licenseUsersUsedMax", { + usedUsers: + licenseStatus.usedUsers || + 0, + maxUsers: + licenseStatus.maxUsers + })} + + + {Math.round( + ((licenseStatus.usedUsers || + 0) / + licenseStatus.maxUsers) * + 100 + )} + % + +
+ +
+ ) : ( +
+ {t("licenseNoUserLimit")} +
+ )} +
+
+
+ + + {t("licenseHost")} + + {t("licenseHostDescription")} + + +
+
+ {licenseStatus?.isLicenseValid ? ( +
+
+ + {t("licensed") + + `${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`} +
+
+ ) : ( +
+ {t("unlicensed")} +
+ )} +
+ {licenseStatus?.hostId && ( +
+
+ {t("hostId")} +
+
)}
- {licenseStatus?.hostId && ( -
-
- {t("hostId")} -
- -
- )} - {hostLicense && ( -
-
- {t("licenseKey")} -
- -
- )} -
- - - - + + + + + { diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 4b63a7b28..1e4f47a82 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -112,6 +112,54 @@ export function LicenseKeysDataTable({ } } }, + { + accessorKey: "quantity", + friendlyName: t("users"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const quantity = row.original.quantity; + if (quantity === undefined) { + return "-"; + } + return quantity < 0 ? t("licenseUnlimited") : quantity; + } + }, + { + accessorKey: "quantity_2", + friendlyName: t("sites"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const quantity = row.original.quantity_2; + if (quantity === undefined) { + return "-"; + } + return quantity < 0 ? t("licenseUnlimited") : quantity; + } + }, { accessorKey: "terminateAt", friendlyName: t("licenseTableValidUntil"), From 0364257e68d9fb720c31a5f55fc933e1986c44bc Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 09:45:11 -0400 Subject: [PATCH 23/47] Pick key based on tier ranked --- server/private/license/license.ts | 40 +++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 61ea23b48..0ff77bc4f 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -50,6 +50,27 @@ type ValidateLicenseAPIResponse = { status: number; }; +// Ranks license tiers so that when multiple license keys are active, the +// highest tier among them wins. Order: personal < tier1 < tier2 < ... < +// tier[n] < enterprise. Tier numbers are parsed so this scales to any +// tier[n] without needing updates here. +function tierRank(tier?: LicenseKeyTier): number { + if (!tier) { + return -1; + } + if (tier === "enterprise") { + return Number.MAX_SAFE_INTEGER; + } + if (tier === "personal") { + return 0; + } + const match = /^tier(\d+)$/.exec(tier); + if (match) { + return parseInt(match[1], 10); + } + return 0; +} + type TokenPayload = { valid: boolean; type: LicenseKeyType; @@ -371,12 +392,22 @@ LQIDAQAB } // Compute host status: quantity = users, quantity_2 = sites + // When multiple host keys are active, prefer a valid key over an + // invalid one, and among equally-valid keys prefer the highest tier. + let selectedHostKey: LicenseKeyCache | undefined; for (const key of keys) { const cached = newCache.get(key.licenseKey)!; if (cached.type === "host") { - status.isLicenseValid = cached.valid; - status.tier = cached.tier; + if ( + !selectedHostKey || + (cached.valid && !selectedHostKey.valid) || + (cached.valid === selectedHostKey.valid && + tierRank(cached.tier) > + tierRank(selectedHostKey.tier)) + ) { + selectedHostKey = cached; + } } if (!cached.valid) { @@ -393,6 +424,11 @@ LQIDAQAB } } + if (selectedHostKey) { + status.isLicenseValid = selectedHostKey.valid; + status.tier = selectedHostKey.tier; + } + // Invalidate license if over user or site limits if ( (status.maxSites !== undefined && From 0de5c763b331f4934c1e39edb04fc15903f048a0 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 09:53:05 -0400 Subject: [PATCH 24/47] Order by valid until --- src/components/LicenseKeysDataTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 1e4f47a82..939686503 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -206,6 +206,7 @@ export function LicenseKeysDataTable({ title={t("licenseKeys")} searchPlaceholder={t("licenseKeySearch")} searchColumn="licenseKey" + defaultSort={{ id: "terminateAt", desc: false }} onAdd={onCreate} addButtonText={t("licenseKeyAdd")} enableColumnVisibility={true} From d38281b9f7de3b6af518f2c9dc845bfb946c6578 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 10:37:52 -0400 Subject: [PATCH 25/47] Update language and add debug --- messages/en-US.json | 17 +++++++++-------- server/private/license/license.ts | 12 ++++++++++++ src/app/admin/license/page.tsx | 27 ++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 37d1390e6..f7f4c05f6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -576,10 +576,10 @@ "licenseQuestionRemove": "Are you sure you want to delete the license key ?", "licenseKeyDelete": "Delete License Key", "licenseKeyDeleteConfirm": "Confirm Delete License Key", - "licenseTitle": "Manage License Status", + "licenseTitle": "Manage Licenses", "licenseTitleDescription": "View and manage license keys in the system", - "licenseHost": "Host License", - "licenseHostDescription": "Manage the main license key for the host.", + "licenseHost": "License", + "licenseHostDescription": "See the license tier and host information", "licensedNot": "Not Licensed", "hostId": "Host ID", "licenseReckeckAll": "Recheck All Keys", @@ -589,15 +589,16 @@ "licensePurchase": "Purchase License", "licensePurchaseSites": "Purchase Additional Sites", "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", - "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", - "licenseUsage": "License Usage", - "licenseUsageDescription": "View the number of users and sites licensed for this host.", + "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}}", + "licenseUsage": "Usage", + "licenseUsageDescription": "View the number of users and sites licensed for this host", "licenseUsageSites": "Sites", "licenseUsageUsers": "Users", - "licenseNoUserLimit": "There is no limit on the number of users using an unlicensed host.", + "licenseNoUserLimit": "There is no limit on the number of users using an unlicensed hot", "licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used", - "licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}} in system.", + "licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}}", "licenseUnlimited": "Unlimited", + "licenseTierLabel": "Tier", "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", "licenseFee": "License fee", "licensePriceSite": "Price per site", diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 0ff77bc4f..25eac8bc5 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -297,6 +297,11 @@ LQIDAQAB if (!apiResponse?.success) { throw new Error(apiResponse?.error); } + + logger.debug( + `License server response: ${JSON.stringify(apiResponse)}` + ); + // Reset failure count on success this.phoneHomeFailureCount = 0; } catch (e) { @@ -359,6 +364,11 @@ LQIDAQAB licenseKeyRes, this.publicKey ); + + logger.debug( + `Decoded license key ${key.licenseKey}: ${JSON.stringify(payload)}` + ); + cached.valid = payload.valid; cached.type = payload.type; cached.tier = payload.tier; @@ -451,6 +461,8 @@ LQIDAQAB this.checkInProgress = false; } + logger.debug(`Computed license status: ${JSON.stringify(status)}`); + this.statusCache.set(this.statusKey, status, 0); return status; } diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index 33cf86d96..8ed2d697d 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect } from "react"; -import { LicenseKeyCache } from "@server/license/license"; +import { LicenseKeyCache, LicenseKeyTier } from "@server/license/license"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -65,6 +65,23 @@ const ENTERPRISE_DOCS_URL = "https://docs.pangolin.net/self-host/enterprise-edition"; const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted"; +function getTierLabel( + tier: LicenseKeyTier | undefined, + t: (key: string) => string +): string { + switch (tier) { + case "enterprise": + return t("licenseTierEnterprise"); + case "tier1": + return t("licenseTierTier1"); + case "tier2": + return t("licenseTierTier2"); + case "personal": + default: + return t("licenseTierPersonal"); + } +} + export default function LicensePage() { const api = createApiClient(useEnvContext()); const [rows, setRows] = useState([]); @@ -514,6 +531,14 @@ export default function LicensePage() {
)}
+
+
+ {t("licenseTierLabel")} +
+
+ {getTierLabel(licenseStatus?.tier, t)} +
+
{licenseStatus?.hostId && (
From 70f677a2775c3d17c9d2c970cafc7938464e6ea8 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 10:40:47 -0400 Subject: [PATCH 26/47] Don't allow personal and non-personal keys at the same time --- server/private/license/license.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 25eac8bc5..16a62c042 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -401,6 +401,35 @@ LQIDAQAB } } + // Personal-tier licenses cannot coexist with a paid tier: if any + // valid host key is above personal, personal-tier keys are + // invalidated so they don't contribute to the totals below. + const hasHigherTierValidKey = keys.some((key) => { + const cached = newCache.get(key.licenseKey)!; + return ( + cached.type === "host" && + cached.valid && + tierRank(cached.tier) > tierRank("personal") + ); + }); + + if (hasHigherTierValidKey) { + for (const key of keys) { + const cached = newCache.get(key.licenseKey)!; + if ( + cached.type === "host" && + cached.valid && + cached.tier === "personal" + ) { + logger.debug( + `Invalidating personal license key ${key.licenseKey} because a higher tier license is present` + ); + cached.valid = false; + newCache.set(key.licenseKey, cached); + } + } + } + // Compute host status: quantity = users, quantity_2 = sites // When multiple host keys are active, prefer a valid key over an // invalid one, and among equally-valid keys prefer the highest tier. From 5fd4383396ffb69ba238434a6984335e63973a54 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 22 Sep 2026 10:50:58 -0400 Subject: [PATCH 27/47] Show information about how to get more licenses and when you have them --- messages/en-US.json | 6 +++++- src/app/admin/license/page.tsx | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index f7f4c05f6..38df95476 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -235,7 +235,7 @@ "privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "licenseBillingBannerTitle": "Manage License Billing", - "licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.", + "licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page. You can generate additional licenses below to increase server capacity or deploy more instances.", "licenseBillingBannerButton": "Go to Billing", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", @@ -558,6 +558,10 @@ "licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.", "licenseBannerGetLicense": "Get a License", "licenseBannerViewDocs": "View Documentation", + "licenseUpgradeBannerTitle": "Need More Capacity?", + "licenseUpgradeBannerDescription": "If you need to increase your site or user capacity, you can buy more licenses from the app.pangolin.net portal and add them below to upgrade your instance.", + "licenseUpgradeBannerButton": "Buy More Licenses", + "licenseMultipleKeysDescription": "You have multiple licenses activated on this server. The highest tier is used.", "communityEdition": "Community Edition", "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", "licenseKeyActivated": "License key activated", diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index 8ed2d697d..cdc564297 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -49,6 +49,7 @@ import { ExternalLink, Heart, InfoIcon, + ShoppingCart, TicketCheck } from "lucide-react"; import Link from "next/link"; @@ -64,6 +65,8 @@ import { useTranslations } from "next-intl"; const ENTERPRISE_DOCS_URL = "https://docs.pangolin.net/self-host/enterprise-edition"; const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted"; +const LICENSE_PORTAL_URL = + "https://app.pangolin.net/auth/login?internal_redirect=/settings/license?generate"; function getTierLabel( tier: LicenseKeyTier | undefined, @@ -392,6 +395,33 @@ export default function LicensePage() { )} + {licenseStatus?.isLicenseValid && rows.length > 0 && ( + + } + description={t("licenseUpgradeBannerDescription")} + > + + + + + )} + {/* */} {/* */} {/* */} @@ -538,6 +568,11 @@ export default function LicensePage() {
{getTierLabel(licenseStatus?.tier, t)}
+ {rows.length > 1 && ( +
+ {t("licenseMultipleKeysDescription")} +
+ )}
{licenseStatus?.hostId && (
From 6ec37220d6db884f0a4ef872d9941b90c3b89dd5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 23 Sep 2026 10:33:49 -0400 Subject: [PATCH 28/47] 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 29/47] 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 30/47] 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 31/47] 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 32/47] 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 33/47] 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 34/47] 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 35/47] 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 36/47] 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 37/47] 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 38/47] 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 39/47] 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 40/47] 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 41/47] 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 42/47] 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 55e479d25fb1fa30dd28b1cd6c2c5e1428fcb0d2 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 25 Sep 2026 09:30:51 -0400 Subject: [PATCH 43/47] Fix #3810 --- .../PolicyAccessRulesTable.tsx | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index dba282342..f2d182c73 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -54,11 +54,15 @@ import { GripVertical, LockIcon } from "lucide-react"; +import { useCommandState } from "cmdk"; import { useTranslations } from "next-intl"; import { useCallback, + useEffect, useMemo, + useRef, useState, + type ComponentProps, type DragEvent, type ReactNode } from "react"; @@ -114,6 +118,36 @@ function getColumnClassName(columnId: string) { return ""; } +// cmdk keeps the list's scroll offset when the search text changes, so a +// filtered list can be left scrolled past its (few) matches and look empty. +// Jump back to the top whenever the search changes. +function ResettingCommandList(props: ComponentProps) { + const search = useCommandState((state) => state.search); + const listRef = useRef(null); + + useEffect(() => { + listRef.current?.scrollTo({ top: 0 }); + }, [search]); + + return ; +} + +// Plain case-insensitive substring match on the country name or code, with +// prefix matches ranked first. cmdk's default fuzzy scorer matches unrelated +// countries (e.g. "Russia") and buries the exact one. +function filterCountry(value: string, search: string, keywords?: string[]) { + const query = search.trim().toLowerCase(); + if (!query) { + return 1; + } + const name = value.toLowerCase(); + const code = keywords?.[0]?.toLowerCase() ?? ""; + if (name.startsWith(query) || code === query) { + return 1; + } + return name.includes(query) ? 0.5 : 0; +} + // A METHOD rule stores its methods as a comma-separated list in rule.value, // e.g. "POST,PUT". Only the common methods are offered here; a value set // through a blueprint or the API may contain other methods (the WebDAV verbs, @@ -155,7 +189,9 @@ function RuleMethodSelect({ className="w-full min-w-0 justify-between" > - {selected.length > 0 ? selected.join(", ") : placeholder} + {selected.length > 0 + ? selected.join(", ") + : placeholder} @@ -616,11 +652,11 @@ export function PolicyAccessRulesTable({ - + - + {t("noCountryFound")} @@ -629,6 +665,7 @@ export function PolicyAccessRulesTable({ updateRule( row.original.ruleId, @@ -653,7 +690,7 @@ export function PolicyAccessRulesTable({ ))} - + @@ -686,7 +723,7 @@ export function PolicyAccessRulesTable({ - + No ASN found. Enter a custom ASN below. @@ -714,7 +751,7 @@ export function PolicyAccessRulesTable({ ))} - +
- + {t("noRegionFound")} @@ -848,7 +885,7 @@ export function PolicyAccessRulesTable({ )} ))} - + From 90848581a105b2997b0e7e2de625cec3ba452605 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 25 Sep 2026 11:51:38 -0400 Subject: [PATCH 44/47] Push gateway updates to clients --- server/lib/rebuildClientAssociations.ts | 40 ++++++++++++ server/routers/olm/gateway.ts | 82 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 server/routers/olm/gateway.ts diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 012c391bf..02b84c993 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -23,6 +23,10 @@ import { import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm"; import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; +import { + sendGatewayDisable, + sendGatewaySitesUpdate +} from "@server/routers/olm/gateway"; import { initPeerAddHandshakeBatch, deletePeersBatch as olmDeletePeersBatch @@ -536,6 +540,19 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ); } + // A client that loses access to a gateway resource (it was deleted, or the + // client's roles/users/machines no longer include it) can't keep using it + // as its gateway. The olm ignores this unless it selected this resource. + if ( + siteResource.mode === "gateway" && + clientSiteResourcesToRemove.length > 0 + ) { + await sendGatewayDisable( + clientSiteResourcesToRemove, + siteResource.siteResourceId + ); + } + /////////// process the client-site associations /////////// logger.debug( @@ -2056,6 +2073,29 @@ export async function handleMessagingForUpdatedSiteResource( ); } + // The olm only knows which gateway resource it selected and the sites it + // is currently using for it, so tell the clients that have access to this + // one what changed. Clients that lost access are handled by the rebuild. + if (existingSiteResource?.mode === "gateway") { + const clientIds = mergedAllClients.map((c) => c.clientId); + if ( + updatedSiteResource.mode !== "gateway" || + !updatedSiteResource.enabled + ) { + await sendGatewayDisable( + clientIds, + updatedSiteResource.siteResourceId + ); + } else { + await sendGatewaySitesUpdate( + clientIds, + updatedSiteResource.siteResourceId, + addedSiteIds, + removedSiteIds + ); + } + } + logger.debug( `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` ); diff --git a/server/routers/olm/gateway.ts b/server/routers/olm/gateway.ts new file mode 100644 index 000000000..d6a421c3e --- /dev/null +++ b/server/routers/olm/gateway.ts @@ -0,0 +1,82 @@ +import { sendToClientsBatch } from "#dynamic/routers/ws"; +import { db, olms } from "@server/db"; +import { canCompress } from "@server/lib/clientVersionChecks"; +import logger from "@server/logger"; +import { inArray } from "drizzle-orm"; + +// The olm only tracks which gateway (exit node) site resource it selected, by +// its numeric siteResourceId, and the site IDs that resource currently +// resolves to. So all the server has to push is what changed for that one +// resource; the olm ignores a message whose siteResourceId isn't the one it +// selected, which stops a site added to some other gateway resource from +// being pulled into the client's gateway set. (Creates aren't pushed: a +// client has to select a gateway resource before it can be using it.) + +async function sendGatewayMessageToClients( + clientIds: number[], + type: string, + data: Record +): Promise { + const uniqueClientIds = Array.from(new Set(clientIds)); + if (uniqueClientIds.length === 0) { + return; + } + + const olmRows = await db + .select({ + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, uniqueClientIds)); + + const payloads = olmRows.map((olm) => ({ + clientId: olm.olmId, + message: { type, data }, + options: { + compress: canCompress(olm.version, "olm"), + incrementConfigVersion: true + } + })); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads).catch((error) => { + logger.error(`Error sending ${type} messages to olms:`, error); + }); +} + +// Tells the olms of the given clients that sites were added to / removed from +// the gateway site resource, so those that selected it can adjust the set of +// sites they use as the gateway. +export async function sendGatewaySitesUpdate( + clientIds: number[], + siteResourceId: number, + addedSiteIds: number[], + removedSiteIds: number[] +): Promise { + if (addedSiteIds.length === 0 && removedSiteIds.length === 0) { + return; + } + + await sendGatewayMessageToClients( + clientIds, + "olm/wg/gateway/sites/update", + { siteResourceId, addedSiteIds, removedSiteIds } + ); +} + +// Tells the olms of the given clients that the gateway site resource can no +// longer be used as a gateway (it was deleted, disabled, changed to another +// mode, or the client lost access to it), so those that selected it drop out +// of gateway mode. +export async function sendGatewayDisable( + clientIds: number[], + siteResourceId: number +): Promise { + await sendGatewayMessageToClients(clientIds, "olm/wg/gateway/disable", { + siteResourceId + }); +} From fa3759981917a56e38350c9c66c3fa3bc265be3d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 28 Sep 2026 09:24:49 -0400 Subject: [PATCH 45/47] update mac models --- server/db/mac_models.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/db/mac_models.json b/server/db/mac_models.json index 6d9b837d5..bb9621995 100644 --- a/server/db/mac_models.json +++ b/server/db/mac_models.json @@ -38,6 +38,9 @@ "Mac16,7": "MacBook Pro", "Mac16,8": "MacBook Pro", "Mac16,9": "Mac Studio", + "Mac17,14": "Mac Studio", + "Mac17,15": "Mac Studio", + "Mac17,16": "Mac mini", "Mac17,2": "MacBook Pro", "Mac17,3": "MacBook Air", "Mac17,4": "MacBook Air", @@ -46,6 +49,7 @@ "Mac17,7": "MacBook Pro", "Mac17,8": "MacBook Pro", "Mac17,9": "MacBook Pro", + "Mac18,5": "Mac mini", "MacBook1,1": "MacBook", "MacBook10,1": "MacBook", "MacBook2,1": "MacBook", @@ -173,7 +177,7 @@ "PowerMac12,1": "iMac", "PowerMac2,1": "iMac", "PowerMac2,2": "iMac", - "PowerMac3,1": "Mac Server", + "PowerMac3,1": "Power Macintosh", "PowerMac3,3": "Power Macintosh", "PowerMac3,4": "Power Macintosh", "PowerMac3,5": "Power Macintosh", @@ -232,4 +236,4 @@ "iMac8,1": "iMac", "iMac9,1": "iMac", "iMacPro1,1": "iMac Pro" -} +} \ No newline at end of file From 397fcbf4c47912b3f83cb19b1a80ebe7d6d6b7f8 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 28 Sep 2026 11:29:53 -0400 Subject: [PATCH 46/47] Show the right tier in the banner --- messages/en-US.json | 2 +- src/app/[orgId]/settings/(private)/billing/page.tsx | 5 ++--- src/components/TrialBillingBanner.tsx | 11 ++++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 1cfee968e..cfa151c90 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -26,7 +26,7 @@ "trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.", "trialBannerExpired": "Your trial has expired. Upgrade now to restore access.", "billingTrialBannerTitle": "Free Trial Active", - "billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.", + "billingTrialBannerDescription": "You're currently on a free trial on the {tier} tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.", "billingTrialBannerUpgrade": "Upgrade Now", "billingTrialBadge": "Free Trial", "trialActive": "Free Trial Active", diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 7869374cd..d60f300d5 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -524,6 +524,7 @@ export default function BillingPage() { }; const currentPlanId = getCurrentPlanId(); + const currentPlan = planOptions.find((p) => p.id === currentPlanId); const visiblePlanOptions = planOptions.filter( (plan) => plan.id !== "home" || currentPlanId === "home" @@ -873,10 +874,8 @@ export default function BillingPage() { {/* Trial Banner */} {isTrial && ( { - const currentPlan = planOptions.find( - (p) => p.id === currentPlanId - ); if (currentPlan?.tierType) { handleStartSubscription(currentPlan.tierType); } diff --git a/src/components/TrialBillingBanner.tsx b/src/components/TrialBillingBanner.tsx index 52fcb4873..4610f5520 100644 --- a/src/components/TrialBillingBanner.tsx +++ b/src/components/TrialBillingBanner.tsx @@ -1,6 +1,5 @@ "use client"; -import React from "react"; import { Button } from "@app/components/ui/button"; import { ClockIcon, ArrowRight } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -8,9 +7,13 @@ import DismissableBanner from "./DismissableBanner"; type TrialBillingBannerProps = { onUpgrade: () => void; + tierName: string; }; -export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => { +export const TrialBillingBanner = ({ + onUpgrade, + tierName +}: TrialBillingBannerProps) => { const t = useTranslations(); return ( @@ -19,7 +22,9 @@ export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => { version={1} title={t("billingTrialBannerTitle")} titleIcon={} - description={t("billingTrialBannerDescription")} + description={t("billingTrialBannerDescription", { + tier: tierName + })} dismissable={false} >