diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 790bdec85..5901fe797 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -2826,8 +2826,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": "Заглавия на заявката, изпратени до целевите сървъри. По едно на ред: Заглавие-Име: стойност", + "customResponseHeaders": "Персонализирани заглавия на отговора", + "customResponseHeadersDescription": "Заглавия на отговора, върнати на клиента. По едно на ред: Заглавие-Име: стойност", "headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.", "saveHealthCheck": "Запазване на проверка на здравето", "healthCheckSaved": "Проверка на здравето е запазена", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 7e660dd72..ab5e4bf2e 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -2826,8 +2826,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í 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/da-DK.json b/messages/da-DK.json index 9d12079b2..90acf2eb0 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -2826,8 +2826,10 @@ "retryAttempts": "Forsøg igen", "expectedResponseCodes": "Forventede svarkoder", "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sund status. Hvis den bliver stående tom, regnes 200-300 som sund.", - "customHeaders": "Brugerdefinerede headers", - "customHeadersDescription": "Headers som er adskilt med linje: Overskriftsnavn: værdi", + "customRequestHeaders": "Brugerdefinerede anmodnings-headers", + "customRequestHeadersDescription": "Anmodnings-headers som sendes til destinationerne. Én pr. linje: Header-navn: værdi", + "customResponseHeaders": "Brugerdefinerede svar-headers", + "customResponseHeadersDescription": "Svar-headers som sendes tilbage til klienten. Én pr. linje: Header-navn: værdi", "headersValidationError": "Header skal være i formatet: header-navn: værdi.", "saveHealthCheck": "Gem Sundhedstjek", "healthCheckSaved": "Sundhedstjek Gemt", diff --git a/messages/de-DE.json b/messages/de-DE.json index f26be1670..8c393d288 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2826,8 +2826,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 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/en-US.json b/messages/en-US.json index ed0ca7dba..6be431209 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2829,8 +2829,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 06b5b19a9..911b6c888 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2826,8 +2826,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 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 a2b7e933d..9204a0981 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -2826,8 +2826,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 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 46c60a440..5bb856c68 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -2826,8 +2826,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 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 34fb31369..ce2208228 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2826,8 +2826,10 @@ "retryAttempts": "재시도 횟수", "expectedResponseCodes": "예상 응답 코드", "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", - "customHeaders": "사용자 정의 헤더", - "customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", + "customRequestHeaders": "사용자 정의 요청 헤더", + "customRequestHeadersDescription": "다운스트림 대상으로 전송되는 요청 헤더. 한 줄에 하나씩: 헤더-이름: 값", + "customResponseHeaders": "사용자 정의 응답 헤더", + "customResponseHeadersDescription": "클라이언트로 반환되는 응답 헤더. 한 줄에 하나씩: 헤더-이름: 값", "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", "saveHealthCheck": "상태 확인 저장", "healthCheckSaved": "상태 확인이 저장되었습니다.", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 0d042e03d..dd06df0db 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2826,8 +2826,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 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 aafe7aabf..5f7ea14a0 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2826,8 +2826,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 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 4d291b0fe..a5b816e1f 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -2826,8 +2826,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 żą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 c7cb6dac7..fcdbc0b72 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -2826,8 +2826,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 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 484b789eb..6f625c89c 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -2826,8 +2826,10 @@ "retryAttempts": "Количество попыток повторного запроса", "expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", - "customHeaders": "Пользовательские заголовки", - "customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", + "customRequestHeaders": "Пользовательские заголовки запроса", + "customRequestHeadersDescription": "Заголовки запроса, отправляемые целевым серверам. По одному в строке: Имя-Заголовка: значение", + "customResponseHeaders": "Пользовательские заголовки ответа", + "customResponseHeadersDescription": "Заголовки ответа, возвращаемые клиенту. По одному в строке: Имя-Заголовка: значение", "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", "saveHealthCheck": "Сохранить проверку здоровья", "healthCheckSaved": "Проверка здоровья сохранена", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 970fd6d28..efa8a9eb7 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -2826,8 +2826,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 İ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 804efda2b..dc4467d6f 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -2826,8 +2826,10 @@ "retryAttempts": "重试次数", "expectedResponseCodes": "期望响应代码", "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", - "customHeaders": "自定义标题", - "customHeadersDescription": "头部新行分隔:头部名称:值", + "customRequestHeaders": "自定义请求标头", + "customRequestHeadersDescription": "转发到目标的请求标头。每行一个:标头名称:值", + "customResponseHeaders": "自定义响应标头", + "customResponseHeadersDescription": "返回给客户端的响应标头。每行一个:标头名称:值", "headersValidationError": "头部必须是格式:头部名称:值。", "saveHealthCheck": "保存健康检查", "healthCheckSaved": "健康检查已保存", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 9e32a32f6..8f25e307d 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1551,8 +1551,10 @@ "retryAttempts": "重試次數", "expectedResponseCodes": "期望響應代碼", "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", - "customHeaders": "自訂 Headers", - "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "customRequestHeaders": "自訂請求 Headers", + "customRequestHeadersDescription": "轉發至目標的請求標頭。每行一個:Header-名稱:值", + "customResponseHeaders": "自訂回應 Headers", + "customResponseHeadersDescription": "回傳給客戶端的回應標頭。每行一個:Header-名稱:值", "headersValidationError": "Header 必須是格式:Header 名稱:值。", "saveHealthCheck": "保存健康檢查", "healthCheckSaved": "健康檢查已保存", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6569d48b9..9ae0e76fa 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -186,7 +186,8 @@ export const resources = pgTable( 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), maintenanceModeEnabled: boolean("maintenanceModeEnabled") diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6201e695d..f14e9d8b1 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -202,7 +202,8 @@ export const resources = sqliteTable( 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/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts index 55e0013ae..719671d97 100644 --- a/server/lib/blueprints/publicResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -261,10 +261,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 (resourceData.policy) { const isLicensed = await isLicensedOrSubscribed( @@ -403,7 +411,8 @@ export async function updatePublicResources( ? resourceData.auth["whitelist-users"].length > 0 : false, - headers: headers || null, + requestHeaders, + responseHeaders, applyRules: resourceData.rules && resourceData.rules.length > 0, @@ -599,7 +608,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, @@ -1222,7 +1232,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/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 80c4aeb87..9049e4dc6 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -326,7 +326,9 @@ export const PublicResourceSchema = 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(), "auth-daemon": AuthDaemonSchema.optional(), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index ae8554597..47f665794 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -96,7 +96,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, @@ -225,7 +226,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 @@ -405,7 +407,8 @@ export async function getTraefikConfig( // Handle custom headers middleware const customHeadersMiddleware = buildCustomHeadersMiddleware( - resource.headers, + resource.requestHeaders, + resource.responseHeaders, resource.setHostHeader, resource.resourceId ); diff --git a/server/lib/traefik/headersMiddleware.ts b/server/lib/traefik/headersMiddleware.ts index 84f34dff4..20c775e23 100644 --- a/server/lib/traefik/headersMiddleware.ts +++ b/server/lib/traefik/headersMiddleware.ts @@ -1,46 +1,78 @@ import logger from "@server/logger"; +function parseHeaders( + headers: string, + label: string, + resourceId: number +): { name: string; value: string }[] { + try { + return JSON.parse(headers) as { + name: string; + value: string; + }[]; + } catch (e) { + logger.warn( + `Failed to parse ${label} for resource ${resourceId}: ${e}` + ); + return []; + } +} + /** - * Build the customRequestHeaders middleware definition for a resource's - * custom headers + setHostHeader config. Returns null when there are no - * headers to set, so the caller can skip attaching the middleware. + * Build the custom headers middleware definition for a resource's + * custom request/response headers + setHostHeader config. Returns null when + * there are no headers to set, so the caller can skip attaching the + * middleware. */ export function buildCustomHeadersMiddleware( - headers: string | null | undefined, + requestHeaders: string | null | undefined, + responseHeaders: string | null | undefined, setHostHeader: string | null | undefined, resourceId: number -): { headers: { customRequestHeaders: { [key: string]: string } } } | null { - const headersObj: { [key: string]: string } = {}; +): { + headers: { + customRequestHeaders?: { [key: string]: string }; + customResponseHeaders?: { [key: string]: string }; + }; +} | null { + const requestHeadersObj: { [key: string]: string } = {}; + const responseHeadersObj: { [key: string]: string } = {}; - if (headers) { - let headersArr: { name: string; value: string }[] = []; - try { - headersArr = JSON.parse(headers) as { - name: string; - value: string; - }[]; - } catch (e) { - logger.warn( - `Failed to parse headers for resource ${resourceId}: ${e}` - ); - } - - headersArr.forEach((header) => { - headersObj[header.name] = header.value; - }); + if (requestHeaders) { + parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach( + (header) => { + requestHeadersObj[header.name] = header.value; + } + ); } if (setHostHeader) { - headersObj["Host"] = setHostHeader; + requestHeadersObj["Host"] = setHostHeader; } - if (Object.keys(headersObj).length === 0) { + if (responseHeaders) { + parseHeaders(responseHeaders, "responseHeaders", resourceId).forEach( + (header) => { + responseHeadersObj[header.name] = header.value; + } + ); + } + + const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0; + const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0; + + if (!hasRequestHeaders && !hasResponseHeaders) { return null; } return { 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 70a42b42f..a98368376 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -133,7 +133,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, @@ -275,7 +276,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 @@ -712,7 +714,8 @@ export async function getTraefikConfig( ); const customHeadersMiddleware = buildCustomHeadersMiddleware( - resource.headers, + resource.requestHeaders, + resource.responseHeaders, resource.setHostHeader, resource.resourceId ); diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index b1f4108b3..90b949a33 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -53,9 +53,10 @@ async function queryInlinePolicy(resourcePolicyId: number) { 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({ @@ -186,9 +187,12 @@ export async function getResource( return response(res, { data: { ...returnData, - headers: returnData.headers - ? JSON.parse(returnData.headers) - : returnData.headers + requestHeaders: returnData.requestHeaders + ? JSON.parse(returnData.requestHeaders) + : returnData.requestHeaders, + responseHeaders: returnData.responseHeaders + ? JSON.parse(returnData.responseHeaders) + : returnData.responseHeaders }, success: true, error: false, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 6ecc22ce7..8734b8c67 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -100,6 +100,14 @@ const updateHttpResourceBodySchema = z "When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource." ), 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() + .optional(), + responseHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() .optional(), @@ -163,12 +171,13 @@ 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.headers ?? []), + ...(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 !#$%&'*+-.^_`|~)." @@ -176,14 +185,13 @@ 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.headers ?? []), + ...(data.requestHeaders ?? []), + ...(data.responseHeaders ?? []) + ]; + return allHeaders.every((h) => validHeaderValue.test(h.value)); }, { error: "Header values may only contain printable ASCII characters and horizontal whitespace." @@ -191,16 +199,17 @@ 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.headers ?? []), + ...(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}}." @@ -695,13 +704,32 @@ async function updateHttpResource( await createCertificate(domainId, fullDomain, db); } - let headers = undefined; - if (updateData.headers) { - headers = JSON.stringify(updateData.headers); - } else if (updateData.headers === null) { - headers = null; + let requestHeaders = undefined; + const mergedRequestHeaders = [ + ...(updateData.headers ?? []), + ...(updateData.requestHeaders ?? []) + ]; + if ( + updateData.headers !== undefined || + updateData.requestHeaders !== undefined + ) { + requestHeaders = + mergedRequestHeaders.length > 0 + ? JSON.stringify(mergedRequestHeaders) + : null; } + let responseHeaders = undefined; + if (updateData.responseHeaders) { + responseHeaders = JSON.stringify(updateData.responseHeaders); + } else if (updateData.responseHeaders === null) { + responseHeaders = null; + } + + updateData.headers = undefined; + updateData.requestHeaders = undefined; + updateData.responseHeaders = undefined; + if (!isLicensed) { updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeType = undefined; @@ -754,7 +782,7 @@ async function updateHttpResource( const updatedResource = await db .update(resources) - .set({ ...resourceOnlyData, headers }) + .set({ ...resourceOnlyData, requestHeaders, responseHeaders }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); @@ -778,7 +806,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/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx index a38176210..40330e51b 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/http/page.tsx @@ -119,7 +119,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() }); @@ -131,19 +134,21 @@ function ProxyResourceHttpForm({ ssl: resource.ssl, tlsServerName: resource.tlsServerName || "", setHostHeader: resource.setHostHeader || "", - headers: resource.headers + requestHeaders: resource.requestHeaders, + responseHeaders: resource.responseHeaders }, mode: "onChange" }); const [, formAction, saveLoading] = useActionState(onSubmit, null); - const [headersValid, setHeadersValid] = useState(true); + const [requestHeadersValid, setRequestHeadersValid] = useState(true); + const [responseHeadersValid, setResponseHeadersValid] = useState(true); async function onSubmit() { const isValid = await form.trigger(); if (!isValid) return; - if (!headersValid) { + if (!requestHeadersValid || !responseHeadersValid) { toast({ variant: "destructive", title: t("settingsErrorUpdate"), @@ -162,7 +167,8 @@ function ProxyResourceHttpForm({ ssl: data.ssl, tlsServerName: data.tlsServerName || null, setHostHeader: data.setHostHeader || null, - headers: data.headers || null + requestHeaders: data.requestHeaders || null, + responseHeaders: data.responseHeaders || null } ) .catch((err) => { @@ -183,7 +189,8 @@ function ProxyResourceHttpForm({ ssl: data.ssl, tlsServerName: data.tlsServerName || null, setHostHeader: data.setHostHeader || null, - headers: data.headers || null + requestHeaders: data.requestHeaders || null, + responseHeaders: data.responseHeaders || null }); toast({ @@ -324,11 +331,11 @@ function ProxyResourceHttpForm({ ( - {t("customHeaders")} + {t("customRequestHeaders")} {t( - "customHeadersDescription" + "customRequestHeadersDescription" + )} + + + + )} + /> + + + + ( + + + {t("customResponseHeaders")} + + + + + + {t( + "customResponseHeadersDescription" )} @@ -362,7 +401,11 @@ function ProxyResourceHttpForm({