Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] ae6f501756 Bump the docker-dependencies group across 1 directory with 2 updates
Bumps the docker-dependencies group with 2 updates in the / directory: docker/library/node and node.


Updates `docker/library/node` from 24.18.1-slim to 26.9.0-slim

Updates `node` from 24.18.1-alpine to 26.9.0-alpine

---
updated-dependencies:
- dependency-name: docker/library/node
  dependency-version: 26.9.0-slim
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
- dependency-name: node
  dependency-version: 26.9.0-alpine
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 01:34:09 +00:00
84 changed files with 384 additions and 5327 deletions
+11 -18
View File
@@ -3,25 +3,18 @@ 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:
workflow_dispatch:
pull_request:
paths:
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '.eslintrc*'
- 'package.json'
- 'yarn.lock'
- 'pnpm-lock.yaml'
- 'package-lock.json'
jobs:
Linter:
+2 -2
View File
@@ -1,5 +1,5 @@
# FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:26.9.0-slim AS base
WORKDIR /app
@@ -33,7 +33,7 @@ FROM base AS builder
RUN npm ci --omit=dev
# FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:26.9.0-slim AS runner
WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine
FROM node:26.9.0-alpine
WORKDIR /app
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "Опити за повторно",
"expectedResponseCodes": "Очаквани кодове за отговор",
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
"customRequestHeaders": "Персонализирани заглавия на заявката",
"customRequestHeadersDescription": "Заглавия на заявката, изпратени до целевите сървъри. По едно на ред: Заглавие-Име: стойност",
"customResponseHeaders": "Персонализирани заглавия на отговора",
"customResponseHeadersDescription": "Заглавия на отговора, върнати на клиента. По едно на ред: Заглавие-Име: стойност",
"customHeaders": "Персонализирани заглавия",
"customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value",
"headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.",
"saveHealthCheck": "Запазване на проверка на здравето",
"healthCheckSaved": "Проверка на здравето е запазена",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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í 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",
"customHeaders": "Vlastní záhlaví",
"customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota",
"headersValidationError": "Headers must be in the format: Header-Name: value.",
"saveHealthCheck": "Uložit kontrolu stavu",
"healthCheckSaved": "Kontrola stavu uložena",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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.",
"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",
"customHeaders": "Brugerdefinerede headers",
"customHeadersDescription": "Headers som er adskilt med linje: Overskriftsnavn: værdi",
"headersValidationError": "Header skal være i formatet: header-navn: værdi.",
"saveHealthCheck": "Gem Sundhedstjek",
"healthCheckSaved": "Sundhedstjek Gemt",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "Wiederholungsversuche",
"expectedResponseCodes": "Erwartete Antwortcodes",
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
"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",
"customHeaders": "Eigene Kopfzeilen",
"customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert",
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
"saveHealthCheck": "Gesundheits-Check speichern",
"healthCheckSaved": "Gesundheits-Check gespeichert",
+10 -82
View File
@@ -132,7 +132,7 @@
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
"siteRestarted": "Site restarted",
"siteRestartedDescription": "The site has been restarted.",
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
"siteErrorRestart": "Failed to restart site",
"siteErrorRestartDescription": "An error occurred while restarting the site.",
"siteSettingDescription": "Configure the settings on the site",
@@ -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. You can generate additional licenses below to increase server capacity or deploy more instances.",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
@@ -558,10 +558,6 @@
"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",
@@ -580,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 Licenses",
"licenseTitle": "Manage License Status",
"licenseTitleDescription": "View and manage license keys in the system",
"licenseHost": "License",
"licenseHostDescription": "See the license tier and host information",
"licenseHost": "Host License",
"licenseHostDescription": "Manage the main license key for the host.",
"licensedNot": "Not Licensed",
"hostId": "Host ID",
"licenseReckeckAll": "Recheck All Keys",
@@ -593,16 +589,7 @@
"licensePurchase": "Purchase License",
"licensePurchaseSites": "Purchase Additional Sites",
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
"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 hot",
"licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used",
"licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}}",
"licenseUnlimited": "Unlimited",
"licenseTierLabel": "Tier",
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
"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",
@@ -847,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, ASN, or method).",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
"rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -2136,7 +2123,6 @@
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
"sidebarApiKeys": "API Keys",
"sidebarOrgs": "Organizations",
"sidebarRedirects": "Redirects",
"sidebarProvisioning": "Provisioning",
"sidebarSettings": "Settings",
"sidebarAllUsers": "Users",
@@ -2843,10 +2829,8 @@
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
"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",
"customHeaders": "Custom Request Headers",
"customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value",
"headersValidationError": "Headers must be in the format: Header-Name: value",
"saveHealthCheck": "Save Health Check",
"healthCheckSaved": "Health Check Saved",
@@ -3584,7 +3568,6 @@
"validEmail": "Valid email",
"validSSO": "Valid SSO",
"validVirtualAPIKey": "Valid Virtual API Key",
"allowedRedirect": "Allowed Redirect",
"view": "View",
"configManaged": "Config Managed",
"connectedClient": "Connected Client",
@@ -4417,60 +4400,5 @@
"sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals",
"check": "Check",
"redirectsTitle": "Manage Redirects",
"redirectsDescription": "Forward requests from a path on your domains or resources to another URL",
"redirectsSearch": "Search redirects...",
"redirectAdd": "Add Redirect",
"redirectSource": "Source",
"redirectDestination": "Destination",
"redirectAttachedTo": "Attached To",
"redirectType": "Type",
"redirectTypePermanent": "Permanent (308)",
"redirectTypeTemporary": "Temporary (307)",
"redirectUpdated": "Redirect updated successfully",
"redirectErrorUpdate": "Failed to update redirect",
"redirectDeleted": "Redirect deleted successfully",
"redirectErrorDelete": "Failed to delete redirect",
"redirectDelete": "Delete Redirect",
"redirectDeleteConfirm": "Confirm Delete Redirect",
"redirectQuestionRemove": "Are you sure you want to remove this redirect?",
"redirectMessageRemove": "Once removed, requests matching this redirect will no longer be forwarded.",
"redirectDestinationHost": "Destination",
"redirectDestinationHostDescription": "Where requests are sent, including the scheme, such as https://example.com",
"redirectDestinationHostRequired": "Enter a destination",
"redirectSameDomainAsSource": "Same domain as source",
"redirectSameDomainAsSourceDescription": "Keep the destination on the source domain and only change the path",
"redirectDestinationHostInvalid": "Enter a valid destination with a scheme, such as https://example.com",
"redirectMatchPathDescription": "Which incoming paths this redirect applies to",
"redirectRewritePathDescription": "Optionally change the path before redirecting. Leave unset to keep the original path.",
"redirectRewritePathRequired": "Enter a rewrite path, or choose Strip Prefix",
"redirectMatchPathInvalidRegex": "Match path must be a valid regular expression",
"redirectPriorityInvalid": "Enter a whole number between 1 and 1000",
"redirectPriorityDescription": "Higher priority routes are evaluated first. Redirects are always evaluated before targets.",
"redirectCreate": "Create Redirect",
"redirectCreateDescription": "Forward requests matching a path to another URL",
"redirectEditDescription": "Update how this redirect forwards incoming requests",
"redirectGoBack": "Back to Redirects",
"redirectCreated": "Redirect created successfully",
"redirectErrorCreate": "Failed to create redirect",
"redirectSettings": "Redirect Rule",
"selectedRedirectDomain": "Selected Domain",
"selectedRedirectResource": "Selected Resource",
"redirectResourceNoDomain": "This resource has no domain",
"redirectSourceSectionDescription": "Choose the domain or resource this redirect applies to",
"redirectSettingsDescription": "Set which paths to match and where to send them",
"redirectEnabledDescription": "Turn the redirect off to stop forwarding requests without deleting it",
"redirectAttachedToDescription": "Choose whether this redirect applies to a whole domain or a single resource",
"redirectAttachDomain": "Domain",
"redirectAttachResource": "Resource",
"redirectDomainRequired": "Select a domain to attach this redirect to",
"redirectResourceRequired": "Select a resource to attach this redirect to",
"redirectPermanent": "Permanent Redirect",
"redirectPermanentDescription": "Respond with 308 instead of 307. Permanent redirects are cached by browsers.",
"redirectSslDescription": "Serve this redirect over HTTPS. Turn off to match requests on plain HTTP.",
"redirectSslInheritedDescription": "Inherited from the attached resource's TLS setting.",
"rulesErrorInvalidMethod": "Invalid HTTP method",
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
"rulesSelectMethods": "Select methods"
"check": "Check"
}
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 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",
"customHeaders": "Cabeceras personalizadas",
"customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor",
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
"saveHealthCheck": "Guardar Chequeo de Salud",
"healthCheckSaved": "Chequeo de Salud Guardado",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 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",
"customHeaders": "En-têtes personnalisés",
"customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: 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",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 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",
"customHeaders": "Intestazioni Personalizzate",
"customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore",
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
"saveHealthCheck": "Salva Controllo Salute",
"healthCheckSaved": "Controllo Salute Salvato",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "재시도 횟수",
"expectedResponseCodes": "예상 응답 코드",
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
"customRequestHeaders": "사용자 정의 요청 헤더",
"customRequestHeadersDescription": "다운스트림 대상으로 전송되는 요청 헤더. 한 줄에 하나씩: 헤더-이름: 값",
"customResponseHeaders": "사용자 정의 응답 헤더",
"customResponseHeadersDescription": "클라이언트로 반환되는 응답 헤더. 한 줄에 하나씩: 헤더-이름: 값",
"customHeaders": "사용자 정의 헤더",
"customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value",
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
"saveHealthCheck": "상태 확인 저장",
"healthCheckSaved": "상태 확인이 저장되었습니다.",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 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",
"customHeaders": "Egendefinerte topptekster",
"customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi",
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
"saveHealthCheck": "Lagre Helsekontroll",
"healthCheckSaved": "Helsekontroll Lagret",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "Herhaal Pogingen",
"expectedResponseCodes": "Verwachte Reactiecodes",
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
"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",
"customHeaders": "Aangepaste headers",
"customHeadersDescription": "Kopregeleinde: Header-Naam: waarde",
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
"saveHealthCheck": "Opslaan Gezondheidscontrole",
"healthCheckSaved": "Gezondheidscontrole Opgeslagen",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 żą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ść",
"customHeaders": "Niestandardowe nagłówki",
"customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość",
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
"saveHealthCheck": "Zapisz Kontrolę Zdrowia",
"healthCheckSaved": "Kontrola Zdrowia Zapisana",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 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",
"customHeaders": "Cabeçalhos Personalizados",
"customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do 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",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "Количество попыток повторного запроса",
"expectedResponseCodes": "Ожидаемые коды ответов",
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
"customRequestHeaders": "Пользовательские заголовки запроса",
"customRequestHeadersDescription": "Заголовки запроса, отправляемые целевым серверам. По одному в строке: Имя-Заголовка: значение",
"customResponseHeaders": "Пользовательские заголовки ответа",
"customResponseHeadersDescription": "Заголовки ответа, возвращаемые клиенту. По одному в строке: Имя-Заголовка: значение",
"customHeaders": "Пользовательские заголовки",
"customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение",
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
"saveHealthCheck": "Сохранить проверку здоровья",
"healthCheckSaved": "Проверка здоровья сохранена",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"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 İ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",
"customHeaders": "Özel Başlıklar",
"customHeadersDescription": "Başlıklar yeni satırla ayrılmış: 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",
+2 -4
View File
@@ -2826,10 +2826,8 @@
"retryAttempts": "重试次数",
"expectedResponseCodes": "期望响应代码",
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
"customRequestHeaders": "自定义请求标头",
"customRequestHeadersDescription": "转发到目标的请求标头。每行一个:标头名称:值",
"customResponseHeaders": "自定义响应标头",
"customResponseHeadersDescription": "返回给客户端的响应标头。每行一个:标头名称:值",
"customHeaders": "自定义标题",
"customHeadersDescription": "头部新行分隔:头部名称:值",
"headersValidationError": "头部必须是格式:头部名称:值。",
"saveHealthCheck": "保存健康检查",
"healthCheckSaved": "健康检查已保存",
+2 -4
View File
@@ -1551,10 +1551,8 @@
"retryAttempts": "重試次數",
"expectedResponseCodes": "期望響應代碼",
"expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。",
"customRequestHeaders": "自訂請求 Headers",
"customRequestHeadersDescription": "轉發至目標的請求標頭。每行一個Header-名稱:值",
"customResponseHeaders": "自訂回應 Headers",
"customResponseHeadersDescription": "回傳給客戶端的回應標頭。每行一個:Header-名稱:值",
"customHeaders": "自訂 Headers",
"customHeadersDescription": "Header 斷行分隔Header 名稱:值",
"headersValidationError": "Header 必須是格式:Header 名稱:值。",
"saveHealthCheck": "保存健康檢查",
"healthCheckSaved": "健康檢查已保存",
+1 -6
View File
@@ -205,12 +205,7 @@ export enum ActionsEnum {
deleteVirtualApiKey = "deleteVirtualApiKey",
getVirtualApiKey = "getVirtualApiKey",
listVirtualApiKeys = "listVirtualApiKeys",
updateVirtualApiKey = "updateVirtualApiKey",
createRedirect = "createRedirect",
deleteRedirect = "deleteRedirect",
getRedirect = "getRedirect",
listRedirects = "listRedirects",
updateRedirect = "updateRedirect"
updateVirtualApiKey = "updateVirtualApiKey"
}
export async function checkUserActionPermission(
+43 -72
View File
@@ -4,18 +4,42 @@
"iPad2,2": "iPad 2",
"iPad2,3": "iPad 2",
"iPad2,4": "iPad 2",
"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,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",
"iPad4,4": "iPad mini 2",
"iPad4,5": "iPad mini 2",
"iPad4,6": "iPad mini 2",
@@ -24,22 +48,18 @@
"iPad4,9": "iPad mini 3",
"iPad5,1": "iPad mini 4",
"iPad5,2": "iPad mini 4",
"iPad5,3": "iPad Air 2",
"iPad5,4": "iPad Air 2",
"iPad6,3": "iPad Pro 9.7",
"iPad6,4": "iPad Pro 9.7",
"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",
"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",
"iPad6,3": "iPad Pro 9.7",
"iPad6,4": "iPad Pro 9.7",
"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",
"iPad7,1": "iPad Pro 12.9",
"iPad7,2": "iPad Pro 12.9",
"iPad8,1": "iPad Pro 11",
"iPad8,2": "iPad Pro 11",
"iPad8,3": "iPad Pro 11",
@@ -52,16 +72,6 @@
"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",
@@ -70,40 +80,14 @@
"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",
@@ -117,20 +101,20 @@
"iPhone5,4": "iPhone 5c",
"iPhone6,1": "iPhone 5s",
"iPhone6,2": "iPhone 5s",
"iPhone7,1": "iPhone 6 Plus",
"iPhone7,2": "iPhone 6",
"iPhone7,1": "iPhone 6 Plus",
"iPhone8,1": "iPhone 6s",
"iPhone8,2": "iPhone 6s Plus",
"iPhone8,4": "iPhone SE",
"iPhone9,1": "iPhone 7",
"iPhone9,2": "iPhone 7 Plus",
"iPhone9,3": "iPhone 7",
"iPhone9,2": "iPhone 7 Plus",
"iPhone9,4": "iPhone 7 Plus",
"iPhone10,1": "iPhone 8",
"iPhone10,2": "iPhone 8 Plus",
"iPhone10,3": "iPhone X",
"iPhone10,4": "iPhone 8",
"iPhone10,2": "iPhone 8 Plus",
"iPhone10,5": "iPhone 8 Plus",
"iPhone10,3": "iPhone X",
"iPhone10,6": "iPhone X",
"iPhone11,2": "iPhone Xs",
"iPhone11,6": "iPhone Xs Max",
@@ -143,10 +127,10 @@
"iPhone13,2": "iPhone 12",
"iPhone13,3": "iPhone 12 Pro",
"iPhone13,4": "iPhone 12 Pro Max",
"iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,6": "iPhone SE",
"iPhone14,7": "iPhone 14",
"iPhone14,8": "iPhone 14 Plus",
@@ -156,19 +140,6 @@
"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",
@@ -176,4 +147,4 @@
"iPod5,1": "iPod touch 5th",
"iPod7,1": "iPod touch 6th Gen",
"iPod9,1": "iPod touch 7th Gen"
}
}
-25
View File
@@ -4,7 +4,6 @@ import {
aiProviders,
clients,
db,
redirects,
resourcePolicies,
resources,
siteResources
@@ -141,30 +140,6 @@ export async function getUniqueProviderName(orgId: string): Promise<string> {
}
}
export async function getUniqueRedirectName(orgId: string): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const redirectCount = await db
.select({
niceId: redirects.niceId,
orgId: redirects.orgId
})
.from(redirects)
.where(and(eq(redirects.niceId, name), eq(redirects.orgId, orgId)));
if (redirectCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
+1 -40
View File
@@ -186,8 +186,7 @@ export const resources = pgTable(
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
requestHeaders: text("requestHeaders"),
responseHeaders: text("responseHeaders"),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
@@ -228,41 +227,6 @@ export const resources = pgTable(
]
);
export const redirects = pgTable("redirects", {
redirectId: serial("redirectId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
subdomain: varchar("subdomain"),
destinationHost: varchar("destinationHost").notNull(), // scheme://host[:port]
pathMatchType: varchar("pathMatchType")
.$type<"exact" | "prefix" | "regex">()
.notNull()
.default("regex"), // exact, prefix, regex
matchPath: varchar("matchPath"),
rewritePath: varchar("rewritePath"), // if set, rewrites the path to this value,
// else, the original path will be kept
rewritePathType: varchar("rewritePathType").$type<
"exact" | "prefix" | "regex" | "stripPrefix"
>(), // exact, prefix, regex, stripPrefix
priority: integer("priority").default(100),
permanent: boolean("permanent").notNull().default(false),
// Only consulted for domain-attached redirects; resource-attached ones
// inherit the resource's ssl setting.
ssl: boolean("ssl").notNull().default(true),
enabled: boolean("enabled").notNull().default(true)
});
export const resourceAiProviders = pgTable(
"resourceAiProviders",
{
@@ -1173,7 +1137,6 @@ export const resourceRules = pgTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: varchar("value").notNull()
@@ -1198,7 +1161,6 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(),
value: varchar("value").notNull()
@@ -2105,7 +2067,6 @@ export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
+2 -67
View File
@@ -35,12 +35,10 @@ import {
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth,
resourceWhitelist,
resourcePolicyWhiteList,
redirects,
domains
resourcePolicyWhiteList
} from "@server/db";
import { alias } from "@server/db";
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
export type ResourceWithAuth = {
@@ -55,74 +53,11 @@ export type ResourceWithAuth = {
org: Org;
};
export type RedirectByHost = {
redirectId: number;
orgId: string;
matchPath: string | null;
pathMatchType: string;
destinationHost: string;
rewritePath: string | null;
rewritePathType: string | null;
permanent: boolean;
priority: number | null;
};
export type UserSessionWithUser = {
session: any;
user: any;
};
/**
* Enabled redirects listening on the given host, highest priority first.
* A redirect listens on its resource's fullDomain when attached to one,
* otherwise on subdomain.baseDomain (or the bare baseDomain) of its domain.
* Mirrors the host resolution in getTraefikConfig so badger agrees with
* what Traefik routed.
*/
export async function getRedirectsByHost(
host: string
): Promise<RedirectByHost[]> {
// A literal "*." leading label matches any single subdomain, like
// wildcard resources do.
const parts = host.split(".");
const candidates = [host];
for (let i = 1; i < parts.length; i++) {
candidates.push(`*.${parts.slice(i).join(".")}`);
}
const redirectHost = sql<string>`case
when ${redirects.resourceId} is not null then ${resources.fullDomain}
when ${redirects.subdomain} is null then ${domains.baseDomain}
else ${redirects.subdomain} || '.' || ${domains.baseDomain}
end`;
return db
.select({
redirectId: redirects.redirectId,
orgId: redirects.orgId,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
destinationHost: redirects.destinationHost,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
permanent: redirects.permanent,
priority: redirects.priority
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(
eq(redirects.enabled, true),
// Traefik drops resource-attached redirects along with a
// disabled resource; do the same here.
or(isNull(redirects.resourceId), eq(resources.enabled, true)),
inArray(redirectHost, candidates)
)
)
.orderBy(desc(redirects.priority));
}
/**
* Get resource by domain with pincode and password information
*/
+1 -42
View File
@@ -202,8 +202,7 @@ export const resources = sqliteTable(
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
requestHeaders: text("requestHeaders"),
responseHeaders: text("responseHeaders"),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
@@ -244,43 +243,6 @@ export const resources = sqliteTable(
(table) => [index("idx_resources_orgId").on(table.orgId)]
);
export const redirects = sqliteTable("redirects", {
redirectId: integer("redirectId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
subdomain: text("subdomain"),
destinationHost: text("destinationHost").notNull(), // scheme://host[:port]
pathMatchType: text("pathMatchType")
.$type<"exact" | "prefix" | "regex">()
.notNull()
.default("regex"), // exact, prefix, regex
matchPath: text("matchPath"),
rewritePath: text("rewritePath"), // if set, rewrites the path to this value,
// else, the original path will be kept
rewritePathType: text("rewritePathType").$type<
"exact" | "prefix" | "regex" | "stripPrefix"
>(), // exact, prefix, regex, stripPrefix
priority: integer("priority").default(100),
permanent: integer("permanent", { mode: "boolean" })
.notNull()
.default(false),
// Only consulted for domain-attached redirects; resource-attached ones
// inherit the resource's ssl setting.
ssl: integer("ssl", { mode: "boolean" }).notNull().default(true),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
{
@@ -1447,7 +1409,6 @@ export const resourceRules = sqliteTable("resourceRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(), // CIDR, PATH, IP
value: text("value").notNull()
@@ -1504,7 +1465,6 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
| "METHOD"
>()
.notNull(),
value: text("value").notNull()
@@ -2146,7 +2106,6 @@ export type ResourcePolicyHeaderAuth = InferSelectModel<
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type Redirect = InferSelectModel<typeof redirects>;
export type AiProvider = InferSelectModel<typeof aiProviders>;
export type AiModel = InferSelectModel<typeof aiModels>;
export type AiBudget = InferSelectModel<typeof aiBudgets>;
@@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => {
}
]
: []),
{ label: "Time (UTC)", value: new Date().toUTCString() },
{ label: "Time", value: new Date().toUTCString() },
...dataItems
];
@@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => {
</EmailText>
{isTestAlert && (
<EmailText>
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.
</EmailText>
)}
-1
View File
@@ -3,4 +3,3 @@ export * from "./features";
export * from "./limitsService";
export * from "./getOrgTierData";
export * from "./createCustomer";
export * from "./linkEmailOrg";
-6
View File
@@ -1,6 +0,0 @@
export async function linkEmailOrg(
orgId: string,
email: string | null | undefined
): Promise<void> {
return;
}
+8 -33
View File
@@ -48,13 +48,7 @@ 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,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
parseHttpMethodList
} from "../validators";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -261,18 +255,10 @@ export async function updatePublicResources(
resourceData.ssl == undefined || resourceData.ssl == null
? true
: resourceData.ssl;
// `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;
let headers = "";
if (resourceData.headers) {
headers = JSON.stringify(resourceData.headers);
}
if (resourceData.policy) {
const isLicensed = await isLicensedOrSubscribed(
@@ -411,8 +397,7 @@ export async function updatePublicResources(
? resourceData.auth["whitelist-users"].length >
0
: false,
requestHeaders,
responseHeaders,
headers: headers || null,
applyRules:
resourceData.rules &&
resourceData.rules.length > 0,
@@ -608,8 +593,7 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName:
resourceData["tls-server-name"] || null,
requestHeaders,
responseHeaders,
headers: headers || null,
maintenanceModeEnabled:
resourceData.maintenance?.enabled,
maintenanceModeType: resourceData.maintenance?.type,
@@ -1232,8 +1216,7 @@ export async function updatePublicResources(
setHostHeader: resourceData["host-header"] || null,
tlsServerName: resourceData["tls-server-name"] || null,
ssl: resourceSsl,
requestHeaders,
responseHeaders,
headers: headers || null,
applyRules:
resourceData.rules && resourceData.rules.length > 0,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
@@ -1470,10 +1453,6 @@ 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;
}
@@ -1494,10 +1473,6 @@ 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}`);
}
}
}
+12 -16
View File
@@ -19,13 +19,7 @@ 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,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
ResourceRuleMatchType
} from "../validators";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -72,13 +66,6 @@ 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}`
);
}
}
@@ -352,8 +339,17 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
return "PASS";
}
function getRuleMatch(input: string): ResourceRuleMatchType {
return input.toUpperCase() as ResourceRuleMatchType;
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";
}
async function syncRolePolicies(
+2 -27
View File
@@ -3,7 +3,6 @@ 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 {
@@ -128,16 +127,7 @@ 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",
"method"
]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
@@ -217,19 +207,6 @@ 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({
@@ -326,9 +303,7 @@ export const PublicResourceSchema = z
auth: AuthSchema.optional(),
"host-header": z.string().optional(),
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
requestHeaders: z.array(HeaderSchema).optional(),
responseHeaders: z.array(HeaderSchema).optional(),
headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
+39 -134
View File
@@ -1,56 +1,55 @@
import {
db,
domains,
exitNodes,
redirects,
resources,
siteNetworks,
siteResources,
sites,
targetHealthCheck,
targets
domains,
aiProviders,
resourceAiProviders,
siteResources,
siteNetworks,
exitNodes
} from "@server/db";
import regionalCache from "@server/lib/cache";
import config from "@server/lib/config";
import logger from "@server/logger";
import {
and,
desc,
eq,
inArray,
isNotNull,
isNull,
or,
isNull,
ne,
isNotNull,
desc,
sql
} from "drizzle-orm";
import {
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService,
buildAiGatewayTrustMiddlewares,
getAiGatewayHost
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayConfig,
buildBrowserGatewayResourcesMap
} from "./browserGateway";
import logger from "@server/logger";
import config from "@server/lib/config";
import { resources, sites, targets } from "@server/db";
import { applyPathRewriteMiddleware } from "./middleware";
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
import regionalCache from "@server/lib/cache";
import { TargetWithSite } from "./types";
import { buildWildcardTls } from "./certResolver";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import { buildHostRule, appendPathMatch, computeRoutePriority } from "./rule";
import {
buildHttpLoadBalancerServers,
buildStickySessionCookie,
buildStickySessionIp,
buildTcpUdpLoadBalancerServers
buildTcpUdpLoadBalancerServers,
buildStickySessionIp
} from "./loadBalancer";
import { applyPathRewriteMiddleware } from "./middleware";
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
import { buildCustomHeadersMiddleware } from "./headersMiddleware";
import {
AI_GATEWAY_TRUST_MIDDLEWARE_RESOURCE,
AI_GATEWAY_TRUST_MIDDLEWARE_SITE_RESOURCE,
AI_GATEWAY_CLIENT_IP_MIDDLEWARE_NAME,
getAiGatewayHost,
buildAiGatewayTrustMiddlewares,
buildAiGatewayClientIpMiddleware,
buildAiGatewayHostHeaderMiddleware,
buildAiGatewayRouterAndService
} from "./aiGatewayMiddlewares";
import {
buildBrowserGatewayResourcesMap,
buildBrowserGatewayConfig
} from "./browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
import { TargetWithSite } from "./types";
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger";
@@ -97,8 +96,7 @@ export async function getTraefikConfig(
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
requestHeaders: resources.requestHeaders,
responseHeaders: resources.responseHeaders,
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
@@ -130,7 +128,6 @@ export async function getTraefikConfig(
siteOnline: sites.online,
subnet: sites.exitNodeSubnet,
exitNodeId: sites.exitNodeId,
// Domain cert resolver fields
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
@@ -228,8 +225,7 @@ export async function getTraefikConfig(
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: [],
requestHeaders: row.requestHeaders,
responseHeaders: row.responseHeaders,
headers: row.headers,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
@@ -330,87 +326,12 @@ export async function getTraefikConfig(
)
);
// Redirects have no targets/sites, so like inference resources they are
// queried separately and emitted on every exit node. A redirect listens
// either on a resource's fullDomain or on subdomain.baseDomain of a
// domain; the domain join resolves to whichever one applies.
const redirectRows = await db
.select({
name: redirects.name,
enabled: redirects.enabled,
redirectId: redirects.redirectId,
subdomain: redirects.subdomain,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
priority: redirects.priority,
ssl: redirects.ssl,
// Resource (when attached to one)
resourceId: resources.resourceId,
resourceFullDomain: resources.fullDomain,
resourceSubdomain: resources.subdomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
// Domain (the redirect's own, or the resource's)
baseDomain: domains.baseDomain,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(
domains,
eq(
domains.domainId,
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
)
)
.where(
and(
eq(redirects.enabled, true),
or(isNull(redirects.resourceId), eq(resources.enabled, true))
)
)
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
const redirectRoutes: RedirectRouteRow[] = [];
for (const row of redirectRows) {
const attachedToResource = row.resourceId !== null;
const fullDomain = attachedToResource
? row.resourceFullDomain
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
if (!fullDomain) {
logger.debug(
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
);
continue;
}
redirectRoutes.push({
enabled: row.enabled,
name: sanitize(row.name) || "",
redirectId: row.redirectId,
fullDomain,
hasSubdomain: attachedToResource
? !!row.resourceSubdomain
: !!row.subdomain,
wildcard: row.resourceWildcard,
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
attachedTo: attachedToResource ? "resource" : "domain",
matchPath: row.matchPath,
pathMatchType: row.pathMatchType,
priority: row.priority,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert
});
}
// make sure we have at least one resource
if (
resourcesMap.size === 0 &&
inferenceResources.length === 0 &&
browserGatewayResourcesMap.size === 0 &&
siteResourcesWithFullDomain.length === 0 &&
redirectRoutes.length === 0
siteResourcesWithFullDomain.length === 0
) {
return {};
}
@@ -484,8 +405,7 @@ export async function getTraefikConfig(
// Handle custom headers middleware
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.requestHeaders,
resource.responseHeaders,
resource.headers,
resource.setHostHeader,
resource.resourceId
);
@@ -598,21 +518,6 @@ export async function getTraefikConfig(
}
}
buildRedirectConfig({
config_output,
redirects: redirectRoutes,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: (redirect) =>
buildWildcardTls({
fullDomain: redirect.fullDomain,
hasSubdomain: redirect.hasSubdomain,
domainCertResolver: redirect.domainCertResolver,
preferWildcardCert:
redirect.preferWildcardCert || redirect.wildcard
})
});
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
+25 -57
View File
@@ -1,78 +1,46 @@
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 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.
* 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.
*/
export function buildCustomHeadersMiddleware(
requestHeaders: string | null | undefined,
responseHeaders: string | null | undefined,
headers: string | null | undefined,
setHostHeader: string | null | undefined,
resourceId: number
): {
headers: {
customRequestHeaders?: { [key: string]: string };
customResponseHeaders?: { [key: string]: string };
};
} | null {
const requestHeadersObj: { [key: string]: string } = {};
const responseHeadersObj: { [key: string]: string } = {};
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
const headersObj: { [key: string]: string } = {};
if (requestHeaders) {
parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach(
(header) => {
requestHeadersObj[header.name] = header.value;
}
);
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 (setHostHeader) {
requestHeadersObj["Host"] = setHostHeader;
headersObj["Host"] = setHostHeader;
}
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) {
if (Object.keys(headersObj).length === 0) {
return null;
}
return {
headers: {
...(hasRequestHeaders && {
customRequestHeaders: requestHeadersObj
}),
...(hasResponseHeaders && {
customResponseHeaders: responseHeadersObj
})
customRequestHeaders: headersObj
}
};
}
-134
View File
@@ -1,134 +0,0 @@
import { assertEquals } from "../../../test/assert";
import { rewriteRequestPath } from "./middleware";
function runTests() {
console.log("Running rewriteRequestPath tests...");
// no rewrite configured
assertEquals(
rewriteRequestPath("/a/b", "/a", "prefix", null, null),
"/a/b",
"no rewrite type"
);
// exact rewrite
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "exact"),
"/new",
"exact -> exact"
);
assertEquals(
rewriteRequestPath("/old/x", "/old", "exact", "/new", "exact"),
"/old/x",
"exact rewrite only on exact match"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "new", "exact"),
"/new",
"leading slash added to rewrite"
);
assertEquals(
rewriteRequestPath("/anything", null, null, "/new", "exact"),
"/new",
"no match path + exact rewrite replaces path"
);
// prefix rewrite
assertEquals(
rewriteRequestPath("/old/a/b", "/old", "prefix", "/new", "prefix"),
"/new/a/b",
"prefix -> prefix keeps rest"
);
assertEquals(
rewriteRequestPath("/old", "/old", "prefix", "/new", "prefix"),
"/new",
"prefix -> prefix bare"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "prefix"),
"/new",
"exact -> prefix"
);
assertEquals(
rewriteRequestPath(
"/api/v1/users",
"^/api/v1/(.*)",
"regex",
"/v2/$1",
"prefix"
),
"/v2/users",
"regex -> prefix uses capture"
);
// regex rewrite
assertEquals(
rewriteRequestPath(
"/blog/2020/post",
"^/blog/(\\d+)/(.*)$",
"regex",
"/archive/$2-$1",
"regex"
),
"/archive/post-2020",
"regex -> regex"
);
assertEquals(
rewriteRequestPath("/old/x", "/old", "prefix", "/new$1", "regex"),
"/new/x",
"prefix -> regex has (.*) capture"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", "/new", "regex"),
"/new",
"exact -> regex"
);
// stripPrefix
assertEquals(
rewriteRequestPath("/old/a", "/old", "prefix", null, "stripPrefix"),
"/a",
"stripPrefix"
);
assertEquals(
rewriteRequestPath("/old", "/old", "prefix", null, "stripPrefix"),
"/",
"stripPrefix to root"
);
assertEquals(
rewriteRequestPath("/old/a", "/old", "prefix", "/new", "stripPrefix"),
"/new/a",
"stripPrefix + addPrefix"
);
assertEquals(
rewriteRequestPath("/old", "/old", "exact", null, "stripPrefix"),
"/",
"stripPrefix exact"
);
// Same result the replacePathRegex middleware produces for this config
// (regex `^/old` -> `/`), quirky double slash included.
assertEquals(
rewriteRequestPath("/old/a", "^/old", "regex", null, "stripPrefix"),
"//a",
"stripPrefix regex mirrors Traefik"
);
assertEquals(
rewriteRequestPath("/old/a", "^/old/", "regex", null, "stripPrefix"),
"/a",
"stripPrefix regex with trailing slash"
);
assertEquals(
rewriteRequestPath("/a/b", null, null, null, "stripPrefix"),
"/a/b",
"stripPrefix without match path is a no-op"
);
console.log("All rewriteRequestPath tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
-72
View File
@@ -194,78 +194,6 @@ export default function createPathRewriteMiddleware(
return { middlewares };
}
/**
* Apply a path rewrite to a request path in-process, producing the same
* result the replacePathRegex / stripPrefix middlewares built above would.
* Used where Pangolin issues the redirect itself (badger) instead of
* handing it to Traefik.
*/
export function rewriteRequestPath(
requestPath: string,
path: string | null,
pathMatchType: string | null,
rewritePath: string | null,
rewritePathType: string | null
): string {
if (!rewritePathType) {
return requestPath;
}
let target = rewritePath ?? "";
if (
rewritePathType !== "regex" &&
target !== "" &&
!target.startsWith("/")
) {
target = `/${target}`;
}
// Nothing was matched against, so there is nothing to strip or replace;
// an exact rewrite is the only one that still means something.
if (!path || !pathMatchType) {
return rewritePathType === "exact" ? target || "/" : requestPath;
}
let matched = path;
if (pathMatchType !== "regex" && !matched.startsWith("/")) {
matched = `/${matched}`;
}
const matchRegex =
pathMatchType === "regex"
? matched
: pathMatchType === "prefix"
? `^${escapeRegex(matched)}(.*)`
: `^${escapeRegex(matched)}$`;
switch (rewritePathType) {
case "exact":
return requestPath.replace(
new RegExp(`^${escapeRegex(matched)}$`),
target
);
case "prefix":
return requestPath.replace(
new RegExp(matchRegex),
pathMatchType === "prefix" ? `${target}$1` : target
);
case "regex":
return requestPath.replace(new RegExp(matchRegex), target);
case "stripPrefix": {
if (pathMatchType === "prefix") {
const stripped = requestPath.startsWith(matched)
? requestPath.slice(matched.length)
: requestPath;
const prefix = target && target !== "/" ? target : "";
return `${prefix}${stripped}` || "/";
}
return requestPath.replace(new RegExp(matchRegex), target || "/");
}
default:
return requestPath;
}
}
function escapeRegex(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
-127
View File
@@ -1,127 +0,0 @@
import logger from "@server/logger";
import config from "@server/lib/config";
import {
buildHostRule,
appendPathMatch,
computeRedirectPriority
} from "@server/lib/traefik/rule";
export type RedirectRouteRow = {
redirectId: number;
/** Host the redirect listens on (resource fullDomain or subdomain.baseDomain). */
fullDomain: string;
hasSubdomain: boolean;
attachedTo: "resource" | "domain";
enabled: boolean;
name: string;
wildcard: boolean | null;
ssl: boolean;
matchPath: string | null;
pathMatchType: string;
priority: number | null;
domainCertResolver?: string | null;
preferWildcardCert?: boolean | null;
};
// Traefik requires a service on every router, but a redirect router's
// middleware chain always terminates the request with a 30x, so the service
// is never reached. noop@internal answers 418 if it ever is - treat that as
// a bug in the middleware chain, not something to route around.
const NOOP_SERVICE = "noop@internal";
export function buildRedirectConfig(params: {
config_output: any;
redirects: RedirectRouteRow[];
badgerMiddlewareName: string;
redirectHttpsMiddlewareName: string;
resolveTls: (row: RedirectRouteRow) => any | null;
}): void {
const {
config_output,
redirects,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls
} = params;
if (redirects.length === 0) {
return;
}
const httpEntrypoint = config.getRawConfig().traefik.http_entrypoint;
const httpsEntrypoint = config.getRawConfig().traefik.https_entrypoint;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [badgerMiddlewareName, ...additionalMiddlewares];
for (const redirect of redirects) {
const routerName = `${redirect.redirectId}-redirect-${redirect.name}-router`;
logger.debug(
`Processing redirect ${redirect.name} with domain ${redirect.fullDomain}`
);
if (!redirect.enabled) {
logger.debug(
`Redirect ${redirect.name} is disabled, skipping Traefik config`
);
continue;
}
let tls: any = {};
if (redirect.ssl) {
tls = resolveTls(redirect);
if (tls === null) {
continue;
}
}
if (!config_output.http.routers) {
config_output.http.routers = {};
}
if (redirect.matchPath && redirect.pathMatchType === "regex") {
try {
new RegExp(redirect.matchPath);
} catch {
logger.debug(
`Invalid regex pattern in redirect ${redirect.redirectId} match path: ${redirect.matchPath}`
);
continue;
}
}
const rule = appendPathMatch(
buildHostRule(redirect.fullDomain, redirect.wildcard),
redirect.matchPath,
redirect.pathMatchType
);
const priority = computeRedirectPriority(
redirect.priority,
redirect.matchPath,
redirect.pathMatchType
);
// if resource is already attached to resource, we don't need to add the https redirect
// as it is already added in the resource traefik config
if (redirect.attachedTo !== "resource" && redirect.ssl) {
config_output.http.routers[`${routerName}-redirect`] = {
entryPoints: [httpEntrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: NOOP_SERVICE,
rule,
priority
};
}
config_output.http.routers[routerName] = {
entryPoints: [redirect.ssl ? httpsEntrypoint : httpEntrypoint],
middlewares: routerMiddlewares,
service: NOOP_SERVICE,
rule,
priority,
...(redirect.ssl ? { tls } : {})
};
}
}
-138
View File
@@ -1,138 +0,0 @@
import { assertEquals } from "../../../test/assert";
import {
computeRedirectPriority,
computeRoutePriority,
matchesPath
} from "./rule";
function runTests() {
console.log("Running matchesPath tests...");
// No path config matches everything
assertEquals(matchesPath("/anything", null, null), true, "null path");
assertEquals(matchesPath("/anything", "/a", null), true, "null type");
assertEquals(
matchesPath("/anything", null, "prefix"),
true,
"null path w/ type"
);
// exact
assertEquals(matchesPath("/api", "/api", "exact"), true, "exact match");
assertEquals(
matchesPath("/api/", "/api", "exact"),
false,
"exact trailing slash"
);
assertEquals(matchesPath("/api/x", "/api", "exact"), false, "exact child");
assertEquals(
matchesPath("/api", "api", "exact"),
true,
"exact leading slash added"
);
// prefix (segment-aware, like Traefik v3 PathPrefix)
assertEquals(
matchesPath("/products", "/products", "prefix"),
true,
"prefix itself"
);
assertEquals(
matchesPath("/products/", "/products", "prefix"),
true,
"prefix slash"
);
assertEquals(
matchesPath("/products/shoes", "/products", "prefix"),
true,
"prefix child"
);
assertEquals(
matchesPath("/productsforsale", "/products", "prefix"),
false,
"prefix partial segment"
);
assertEquals(
matchesPath("/products/shoes", "/products/", "prefix"),
true,
"prefix with trailing slash"
);
assertEquals(
matchesPath("/products", "/products/", "prefix"),
false,
"trailing-slash prefix vs bare"
);
assertEquals(
matchesPath("/other", "/products", "prefix"),
false,
"prefix miss"
);
// regex (unanchored, like PathRegexp)
assertEquals(
matchesPath("/api/v1/x", "^/api/.*", "regex"),
true,
"regex anchored"
);
assertEquals(
matchesPath("/x/api/v1", "/api/", "regex"),
true,
"regex unanchored"
);
assertEquals(matchesPath("/foo", "^/api", "regex"), false, "regex miss");
assertEquals(
matchesPath("/foo", "(", "regex"),
false,
"invalid regex never matches"
);
console.log("All matchesPath tests passed!");
console.log("Running priority tests...");
// Resource routers: explicit override, else derived from path specificity
assertEquals(computeRoutePriority(null, null, null), 100, "default");
assertEquals(computeRoutePriority(100, null, null), 100, "100 is auto");
assertEquals(computeRoutePriority(500, "/a", "exact"), 500, "explicit");
assertEquals(computeRoutePriority(null, "/a", "exact"), 115, "exact");
assertEquals(computeRoutePriority(null, "/a", "prefix"), 113, "prefix");
assertEquals(computeRoutePriority(null, "/a", "regex"), 112, "regex");
assertEquals(computeRoutePriority(null, "/", "prefix"), 1, "catch-all");
// Redirect routers always land above any resource router (max 1000)
assertEquals(
computeRedirectPriority(null, null, null),
1100,
"redirect default"
);
assertEquals(
computeRedirectPriority(null, "/", "prefix"),
1001,
"redirect catch-all"
);
assertEquals(
computeRedirectPriority(1, null, null),
1001,
"redirect lowest explicit"
);
assertEquals(
computeRedirectPriority(1000, null, null),
2000,
"redirect highest explicit"
);
assertEquals(
computeRedirectPriority(1, "/", "prefix") >
computeRoutePriority(1000, "/a", "exact"),
true,
"weakest redirect beats strongest resource"
);
console.log("All priority tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
-64
View File
@@ -36,48 +36,6 @@ export function appendPathMatch(
return rule;
}
/**
* Server-side equivalent of the clause appendPathMatch emits, so badger can
* tell whether a request would have matched a given path config. Mirrors
* Traefik v3 semantics: Path is exact, PathPrefix is segment-aware
* (`/products` matches `/products/shoes` but not `/productsforsale`), and
* PathRegexp is an unanchored regex test.
*/
export function matchesPath(
requestPath: string,
path: string | null | undefined,
pathMatchType: string | null | undefined
): boolean {
if (!path || !pathMatchType) return true;
if (pathMatchType === "regex") {
try {
return new RegExp(path).test(requestPath);
} catch {
return false;
}
}
let p = path;
if (!p.startsWith("/")) {
p = `/${p}`;
}
if (pathMatchType === "exact") {
return requestPath === p;
} else if (pathMatchType === "prefix") {
if (!requestPath.startsWith(p)) {
return false;
}
if (p.endsWith("/")) {
return true;
}
const rest = requestPath.slice(p.length);
return rest === "" || rest.startsWith("/");
}
return true;
}
// Compute the router priority for a resource, favoring an explicit override
// and otherwise deriving it from the path match specificity.
export function computeRoutePriority(
@@ -105,25 +63,3 @@ export function computeRoutePriority(
}
return p;
}
// Redirects must always be evaluated before resource routers on the same
// host. Target and redirect priorities are both capped at 1000, so lifting
// every redirect by this offset puts them in a band (1001-2000) no resource
// router can reach, while explicit priorities still order redirects among
// themselves.
export const REDIRECT_PRIORITY_OFFSET = 1000;
/**
* Compute the router priority for a redirect: the same derivation as a
* resource router, shifted into the redirect band.
*/
export function computeRedirectPriority(
priority: number | null | undefined,
path: string | null | undefined,
pathMatchType: string | null | undefined
): number {
return (
computeRoutePriority(priority, path, pathMatchType) +
REDIRECT_PRIORITY_OFFSET
);
}
+3 -42
View File
@@ -1,8 +1,8 @@
import type { Domain, Resource, Target } from "@server/db";
import { Target } from "@server/db";
// Target subset with site information, shared between the OSS and
// Extended target type with site information, shared between the OSS and
// private getTraefikConfig implementations.
export type TargetWithSite = {
export type TargetWithSite = Target & {
resourceId: number;
targetId: number;
ip: string | null;
@@ -19,42 +19,3 @@ export type TargetWithSite = {
online: boolean;
};
};
// A resource grouped with its targets for router/service generation. Every
// target in a group shares the same path/rewrite config, so those columns
// live on the resource rather than on each target.
export type ResourceWithTargets = Pick<
Resource,
| "resourceId"
| "fullDomain"
| "ssl"
| "proxyPort"
| "subdomain"
| "domainId"
| "enabled"
| "stickySession"
| "tlsServerName"
| "setHostHeader"
| "enableProxy"
| "headers"
| "proxyProtocol"
| "wildcard"
| "mode"
| "maintenanceModeEnabled"
| "maintenanceModeType"
| "maintenanceTitle"
| "maintenanceMessage"
| "maintenanceEstimatedTime"
> &
Pick<Target, "path" | "pathMatchType" | "rewritePath" | "rewritePathType"> & {
/** Sanitized resource name used in router/service names */
name: string;
/** Sanitized resourceId + path config, unique per router */
key: string;
priority: number;
proxyProtocolVersion: number;
// Left-joined from the resource's domain, so absent when there is none
domainCertResolver: Domain["certResolver"] | null;
preferWildcardCert: Domain["preferWildcardCert"] | null;
targets: TargetWithSite[];
};
+2 -41
View File
@@ -1,10 +1,9 @@
import {
getResourceRuleValueValidationError,
isValidDomain,
isValidUrlGlobPattern,
parseHttpMethodList
isValidUrlGlobPattern
} from "./validators";
import { assertEquals, assertEqualsObj } from "@test/assert";
import { assertEquals } from "@test/assert";
function runTests() {
console.log("Running domain validation tests...");
@@ -296,44 +295,6 @@ 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!");
}
+1 -42
View File
@@ -76,46 +76,9 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION",
"METHOD"
"REGION"
] 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(
@@ -138,10 +101,6 @@ 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) ||
+1 -2
View File
@@ -32,8 +32,7 @@ export enum OpenAPITags {
AiProvider = "AI Provider",
AiModel = "AI Model",
AiBudget = "AI Budget",
VirtualApiKey = "Virtual API Key",
Redirect = "Redirect"
VirtualApiKey = "Virtual API Key"
}
// Order here controls the order tags are displayed in Swagger UI
-1
View File
@@ -13,4 +13,3 @@
export * from "./getOrgTierData";
export * from "./createCustomer";
export * from "./linkEmailOrg";
@@ -1,56 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import 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<void> {
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
);
}
}
+9 -139
View File
@@ -18,7 +18,6 @@ import {
domains,
exitNodes,
loginPage,
redirects,
SiteResource,
targetHealthCheck
} from "@server/db";
@@ -57,7 +56,7 @@ import {
import { build } from "@server/build";
import license from "#private/license/license";
import regionalCache from "#private/lib/cache";
import { ResourceWithTargets } from "@server/lib/traefik/types";
import { TargetWithSite } from "@server/lib/traefik/types";
import { buildWildcardTls } from "@server/lib/traefik/certResolver";
import {
buildHostRule,
@@ -86,10 +85,6 @@ import {
buildBrowserGatewayConfig
} from "@server/lib/traefik/browserGateway";
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
import {
buildRedirectConfig,
RedirectRouteRow
} from "@server/lib/traefik/redirect";
const redirectHttpsMiddlewareName = "redirect-to-https";
const redirectToRootMiddlewareName = "redirect-to-root";
@@ -138,8 +133,7 @@ export async function getTraefikConfig(
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader,
enableProxy: resources.enableProxy,
requestHeaders: resources.requestHeaders,
responseHeaders: resources.responseHeaders,
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
@@ -220,7 +214,7 @@ export async function getTraefikConfig(
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
// Group by resource and include targets with their unique site data
const resourcesMap = new Map<string, ResourceWithTargets>();
const resourcesMap = new Map();
for (const row of resourcesWithTargetsAndSites) {
if (!["http", "tcp", "udp"].includes(row.mode)) {
@@ -248,7 +242,7 @@ export async function getTraefikConfig(
.filter(Boolean)
.join("-");
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
const key = sanitize(mapKey) ?? "";
const key = sanitize(mapKey);
if (!resourcesMap.has(mapKey)) {
const validation = validatePathRewriteConfig(
@@ -281,8 +275,7 @@ export async function getTraefikConfig(
setHostHeader: row.setHostHeader,
enableProxy: row.enableProxy,
targets: [],
requestHeaders: row.requestHeaders,
responseHeaders: row.responseHeaders,
headers: row.headers,
proxyProtocol: row.proxyProtocol,
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
path: row.path, // the targets will all have the same path
@@ -303,7 +296,7 @@ export async function getTraefikConfig(
}
// Add target with its associated site data
resourcesMap.get(mapKey)!.targets.push({
resourcesMap.get(mapKey).targets.push({
resourceId: row.resourceId,
targetId: row.targetId,
ip: row.ip,
@@ -403,89 +396,6 @@ export async function getTraefikConfig(
)
);
// Redirects have no targets/sites, so like inference resources they are
// queried separately and emitted on every exit node. A redirect listens
// either on a resource's fullDomain or on subdomain.baseDomain of a
// domain; the domain join resolves to whichever one applies.
const redirectRows = await db
.select({
name: redirects.name,
enabled: redirects.enabled,
redirectId: redirects.redirectId,
subdomain: redirects.subdomain,
matchPath: redirects.matchPath,
pathMatchType: redirects.pathMatchType,
priority: redirects.priority,
ssl: redirects.ssl,
// Resource (when attached to one)
resourceId: resources.resourceId,
resourceFullDomain: resources.fullDomain,
resourceSubdomain: resources.subdomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
// Domain (the redirect's own, or the resource's)
baseDomain: domains.baseDomain,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(
domains,
eq(
domains.domainId,
sql`coalesce(${redirects.domainId}, ${resources.domainId})`
)
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domains.domainId)
)
.where(
and(
eq(redirects.enabled, true),
or(isNull(redirects.resourceId), eq(resources.enabled, true))
)
)
.orderBy(desc(redirects.priority), redirects.redirectId); // stable ordering
const redirectRoutes: RedirectRouteRow[] = [];
for (const row of redirectRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
const attachedToResource = row.resourceId !== null;
const fullDomain = attachedToResource
? row.resourceFullDomain
: [row.subdomain, row.baseDomain].filter(Boolean).join(".");
if (!fullDomain) {
logger.debug(
`Redirect ${row.redirectId} has no host to listen on, skipping Traefik config`
);
continue;
}
redirectRoutes.push({
enabled: row.enabled,
name: sanitize(row.name) || "",
redirectId: row.redirectId,
fullDomain,
hasSubdomain: attachedToResource
? !!row.resourceSubdomain
: !!row.subdomain,
wildcard: row.resourceWildcard,
ssl: attachedToResource ? !!row.resourceSsl : row.ssl,
attachedTo: attachedToResource ? "resource" : "domain",
matchPath: row.matchPath,
pathMatchType: row.pathMatchType,
priority: row.priority,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert
});
}
// Pangolin-managed DNS-01/ACME cert mode requires either a tier1
// license (self-hosted) or a saas build - otherwise fall back to
// Traefik's own cert resolvers (buildWildcardTls) throughout.
@@ -525,12 +435,6 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include redirect hosts
for (const redirect of redirectRoutes) {
if (redirect.ssl) {
domains.add(redirect.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -563,14 +467,9 @@ export async function getTraefikConfig(
}
};
console.dir(
{ resourcesMap, resourcesWithTargetsAndSites },
{ depth: null }
);
// get the key and the resource
for (const [, resource] of resourcesMap.entries()) {
const targets = resource.targets;
const targets = resource.targets as TargetWithSite[];
const key = resource.key;
const routerName = `${key}-${resource.name}-router`;
@@ -693,7 +592,7 @@ export async function getTraefikConfig(
if (!target.site.online) return false;
if (target.health === "unhealthy") return false;
if (target.health == "unhealthy") return false;
return true;
});
@@ -813,8 +712,7 @@ export async function getTraefikConfig(
);
const customHeadersMiddleware = buildCustomHeadersMiddleware(
resource.requestHeaders,
resource.responseHeaders,
resource.headers,
resource.setHostHeader,
resource.resourceId
);
@@ -883,34 +781,6 @@ export async function getTraefikConfig(
}
}
buildRedirectConfig({
config_output,
redirects: redirectRoutes,
badgerMiddlewareName,
redirectHttpsMiddlewareName,
resolveTls: (redirect) => {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
return buildWildcardTls({
fullDomain: redirect.fullDomain,
hasSubdomain: redirect.hasSubdomain,
domainCertResolver: redirect.domainCertResolver,
preferWildcardCert:
redirect.preferWildcardCert || redirect.wildcard
});
}
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === redirect.fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for redirect domain: ${redirect.fullDomain}`
);
return null;
}
return {};
}
});
if (browserGatewayUiUrl) {
buildBrowserGatewayConfig({
config_output,
+2 -79
View File
@@ -50,27 +50,6 @@ 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;
@@ -297,11 +276,6 @@ 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) {
@@ -364,11 +338,6 @@ 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;
@@ -401,52 +370,13 @@ 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.
let selectedHostKey: LicenseKeyCache | undefined;
for (const key of keys) {
const cached = newCache.get(key.licenseKey)!;
if (cached.type === "host") {
if (
!selectedHostKey ||
(cached.valid && !selectedHostKey.valid) ||
(cached.valid === selectedHostKey.valid &&
tierRank(cached.tier) >
tierRank(selectedHostKey.tier))
) {
selectedHostKey = cached;
}
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
if (!cached.valid) {
@@ -463,11 +393,6 @@ LQIDAQAB
}
}
if (selectedHostKey) {
status.isLicenseValid = selectedHostKey.valid;
status.tier = selectedHostKey.tier;
}
// Invalidate license if over user or site limits
if (
(status.maxSites !== undefined &&
@@ -490,8 +415,6 @@ LQIDAQAB
this.checkInProgress = false;
}
logger.debug(`Computed license status: ${JSON.stringify(status)}`);
this.statusCache.set(this.statusKey, status, 0);
return status;
}
-1
View File
@@ -20,7 +20,6 @@ Reasons:
107 - Valid SSO
108 - Connected Client
109 - Valid Virtual API Key
110 - Allowed Redirect
201 - Resource Not Found
202 - Resource Blocked
+6 -117
View File
@@ -10,8 +10,6 @@ import {
verifyVirtualApiKey
} from "@server/auth/verifyVirtualApiKey";
import {
type RedirectByHost,
getRedirectsByHost,
getResourceByDomain,
getResourceRules,
getRoleResourceAccess,
@@ -41,10 +39,7 @@ import {
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed, type isPathAllowed } from "@server/lib/pathMatch";
import { matchesPath } from "@server/lib/traefik/rule";
import { rewriteRequestPath } from "@server/lib/traefik/middleware";
import { parseHttpMethodList } from "@server/lib/validators";
import { isPathAllowed } from "@server/lib/pathMatch";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -72,7 +67,6 @@ import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
import type ResponseT from "@server/types/MessageResponse";
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(),
@@ -114,9 +108,6 @@ export type VerifyUserResponse = {
valid: boolean;
headerAuthChallenged?: boolean;
redirectUrl?: string;
// Set alongside redirectUrl when the redirect is a configured Redirect
// rather than a login bounce, so badger can answer 307 instead of 302.
redirectPermanent?: boolean;
userData?: BasicUserData;
pangolinVersion?: string;
dontStripSession?: boolean;
@@ -172,7 +163,6 @@ export async function verifyResourceSession(
path,
headers,
query,
method,
badgerVersion
} = parsedBody.data;
@@ -202,30 +192,6 @@ export async function verifyResourceSession(
cleanHost = cleanHost.slice(0, -1 * matched.length);
}
// Redirects always win: they are routed ahead of resources in
// Traefik and never require auth, even when attached to a resource,
// so let a matching one through to the redirect middleware before
// any resource lookup.
const redirect = await findRedirect(cleanHost, path);
if (redirect) {
const redirectUrl = buildRedirectUrl(redirect, parsedBody.data);
logger.debug(
`Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})`
);
logRequestAudit(
{
action: true,
reason: 110, // redirected
orgId: redirect.orgId,
location: ipCC
},
parsedBody.data
);
return redirected(res, redirectUrl, redirect.permanent);
}
const resourceCacheKey = `resource:${cleanHost}`;
let resourceData:
| {
@@ -233,7 +199,9 @@ export async function verifyResourceSession(
pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth:
ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
| ResourceHeaderAuth
| ResourcePolicyHeaderAuth
| null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean | null;
sso: boolean | null;
@@ -325,8 +293,7 @@ export async function verifyResourceSession(
clientIp,
path,
ipCC,
ipAsn,
method
ipAsn
);
if (action == "ACCEPT") {
@@ -1042,71 +1009,6 @@ function extractResourceSessionToken(
return latest.token;
}
async function findRedirect(
host: string,
path: string
): Promise<RedirectByHost | null> {
const cacheKey = `redirects:${host}`;
let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey);
if (!candidates) {
candidates = await getRedirectsByHost(host);
localCache.set(cacheKey, candidates, 5);
}
// Candidates come back highest priority first, matching the order
// Traefik evaluates the routers in.
return (
candidates.find((r) =>
matchesPath(path, r.matchPath, r.pathMatchType)
) ?? null
);
}
/**
* Destination for a configured redirect: the scheme and host come from the
* redirect's destination, the request's query is kept and the path is run
* through the redirect's rewrite rules (if any).
*/
function buildRedirectUrl(
redirect: RedirectByHost,
request: VerifyResourceSessionSchema
): string {
const newPath = rewriteRequestPath(
request.path,
redirect.matchPath,
redirect.pathMatchType,
redirect.rewritePath,
redirect.rewritePathType
);
let search = "";
try {
search = new URL(request.originalRequestURL).search;
} catch {
// originalRequestURL is validated as a URL, so this is only defensive
}
return `${redirect.destinationHost}${newPath}${search}`;
}
// Like a notAllowed login bounce, but the destination is the configured
// redirect target rather than the auth page.
function redirected(res: Response, redirectUrl: string, permanent: boolean) {
const data = {
data: {
valid: true,
redirectUrl,
redirectPermanent: permanent,
pangolinVersion: APP_VERSION
},
success: true,
error: false,
message: "Redirected",
status: HttpCode.OK
} satisfies ResponseT<VerifyUserResponse>;
return response<VerifyUserResponse>(res, data);
}
async function notAllowed(
res: Response,
redirectPath?: string,
@@ -1527,8 +1429,7 @@ async function checkRules(
clientIp: string | undefined,
path: string | undefined,
ipCC?: string,
ipAsn?: number,
method?: string
ipAsn?: number
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
@@ -1603,24 +1504,12 @@ 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(
-45
View File
@@ -62,7 +62,6 @@ import { createStore } from "#dynamic/lib/rateLimitStore";
import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
import * as aiProvider from "@server/routers/aiProvider";
import * as redirect from "@server/routers/redirect";
import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
@@ -1635,50 +1634,6 @@ authenticated.delete(
aiProvider.deleteAiProvider
);
authenticated.put(
"/org/:orgId/redirect",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createRedirect),
logActionAudit(ActionsEnum.createRedirect),
redirect.createRedirect
);
authenticated.get(
"/org/:orgId/redirects",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listRedirects),
redirect.listRedirects
);
authenticated.get(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getRedirect),
redirect.getRedirect
);
authenticated.get(
"/org/:orgId/redirect/:niceId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getRedirect),
redirect.getRedirect
);
authenticated.post(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateRedirect),
logActionAudit(ActionsEnum.updateRedirect),
redirect.updateRedirect
);
authenticated.delete(
"/org/:orgId/redirects/:redirectId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteRedirect),
logActionAudit(ActionsEnum.deleteRedirect),
redirect.deleteRedirect
);
authenticated.put(
"/ai-provider/:providerId/model",
verifyAiProviderAccess,
-24
View File
@@ -1,24 +0,0 @@
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
}
});
}
@@ -14,7 +14,6 @@ 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;
@@ -117,7 +116,6 @@ 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;
}
-5
View File
@@ -94,11 +94,6 @@ 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;
@@ -347,7 +347,6 @@ 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;
}
+1 -2
View File
@@ -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, linkEmailOrg } from "#dynamic/lib/billing";
import { createCustomer } 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,7 +425,6 @@ export async function createOrg(
customerId
); // Only 1 because we are creating the org
}
await linkEmailOrg(orgId, req.user?.email);
}
if (numOrgs) {
-239
View File
@@ -1,239 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains, redirects, resources } from "@server/db";
import type { Domain, Redirect, Resource } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
redirectDestinationHostSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
isValidMatchPath,
isAllowedSsl,
redirectPrioritySchema,
redirectRewritePathTypeSchema
} from "@server/routers/redirect/validation";
import { getUniqueRedirectName } from "@server/db/names";
import { createCertificate } from "../certificates";
export type CreateRedirectResponse = {
redirect: Redirect;
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z
.strictObject({
name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationHost: redirectDestinationHostSchema,
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
rewritePath: redirectRewritePathSchema.optional().nullable(),
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional().nullable(),
permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: z.boolean().optional()
})
.refine(
(data) =>
// stripPrefix removes the matched prefix and needs no replacement
// value; every other rewrite type is meaningless without one.
!data.rewritePathType ||
data.rewritePathType === "stripPrefix" ||
Boolean(data.rewritePath),
{
message:
"rewritePath is required unless rewritePathType is stripPrefix",
path: ["rewritePath"]
}
)
.refine((data) => Boolean(data.resourceId) !== Boolean(data.domainId), {
message: "Exactly one of resourceId or domainId must be provided",
path: ["resourceId"]
})
.refine((data) => isValidMatchPath(data.matchPath, data.pathMatchType), {
message: "matchPath must be a valid regular expression",
path: ["matchPath"]
})
.refine((data) => isAllowedSsl(data.ssl), {
message: "TLS cannot be disabled on this build",
path: ["ssl"]
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/redirect",
description: "Create a redirect for an organization.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response"
}
}
});
export async function createRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const {
name,
resourceId,
domainId,
subdomain,
destinationHost,
pathMatchType,
matchPath,
rewritePath,
rewritePathType,
priority,
permanent,
ssl,
enabled
} = parsedBody.data;
let resource: Resource | null = null;
if (resourceId) {
const res = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
resource = res.at(0) ?? null;
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
}
let domain: Domain | null = null;
if (domainId) {
const res = await db
.select()
.from(domains)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
)
.where(
and(
eq(domains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
domain = res.at(0)?.domains ?? null;
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found`
)
);
}
}
const niceId = await getUniqueRedirectName(orgId);
const [redirect] = await db
.insert(redirects)
.values({
orgId,
name,
niceId,
resourceId: resourceId ?? null,
domainId: domainId ?? null,
subdomain: subdomain ?? null,
destinationHost,
pathMatchType: pathMatchType ?? "regex",
matchPath: matchPath ?? null,
rewritePath: rewritePath ?? null,
rewritePathType: rewritePathType ?? null,
priority: priority ?? 100,
permanent: permanent ?? false,
// Resource-attached redirects follow the resource's ssl, so
// the column is only meaningful for domain-attached ones.
ssl: resource ? true : (ssl ?? true),
enabled: enabled ?? true
})
.returning();
if (domain) {
const fullDomain = [subdomain ?? null, domain.baseDomain]
.filter(Boolean)
.join(".");
await createCertificate(domain.domainId, fullDomain, db);
}
return response<CreateRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-92
View File
@@ -1,92 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { redirects, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z.coerce.number().int().positive()
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Delete a redirect.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function deleteRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, redirectId } = parsedParams.data;
const [existing] = await db
.select({ redirectId: redirects.redirectId })
.from(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId} not found`
)
);
}
await db
.delete(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
);
return response(res, {
data: null,
success: true,
error: false,
message: "Redirect deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-186
View File
@@ -1,186 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { domains, redirects, resources, db } from "@server/db";
import response from "@server/lib/response";
import stoi from "@server/lib/stoi";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
export type GetRedirectResponse = {
redirect: {
redirectId: number;
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
ssl: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceSsl: boolean | null;
resourceWildcard: boolean | null;
domainId: string | null;
baseDomain: string | null;
};
};
const redirectColumns = {
redirectId: redirects.redirectId,
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationHost: redirects.destinationHost,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
priority: redirects.priority,
permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled,
resourceId: redirects.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
resourceFullDomain: resources.fullDomain,
resourceSsl: resources.ssl,
resourceWildcard: resources.wildcard,
domainId: redirects.domainId,
baseDomain: domains.baseDomain
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z
.string()
.optional()
.transform(stoi)
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional()
});
async function query(orgId: string, redirectId?: number, niceId?: string) {
if (redirectId) {
const [res] = await db
.select(redirectColumns)
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
return res;
} else if (niceId) {
const [res] = await db
.select(redirectColumns)
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(
and(eq(redirects.niceId, niceId), eq(redirects.orgId, orgId))
)
.limit(1);
return res;
}
}
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Get a redirect by ID.",
tags: [OpenAPITags.Redirect],
request: {
params: z.object({
orgId: z.string(),
redirectId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirect/{niceId}",
description:
"Get a redirect by orgId and niceId. NiceId is a readable ID for the redirect and unique on a per org basis.",
tags: [OpenAPITags.Redirect],
request: {
params: z.object({
orgId: z.string(),
niceId: z.string()
})
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function getRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, redirectId, niceId } = parsedParams.data;
const redirect = await query(orgId, redirectId, niceId);
if (!redirect) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId || niceId} not found`
)
);
}
return response<GetRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-5
View File
@@ -1,5 +0,0 @@
export * from "./createRedirect";
export * from "./listRedirects";
export * from "./getRedirect";
export * from "./updateRedirect";
export * from "./deleteRedirect";
-203
View File
@@ -1,203 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { domains, redirects, resources, db } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import type { PaginatedResponse } from "@server/types/Pagination";
export type ListRedirectsResponse = PaginatedResponse<{
redirects: Array<{
redirectId: number;
orgId: string;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
ssl: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceDomainId: string | null;
domainId: string | null;
baseDomain: string | null;
}>;
}>;
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const listSchema = z.object({
pageSize: z.coerce
.number<string>()
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>()
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/redirects",
description: "List redirects for an organization.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
query: listSchema
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function listRedirects(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
const { pageSize, page, query } = parsedQuery.data;
const conditions = [eq(redirects.orgId, orgId)];
if (query) {
const term = "%" + query.toLowerCase() + "%";
conditions.push(
or(
like(sql`LOWER(${redirects.name})`, term),
like(sql`LOWER(${redirects.matchPath})`, term),
like(sql`LOWER(${redirects.destinationHost})`, term)
)!
);
}
const baseQuery = db
.select({
redirectId: redirects.redirectId,
orgId: redirects.orgId,
niceId: redirects.niceId,
name: redirects.name,
subdomain: redirects.subdomain,
destinationHost: redirects.destinationHost,
pathMatchType: redirects.pathMatchType,
matchPath: redirects.matchPath,
rewritePath: redirects.rewritePath,
rewritePathType: redirects.rewritePathType,
priority: redirects.priority,
permanent: redirects.permanent,
ssl: redirects.ssl,
enabled: redirects.enabled,
resourceId: redirects.resourceId,
resourceName: resources.name,
resourceNiceId: resources.niceId,
resourceFullDomain: resources.fullDomain,
resourceDomainId: resources.domainId,
domainId: redirects.domainId,
baseDomain: domains.baseDomain
})
.from(redirects)
.leftJoin(resources, eq(resources.resourceId, redirects.resourceId))
.leftJoin(domains, eq(domains.domainId, redirects.domainId))
.where(and(...conditions));
const countQuery = db.$count(
db
.select()
.from(redirects)
.where(and(...conditions))
.as("filtered_redirects")
);
const [totalCount, rows] = await Promise.all([
countQuery,
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(desc(redirects.priority), desc(redirects.redirectId))
]);
return response<ListRedirectsResponse>(res, {
data: {
redirects: rows,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Redirects retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-325
View File
@@ -1,325 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, domains, orgDomains, redirects, resources } from "@server/db";
import type { Redirect } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import {
redirectNiceIdSchema,
redirectDestinationHostSchema,
redirectMatchPathSchema,
redirectPathMatchTypeSchema,
redirectRewritePathSchema,
redirectRewritePathTypeSchema,
redirectPrioritySchema,
isValidMatchPath,
isAllowedSsl
} from "@server/routers/redirect/validation";
import { createCertificate } from "../certificates";
export type UpdateRedirectResponse = {
redirect: Redirect;
};
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
redirectId: z.coerce.number().int().positive()
});
const bodySchema = z.strictObject({
name: z.string().nonempty().optional(),
niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
destinationHost: redirectDestinationHostSchema.optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
rewritePath: redirectRewritePathSchema.optional().nullable(),
rewritePathType: redirectRewritePathTypeSchema.optional().nullable(),
priority: redirectPrioritySchema.optional(),
permanent: z.boolean().optional(),
ssl: z.boolean().optional(),
enabled: z.boolean().optional()
});
registry.registerPath({
method: "post",
path: "/org/{orgId}/redirects/{redirectId}",
description: "Update a redirect.",
tags: [OpenAPITags.Redirect],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response"
}
}
});
export async function updateRedirect(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId, redirectId } = parsedParams.data;
const body = parsedBody.data;
const [existing] = await db
.select()
.from(redirects)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Redirect with ID ${redirectId} not found`
)
);
}
const effectiveResourceId =
body.resourceId !== undefined
? body.resourceId
: existing.resourceId;
const effectiveDomainId =
body.domainId !== undefined ? body.domainId : existing.domainId;
if (Boolean(effectiveResourceId) === Boolean(effectiveDomainId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exactly one of resourceId or domainId must be provided"
)
);
}
if (body.resourceId) {
const [resource] = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(
and(
eq(resources.resourceId, body.resourceId),
eq(resources.orgId, existing.orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${body.resourceId} not found`
)
);
}
}
let domain: { domainId: string; baseDomain: string } | null = null;
if (effectiveDomainId) {
const [d] = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain
})
.from(domains)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, domains.domainId)
)
.where(
and(
eq(domains.domainId, effectiveDomainId),
eq(orgDomains.orgId, existing.orgId)
)
)
.limit(1);
domain = d ?? null;
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${effectiveDomainId} not found`
)
);
}
}
if (body.niceId) {
const [existingNiceId] = await db
.select()
.from(redirects)
.where(
and(
eq(redirects.niceId, body.niceId),
eq(redirects.orgId, existing.orgId),
ne(redirects.redirectId, existing.redirectId) // exclude the current redirect from the search
)
)
.limit(1);
if (existingNiceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
`A redirect with niceId "${body.niceId}" already exists`
)
);
}
}
if (
!isValidMatchPath(
body.matchPath !== undefined
? body.matchPath
: existing.matchPath,
body.pathMatchType ?? existing.pathMatchType
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"matchPath must be a valid regular expression"
)
);
}
if (!isAllowedSsl(body.ssl)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"TLS cannot be disabled on this build"
)
);
}
const updateData: Partial<typeof redirects.$inferInsert> = {};
if (body.name !== undefined) {
updateData.name = body.name;
}
if (body.niceId !== undefined) {
updateData.niceId = body.niceId;
}
if (body.resourceId !== undefined) {
updateData.resourceId = body.resourceId;
}
if (body.domainId !== undefined) {
updateData.domainId = body.domainId;
}
if (body.subdomain !== undefined) {
updateData.subdomain = body.subdomain;
}
if (body.destinationHost !== undefined) {
updateData.destinationHost = body.destinationHost;
}
if (body.pathMatchType !== undefined) {
updateData.pathMatchType = body.pathMatchType;
}
if (body.matchPath !== undefined) {
updateData.matchPath = body.matchPath;
}
if (body.rewritePath !== undefined) {
updateData.rewritePath = body.rewritePath;
}
if (body.rewritePathType !== undefined) {
updateData.rewritePathType = body.rewritePathType;
}
if (body.priority !== undefined) {
updateData.priority = body.priority;
}
if (body.permanent !== undefined) {
updateData.permanent = body.permanent;
}
if (effectiveResourceId) {
// Resource-attached redirects follow the resource's ssl; reset
// the column so a later move back to a domain starts from TLS on.
updateData.ssl = true;
} else if (body.ssl !== undefined) {
updateData.ssl = body.ssl;
}
if (body.enabled !== undefined) {
updateData.enabled = body.enabled;
}
const [redirect] = await db
.update(redirects)
.set(updateData)
.where(
and(
eq(redirects.redirectId, redirectId),
eq(redirects.orgId, orgId)
)
)
.returning();
if (domain) {
const effectiveSubdomain =
body.subdomain !== undefined
? body.subdomain
: existing.subdomain;
const fullDomain = [effectiveSubdomain ?? null, domain.baseDomain]
.filter(Boolean)
.join(".");
await createCertificate(domain.domainId, fullDomain, db);
}
return response<UpdateRedirectResponse>(res, {
data: {
redirect
},
success: true,
error: false,
message: "Redirect updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
-75
View File
@@ -1,75 +0,0 @@
import { z } from "zod";
import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
export const redirectNiceIdSchema = z
.string()
.min(1)
.max(255)
.regex(
/^[a-zA-Z0-9-]+$/,
"niceId can only contain letters, numbers, and dashes"
);
export const redirectPathMatchTypeSchema = z.enum(["exact", "prefix", "regex"]);
export const redirectRewritePathTypeSchema = z.enum([
"exact",
"prefix",
"regex",
"stripPrefix"
]);
export const redirectMatchPathSchema = z.string().nonempty();
export function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/**
* A regex match path is fed straight to `new RegExp` when building routes,
* so reject patterns that would throw there.
*/
export function isValidMatchPath(
matchPath: string | null | undefined,
pathMatchType: string | null | undefined
): boolean {
return (
pathMatchType !== "regex" || !matchPath || isValidRegex(matchPath)
);
}
export const redirectRewritePathSchema = z.string().nonempty();
// Same range as target priorities; 100 means "let the system order it".
export const redirectPrioritySchema = z.int().min(1).max(1000);
/**
* A destination is `scheme://host[:port]` with no path, query or fragment;
* the request path (after any rewrite) is appended to it by badger.
*/
export function isValidDestinationHost(value: string): boolean {
const match = /^https?:\/\/([^/:?#]+)(:\d{1,5})?$/.exec(value);
return match !== null && isValidDomain(match[1]);
}
export const redirectDestinationHostSchema = z
.string()
.nonempty()
.refine(isValidDestinationHost, {
message: "Invalid destination, expected scheme://host such as https://example.com"
});
/**
* The cloud only serves HTTPS, so a domain-attached redirect may not opt out
* of TLS there. Resource-attached redirects inherit the resource's ssl
* setting and never carry their own.
*/
export function isAllowedSsl(ssl: boolean | undefined): boolean {
return build !== "saas" || ssl !== false;
}
+5 -9
View File
@@ -53,10 +53,9 @@ async function queryInlinePolicy(resourcePolicyId: number) {
export type GetResourceResponse = Omit<
NonNullable<Awaited<ReturnType<typeof query>>>,
"requestHeaders" | "responseHeaders"
"headers"
> & {
requestHeaders: { name: string; value: string }[] | null;
responseHeaders: { name: string; value: string }[] | null;
headers: { name: string; value: string }[] | null;
};
registry.registerPath({
@@ -187,12 +186,9 @@ export async function getResource(
return response<GetResourceResponse>(res, {
data: {
...returnData,
requestHeaders: returnData.requestHeaders
? JSON.parse(returnData.requestHeaders)
: returnData.requestHeaders,
responseHeaders: returnData.responseHeaders
? JSON.parse(returnData.responseHeaders)
: returnData.responseHeaders
headers: returnData.headers
? JSON.parse(returnData.headers)
: returnData.headers
},
success: true,
error: false,
+31 -59
View File
@@ -100,14 +100,6 @@ 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(),
@@ -171,13 +163,12 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderName.test(h.name));
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;
},
{
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
@@ -185,13 +176,14 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
const validHeaderValue = /^[\t\x20-\x7E]*$/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every((h) => validHeaderValue.test(h.value));
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;
},
{
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
@@ -199,17 +191,16 @@ const updateHttpResourceBodySchema = z
)
.refine(
(data) => {
const templatePattern = /\{\{[^}]+\}\}/;
const allHeaders = [
...(data.headers ?? []),
...(data.requestHeaders ?? []),
...(data.responseHeaders ?? [])
];
return allHeaders.every(
(h) =>
!templatePattern.test(h.name) &&
!templatePattern.test(h.value)
);
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;
},
{
error: "Header names and values must not contain template expressions such as {{value}}."
@@ -705,32 +696,13 @@ async function updateHttpResource(
await createCertificate(domainId, fullDomain, db);
}
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 headers = undefined;
if (updateData.headers) {
headers = JSON.stringify(updateData.headers);
} else if (updateData.headers === null) {
headers = 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;
@@ -783,7 +755,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...resourceOnlyData, requestHeaders, responseHeaders })
.set({ ...resourceOnlyData, headers })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
@@ -807,7 +779,7 @@ async function updateHttpResource(
const updatedResource = await db
.update(resources)
.set({ ...updateData, requestHeaders, responseHeaders })
.set({ ...updateData, headers })
.where(eq(resources.resourceId, resource.resourceId))
.returning();
@@ -29,8 +29,8 @@ export async function traefikConfigProvider(
const traefikConfig = await getTraefikConfig(
currentExitNodeId,
config.getRawConfig().traefik.site_types,
build === "oss", // filter out the namespace domains in open source
build !== "oss", // generate the login pages on the cloud and and enterprise,
build == "oss", // filter out the namespace domains in open source
build != "oss", // generate the login pages on the cloud and and enterprise,
config.getRawConfig().traefik.allow_raw_resources,
pangolinUIUrl,
pangolinUIUrl,
@@ -71,7 +71,8 @@ export async function traefikConfigProvider(
.resource_session_request_param,
remoteUserIdHeader:
config.getRawConfig().server.remote_headers.user_id,
config.getRawConfig().server.remote_headers
.user_id,
remoteVirtualApiKeyIdHeader:
config.getRawConfig().server.remote_headers
@@ -281,7 +281,6 @@ export default function GeneralPage() {
// 107 - Valid SSO
// 108 - Connected Client
// 109 - Valid Virtual API Key
// 110 - Allowed Redirect
// 201 - Resource Not Found
// 202 - Resource Blocked
@@ -301,7 +300,6 @@ export default function GeneralPage() {
107: t("validSSO"),
108: t("connectedClient"),
109: t("validVirtualAPIKey"),
110: t("allowedRedirect"),
201: t("resourceNotFound"),
202: t("resourceBlocked"),
203: t("droppedByRule"),
@@ -607,7 +605,6 @@ export default function GeneralPage() {
{ value: "106", label: t("validEmail") },
{ value: "107", label: t("validSSO") },
{ value: "108", label: t("connectedClient") },
{ value: "110", label: t("allowedRedirect") },
{ value: "201", label: t("resourceNotFound") },
{ value: "202", label: t("resourceBlocked") },
{ value: "203", label: t("droppedByRule") },
@@ -1,73 +0,0 @@
import RedirectForm from "@app/components/RedirectForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { GetRedirectResponse } from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { notFound } from "next/navigation";
export const metadata: Metadata = {
title: "Edit Redirect"
};
export const dynamic = "force-dynamic";
type EditRedirectPageProps = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function EditRedirectPage(props: EditRedirectPageProps) {
const { orgId, niceId } = await props.params;
const t = await getTranslations();
let redirect: GetRedirectResponse["redirect"];
try {
const res = await internal.get<AxiosResponse<GetRedirectResponse>>(
`/org/${orgId}/redirect/${niceId}`,
await authCookieHeader()
);
redirect = res.data.data.redirect;
} catch {
notFound();
}
// The resource selector needs the resource's display fields up front so the
// trigger shows a name instead of a bare id before the list query resolves.
const initialResource =
redirect.resourceId && redirect.resourceNiceId
? {
resourceId: redirect.resourceId,
niceId: redirect.resourceNiceId,
name: redirect.resourceName ?? redirect.resourceNiceId,
fullDomain: redirect.resourceFullDomain,
ssl: redirect.resourceSsl ?? false,
wildcard: redirect.resourceWildcard ?? false
}
: null;
return (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={redirect.name}
description={t("redirectEditDescription")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("redirectGoBack")}
</Link>
</Button>
</div>
<RedirectForm
orgId={orgId}
redirect={redirect}
initialResource={initialResource}
/>
</>
);
}
@@ -1,39 +0,0 @@
import RedirectForm from "@app/components/RedirectForm";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const metadata: Metadata = {
title: "Create Redirect"
};
type CreateRedirectPageProps = {
params: Promise<{ orgId: string }>;
};
export default async function CreateRedirectPage(
props: CreateRedirectPageProps
) {
const { orgId } = await props.params;
const t = await getTranslations();
return (
<>
<div className="flex gap-2 justify-between">
<SettingsSectionTitle
title={t("redirectCreate")}
description={t("redirectCreateDescription")}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("redirectGoBack")}
</Link>
</Button>
</div>
<RedirectForm orgId={orgId} />
</>
);
}
-121
View File
@@ -1,121 +0,0 @@
import RedirectsTable from "@app/components/RedirectsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { build } from "@server/build";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { ListRedirectsResponse } from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Redirects"
};
type RedirectIndexPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
export const dynamic = "force-dynamic";
export default async function RedirectIndexPage(props: RedirectIndexPageProps) {
const { orgId } = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
const t = await getTranslations();
let redirects: ListRedirectsResponse["redirects"] = [];
let pagination: ListRedirectsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<ListRedirectsResponse>>(
`/org/${orgId}/redirects?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
redirects = responseData.redirects;
pagination = responseData.pagination;
} catch {
// empty list on error
}
const redirectRows = redirects.map((redirect) => ({
redirectId: redirect.redirectId,
niceId: redirect.niceId,
name: redirect.name,
subdomain: redirect.subdomain,
destinationHost: redirect.destinationHost,
pathMatchType: redirect.pathMatchType,
matchPath: redirect.matchPath,
rewritePath: redirect.rewritePath,
rewritePathType: redirect.rewritePathType,
priority: redirect.priority,
permanent: redirect.permanent,
enabled: redirect.enabled,
resourceId: redirect.resourceId,
resourceName: redirect.resourceName,
resourceNiceId: redirect.resourceNiceId,
resourceFullDomain: redirect.resourceFullDomain,
resourceDomainId: redirect.resourceDomainId,
domainId: redirect.domainId,
baseDomain: redirect.baseDomain
}));
// Prefetched in one batched call so the table doesn't fire a separate
// certificate request per visible row once it mounts on the client.
const certDomains = Array.from(
new Set(
redirectRows
.map((r) => {
const domainHost = r.baseDomain
? [r.subdomain, r.baseDomain].filter(Boolean).join(".")
: null;
return r.resourceFullDomain ?? domainHost;
})
.filter((host): host is string => Boolean(host))
)
);
let initialCertificates: GetBatchedCertificateResponse | undefined;
if (build !== "oss" && certDomains.length > 0) {
try {
const certSearchParams = new URLSearchParams(
certDomains.map((domain) => ["domains", domain])
);
const certRes = await internal.get<
AxiosResponse<GetBatchedCertificateResponse>
>(
`/org/${orgId}/batched-certificates?${certSearchParams.toString()}`,
await authCookieHeader()
);
initialCertificates = certRes.data.data;
} catch {
// leave undefined so each row falls back to fetching its own
}
}
return (
<>
<SettingsSectionTitle
title={t("redirectsTitle")}
description={t("redirectsDescription")}
/>
<RedirectsTable
orgId={orgId}
redirects={redirectRows}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialCertificates={initialCertificates}
/>
</>
);
}
@@ -119,10 +119,7 @@ function ProxyResourceHttpForm({
message: t("proxyErrorInvalidHeader")
}
),
requestHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
responseHeaders: z
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
});
@@ -134,21 +131,19 @@ function ProxyResourceHttpForm({
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || "",
setHostHeader: resource.setHostHeader || "",
requestHeaders: resource.requestHeaders,
responseHeaders: resource.responseHeaders
headers: resource.headers
},
mode: "onChange"
});
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [requestHeadersValid, setRequestHeadersValid] = useState(true);
const [responseHeadersValid, setResponseHeadersValid] = useState(true);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
if (!requestHeadersValid || !responseHeadersValid) {
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
@@ -167,8 +162,7 @@ function ProxyResourceHttpForm({
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
requestHeaders: data.requestHeaders || null,
responseHeaders: data.responseHeaders || null
headers: data.headers || null
}
)
.catch((err) => {
@@ -189,8 +183,7 @@ function ProxyResourceHttpForm({
ssl: data.ssl,
tlsServerName: data.tlsServerName || null,
setHostHeader: data.setHostHeader || null,
requestHeaders: data.requestHeaders || null,
responseHeaders: data.responseHeaders || null
headers: data.headers || null
});
toast({
@@ -331,11 +324,11 @@ function ProxyResourceHttpForm({
<SettingsFormCell span="full">
<FormField
control={form.control}
name="requestHeaders"
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customRequestHeaders")}
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
@@ -344,46 +337,14 @@ function ProxyResourceHttpForm({
field.onChange
}
onValidityChange={
setRequestHeadersValid
setHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customRequestHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="responseHeaders"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customResponseHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={field.value}
onChange={
field.onChange
}
onValidityChange={
setResponseHeadersValid
}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customResponseHeadersDescription"
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
@@ -401,11 +362,7 @@ function ProxyResourceHttpForm({
<Button
type="submit"
loading={saveLoading}
disabled={
saveLoading ||
!requestHeadersValid ||
!responseHeadersValid
}
disabled={saveLoading || !headersValid}
form="http-settings-form"
>
{t("saveSettings")}
@@ -113,7 +113,7 @@ function ProxyResourceProtocolForm({
message: t("proxyErrorInvalidHeader")
}
),
requestHeaders: z
headers: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable(),
proxyProtocol: z.boolean().optional(),
@@ -124,7 +124,7 @@ function ProxyResourceProtocolForm({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || "",
requestHeaders: resource.requestHeaders,
headers: resource.headers,
proxyProtocol: resource.proxyProtocol || false,
proxyProtocolVersion: resource.proxyProtocolVersion || 1
}
+64 -204
View File
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { LicenseKeyCache, LicenseKeyTier } from "@server/license/license";
import { LicenseKeyCache } from "@server/license/license";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
@@ -41,7 +41,6 @@ import {
SettingsSectionHeader,
SettingsSectionFooter
} from "@app/components/Settings";
import { Progress } from "@app/components/ui/progress";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
ArrowRight,
@@ -49,7 +48,6 @@ import {
ExternalLink,
Heart,
InfoIcon,
ShoppingCart,
TicketCheck
} from "lucide-react";
import Link from "next/link";
@@ -65,24 +63,12 @@ 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,
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");
}
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() {
@@ -94,6 +80,7 @@ export default function LicensePage() {
useState<LicenseKeyCache | null>(null);
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
@@ -141,6 +128,12 @@ 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"),
@@ -395,33 +388,6 @@ export default function LicensePage() {
</DismissableBanner>
)}
{licenseStatus?.isLicenseValid && rows.length > 0 && (
<DismissableBanner
storageKey="license-upgrade-banner-dismissed"
version={1}
title={t("licenseUpgradeBannerTitle")}
titleIcon={
<ShoppingCart className="w-5 h-5 text-primary" />
}
description={t("licenseUpgradeBannerDescription")}
>
<Link
href={LICENSE_PORTAL_URL}
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("licenseUpgradeBannerButton")}
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
)}
{/* <Alert variant="neutral" className="mb-6"> */}
{/* <InfoIcon className="h-4 w-4" /> */}
{/* <AlertTitle className="font-semibold"> */}
@@ -433,168 +399,62 @@ export default function LicensePage() {
{/* </Alert> */}
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseUsage")}</SSTitle>
<SettingsSectionDescription>
{t("licenseUsageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-6">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageSites")}
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (
<div className="text-2xl">
{t("licenseSitesUsed", {
count: licenseStatus?.usedSites || 0
})}
</div>
{licenseStatus?.maxSites ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseSitesUsedMax", {
usedSites:
licenseStatus.usedSites ||
0,
maxSites:
licenseStatus.maxSites
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoSiteLimit")}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageUsers")}
</div>
<div className="text-2xl">
{t("licenseUsersUsed", {
count: licenseStatus?.usedUsers || 0
})}
</div>
{licenseStatus?.maxUsers ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseUsersUsedMax", {
usedUsers:
licenseStatus.usedUsers ||
0,
maxUsers:
licenseStatus.maxUsers
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoUserLimit")}
</div>
)}
</div>
</div>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (
<div className="text-2xl">
{t("unlicensed")}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseTierLabel")}
</div>
<div className="text-lg">
{getTierLabel(licenseStatus?.tier, t)}
</div>
{rows.length > 1 && (
<div className="text-sm text-muted-foreground">
{t("licenseMultipleKeysDescription")}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
{t("unlicensed")}
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseKey")}
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
+11 -22
View File
@@ -122,20 +122,11 @@ 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 = redirectTarget.host;
const redirectPort = redirectTarget.port;
const redirectHost = new URL(searchParams.redirect).host;
const redirectPort = new URL(searchParams.redirect).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
@@ -145,16 +136,14 @@ export default async function ResourceAuthPage(props: {
return host.endsWith(suffix) && host.length > suffix.length;
};
if (
isHttpTarget &&
(serverResourceHost === redirectHost ||
serverResourceHostWithPort === redirectHost ||
(authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(
authInfo.fullDomain,
redirectHost
)))
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (serverResourceHostWithPort === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (
authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
) {
redirectUrl = searchParams.redirect;
}
@@ -294,7 +283,7 @@ export default async function ResourceAuthPage(props: {
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant ?? idp.type
variant: idp.type
})) as LoginFormIDP[];
}
+2 -11
View File
@@ -10,6 +10,7 @@ import {
Cable,
ChartLine,
Coins,
Combine,
CreditCard,
Fingerprint,
Globe,
@@ -20,11 +21,11 @@ import {
LayoutGrid,
Link as LinkIcon,
Logs,
MessageSquare,
MessagesSquare,
MonitorUp,
Plug,
ReceiptText,
Repeat,
ScanEye,
Server,
Settings,
@@ -140,11 +141,6 @@ export const orgNavSections = (
href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(env?.flags.usePangolinDns
? [
{
@@ -471,11 +467,6 @@ export const commandBarNavSections = (
title: "commandMachineClients",
icon: <Server className="size-4 flex-none" />
},
{
title: "sidebarRedirects",
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(env?.flags.usePangolinDns
? [
{
+2 -2
View File
@@ -1355,7 +1355,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
<FormItem>
<FormLabel>
{t(
"customRequestHeaders"
"customHeaders"
)}
</FormLabel>
<FormControl>
@@ -1379,7 +1379,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
</FormControl>
<FormDescription>
{t(
"customRequestHeadersDescription"
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
+2 -2
View File
@@ -733,7 +733,7 @@ export function HealthCheckFormFields({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("customRequestHeaders")}
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
@@ -750,7 +750,7 @@ export function HealthCheckFormFields({
</FormControl>
<FormDescription>
{t(
"customRequestHeadersDescription"
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
-49
View File
@@ -112,54 +112,6 @@ export function LicenseKeysDataTable({
}
}
},
{
accessorKey: "quantity",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("sites")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const quantity = row.original.quantity_2;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
@@ -206,7 +158,6 @@ export function LicenseKeysDataTable({
title={t("licenseKeys")}
searchPlaceholder={t("licenseKeySearch")}
searchColumn="licenseKey"
defaultSort={{ id: "terminateAt", desc: false }}
onAdd={onCreate}
addButtonText={t("licenseKeyAdd")}
enableColumnVisibility={true}
-968
View File
@@ -1,968 +0,0 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
isValidDestinationHost,
isValidRegex
} from "@server/routers/redirect/validation";
import { build } from "@server/build";
import { cn } from "@app/lib/cn";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { zodResolver } from "@hookform/resolvers/zod";
import type {
CreateRedirectResponse,
GetRedirectResponse
} from "@server/routers/redirect";
import type { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ResourceSelector, type SelectedResource } from "./resource-selector";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Plus } from "lucide-react";
import DomainPicker from "@app/components/DomainPicker";
import Link from "next/link";
const DEFAULT_PATH_MATCH_TYPE = "regex" as const;
const DEFAULT_PRIORITY = 100;
export type ExistingRedirect = GetRedirectResponse["redirect"];
type RedirectFormProps = {
orgId: string;
/** Omit to create a new redirect. */
redirect?: ExistingRedirect;
/** Name/domain of the resource the redirect is attached to, when there is one. */
initialResource?: SelectedResource | null;
};
export default function RedirectForm({
orgId,
redirect,
initialResource = null
}: RedirectFormProps) {
const isEditing = Boolean(redirect);
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<SelectedResource | null>(initialResource);
// DomainPicker only hands back the composed host through its callback, so
// keep it locally; seed from the saved redirect for the edit case.
const [domainFullDomain, setDomainFullDomain] = useState<string | null>(
redirect?.baseDomain
? [redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")
: null
);
const formSchema = useMemo(
() =>
z
.object({
name: z
.string()
.trim()
.min(1, { message: t("nameRequired") }),
attachTo: z.enum(["domain", "resource"]),
domainId: z.string().nullable(),
subdomain: z.string().nullable(),
resourceId: z.number().int().positive().nullable(),
destinationHost: z
.string()
.trim()
.min(1, {
message: t("redirectDestinationHostRequired")
})
.refine(isValidDestinationHost, {
message: t("redirectDestinationHostInvalid")
}),
pathMatchType: z.enum(["exact", "prefix", "regex"]),
matchPath: z.string().trim().nullable(),
rewritePath: z.string().nullable(),
rewritePathType: z
.enum(["exact", "prefix", "regex", "stripPrefix"])
.nullable(),
priority: z
.number()
.int()
.min(1, { message: t("redirectPriorityInvalid") })
.max(1000, { message: t("redirectPriorityInvalid") }),
permanent: z.boolean(),
ssl: z.boolean(),
enabled: z.boolean()
})
.superRefine((data, ctx) => {
if (data.attachTo === "domain" && !data.domainId) {
ctx.addIssue({
code: "custom",
message: t("redirectDomainRequired"),
path: ["domainId"]
});
}
if (data.attachTo === "resource" && !data.resourceId) {
ctx.addIssue({
code: "custom",
message: t("redirectResourceRequired"),
path: ["resourceId"]
});
}
if (
data.pathMatchType === "regex" &&
data.matchPath &&
!isValidRegex(data.matchPath)
) {
ctx.addIssue({
code: "custom",
message: t("redirectMatchPathInvalidRegex"),
path: ["matchPath"]
});
}
// stripPrefix drops the matched prefix outright, so it is
// the one rewrite type that needs no replacement value.
if (
data.rewritePathType &&
data.rewritePathType !== "stripPrefix" &&
!data.rewritePath
) {
ctx.addIssue({
code: "custom",
message: t("redirectRewritePathRequired"),
path: ["rewritePath"]
});
}
}),
[t]
);
type RedirectFormValues = z.infer<typeof formSchema>;
const form = useForm<RedirectFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: redirect?.name ?? "",
attachTo: redirect?.resourceId ? "resource" : "domain",
domainId: redirect?.domainId ?? null,
subdomain: redirect?.subdomain ?? null,
resourceId: redirect?.resourceId ?? null,
destinationHost: redirect?.destinationHost ?? "",
pathMatchType: redirect?.pathMatchType ?? DEFAULT_PATH_MATCH_TYPE,
matchPath: redirect?.matchPath ?? null,
rewritePath: redirect?.rewritePath ?? null,
rewritePathType: redirect?.rewritePathType ?? null,
priority: redirect?.priority ?? DEFAULT_PRIORITY,
permanent: redirect?.permanent ?? false,
ssl: redirect?.ssl ?? true,
enabled: redirect?.enabled ?? true
}
});
const attachTo = form.watch("attachTo");
const ssl = form.watch("ssl");
const sourceFullDomain =
attachTo === "domain"
? domainFullDomain
: (selectedResource?.fullDomain ?? null);
// Resource-attached redirects inherit the resource's ssl setting
const sourceSsl =
attachTo === "domain" ? ssl : (selectedResource?.ssl ?? true);
const sourceHost = sourceFullDomain
? `${sourceSsl ? "https" : "http"}://${sourceFullDomain}`
: null;
// Mirror is UI-only state; on edit, infer it from whether the saved
// destination already equals the source host.
const [sameDomainAsSource, setSameDomainAsSource] = useState(
Boolean(
redirect && sourceHost && redirect.destinationHost === sourceHost
)
);
useEffect(() => {
if (sameDomainAsSource && sourceHost) {
form.setValue("destinationHost", sourceHost, {
shouldValidate: true
});
}
}, [sameDomainAsSource, sourceHost, form]);
const pathMatchType = form.watch("pathMatchType");
const rewritePath = form.watch("rewritePath");
const rewritePathType = form.watch("rewritePathType");
// stripPrefix is a valid rewrite with no path value, so it counts as set.
const hasRewrite =
Boolean(rewritePath) || rewritePathType === "stripPrefix";
async function onSubmit() {
if (!(await form.trigger())) return;
const values = form.getValues();
// Only one of the two attachment points is ever persisted; clear the
// other so switching between them doesn't leave a stale reference.
const body = {
name: values.name.trim(),
domainId: values.attachTo === "domain" ? values.domainId : null,
subdomain:
values.attachTo === "domain" ? values.subdomain || null : null,
resourceId:
values.attachTo === "resource" ? values.resourceId : null,
destinationHost: values.destinationHost.trim(),
pathMatchType: values.pathMatchType,
matchPath: values.matchPath?.trim() || null,
rewritePath: values.rewritePath?.trim() || null,
rewritePathType: values.rewritePathType,
priority: values.priority,
permanent: values.permanent,
// Resource-attached redirects inherit the resource's ssl setting
ssl: values.attachTo === "domain" ? values.ssl : true,
enabled: values.enabled
};
try {
if (isEditing) {
await api.post(
`/org/${orgId}/redirects/${redirect!.redirectId}`,
body
);
toast({
title: t("success"),
description: t("redirectUpdated")
});
} else {
const res = await api.put<
AxiosResponse<CreateRedirectResponse>
>(`/org/${orgId}/redirect`, body);
toast({
title: t("success"),
description: t("redirectCreated")
});
}
router.push(`/${orgId}/settings/redirects/`);
} catch (e) {
toast({
variant: "destructive",
title: isEditing
? t("redirectErrorUpdate")
: t("redirectErrorCreate"),
description: formatAxiosError(
e,
isEditing
? t("redirectErrorUpdate")
: t("redirectErrorCreate")
)
});
}
}
async function onDelete() {
setDeleteLoading(true);
try {
await api.delete(`/org/${orgId}/redirects/${redirect!.redirectId}`);
toast({
title: t("success"),
description: t("redirectDeleted")
});
router.push(`/${orgId}/settings/redirects`);
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorDelete"),
description: formatAxiosError(e, t("redirectErrorDelete"))
});
} finally {
setDeleteLoading(false);
setIsDeleteModalOpen(false);
}
}
return (
<>
{isEditing && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={setIsDeleteModalOpen}
dialog={
<div className="space-y-2">
<p>{t("redirectQuestionRemove")}</p>
<p>{t("redirectMessageRemove")}</p>
</div>
}
buttonText={t("redirectDeleteConfirm")}
onConfirm={onDelete}
string={redirect!.name}
title={t("redirectDelete")}
/>
)}
<SettingsContainer>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("redirectSource")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("redirectSourceSectionDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form action={formAction} id="redirect-form">
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-enabled"
label={t(
"enabled"
)}
description={t(
"redirectEnabledDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="attachTo"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"redirectAttachedTo"
)}
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="domain">
{t(
"redirectAttachDomain"
)}
</SelectItem>
<SelectItem value="resource">
{t(
"redirectAttachResource"
)}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t(
"redirectAttachedToDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{attachTo === "domain" ? (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
orgId={orgId}
cols={1}
hideFreeDomain
defaultDomainId={
redirect?.domainId
}
allowWildcard
defaultSubdomain={
redirect?.subdomain
}
onDomainChange={(
res
) => {
form.setValue(
"domainId",
res?.domainId ??
null,
{
shouldValidate: true
}
);
form.setValue(
"subdomain",
res?.subdomain ||
null
);
setDomainFullDomain(
res?.fullDomain ??
null
);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
) : (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(
"selectedRedirectResource"
)}
</FormLabel>
<Popover>
<PopoverTrigger
asChild
>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{selectedResource?.name ??
t(
"resourceSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<ResourceSelector
orgId={
orgId
}
selectedResource={
selectedResource
}
onSelectResource={(
resource
) => {
setSelectedResource(
resource
);
field.onChange(
resource.resourceId
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{attachTo === "resource" && (
<SettingsFormCell span="full">
<FormItem>
<FormLabel>
{t("resourceDomain")}
</FormLabel>
<Input
disabled
readOnly
value={
selectedResource?.fullDomain ??
""
}
placeholder={
selectedResource
? t(
"redirectResourceNoDomain"
)
: t(
"resourceSelect"
)
}
/>
</FormItem>
</SettingsFormCell>
)}
{/* The cloud only serves HTTPS, so there is nothing to toggle there. */}
{build !== "saas" && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-ssl"
label={t(
"proxyEnableSSL"
)}
description={
attachTo ===
"resource"
? t(
"redirectSslInheritedDescription"
)
: t(
"redirectSslDescription"
)
}
disabled={
attachTo ===
"resource"
}
checked={
attachTo ===
"resource"
? (selectedResource?.ssl ??
true)
: field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection className="pb-10">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("redirectSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("redirectSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form action={formAction}>
<SettingsFormGrid>
<SettingsFormCell span="full">
<SwitchInput
id="redirect-same-domain"
label={t(
"redirectSameDomainAsSource"
)}
description={t(
"redirectSameDomainAsSourceDescription"
)}
checked={sameDomainAsSource}
onCheckedChange={
setSameDomainAsSource
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="destinationHost"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"redirectDestinationHost"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder="https://example.com"
readOnly={
sameDomainAsSource
}
{...field}
/>
</FormControl>
<FormDescription>
{t(
"redirectDestinationHostDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="matchPath"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("matchPath")}
</FormLabel>
<PathMatchModal
value={{
path: field.value,
pathMatchType:
pathMatchType
}}
onChange={(
config
) => {
// No match path
// means the
// redirect applies
// to every path;
// pathMatchType is
// NOT NULL so it
// keeps a default.
field.onChange(
config.path ||
null
);
form.setValue(
"pathMatchType",
(config.pathMatchType as
| "exact"
| "prefix"
| "regex") ||
DEFAULT_PATH_MATCH_TYPE
);
}}
trigger={
<Button
type="button"
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
>
{field.value ? (
<PathMatchDisplay
value={{
path: field.value,
pathMatchType:
pathMatchType
}}
/>
) : (
<>
<Plus className="h-4 w-4" />
{t(
"matchPath"
)}
</>
)}
</Button>
}
/>
<FormDescription>
{t(
"redirectMatchPathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="rewritePath"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("rewritePath")}
</FormLabel>
<PathRewriteModal
value={{
rewritePath:
field.value,
rewritePathType:
rewritePathType
}}
onChange={(
config
) => {
field.onChange(
config.rewritePath ||
null
);
form.setValue(
"rewritePathType",
(config.rewritePathType as
| "exact"
| "prefix"
| "regex"
| "stripPrefix"
| null) ??
null
);
}}
trigger={
hasRewrite ? (
<Button
type="button"
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer"
>
<PathRewriteDisplay
value={{
rewritePath:
field.value,
rewritePathType:
rewritePathType
}}
/>
</Button>
) : (
<Button
type="button"
variant="outline"
className="w-full"
>
<Plus className="h-4 w-4 mr-2" />
{t(
"rewritePath"
)}
</Button>
)
}
/>
<FormDescription>
{t(
"redirectRewritePathDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("priority")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={1000}
{...field}
onChange={(e) =>
field.onChange(
e.target
.valueAsNumber
)
}
/>
</FormControl>
<FormDescription>
{t(
"redirectPriorityDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="permanent"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="redirect-permanent"
label={t(
"redirectPermanent"
)}
description={t(
"redirectPermanentDescription"
)}
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{isEditing && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("dangerSection")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionFooter>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
loading={deleteLoading}
disabled={deleteLoading}
>
{t("redirectDelete")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button type="button" variant="outline" asChild>
<Link href={`/${orgId}/settings/redirects`}>
{t("cancel")}
</Link>
</Button>
<Button
type="submit"
form="redirect-form"
loading={saveLoading}
disabled={saveLoading}
>
{isEditing ? t("saveSettings") : t("redirectAdd")}
</Button>
</div>
</SettingsContainer>
</>
);
}
-500
View File
@@ -1,500 +0,0 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Switch } from "@app/components/ui/switch";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown,
ArrowRight,
ArrowUp,
ArrowUpRight,
GlobeIcon,
MinusIcon,
MoreHorizontal,
WaypointsIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import { wait } from "@app/lib/wait";
export type RedirectRow = {
redirectId: number;
niceId: string;
name: string;
subdomain: string | null;
destinationHost: string;
pathMatchType: "exact" | "prefix" | "regex";
matchPath: string | null;
rewritePath: string | null;
rewritePathType: "exact" | "prefix" | "regex" | "stripPrefix" | null;
priority: number | null;
permanent: boolean;
enabled: boolean;
resourceId: number | null;
resourceName: string | null;
resourceNiceId: string | null;
resourceFullDomain: string | null;
resourceDomainId: string | null;
domainId: string | null;
baseDomain: string | null;
};
type RedirectsTableProps = {
redirects: RedirectRow[];
orgId: string;
pagination: PaginationState;
rowCount: number;
initialCertificates?: GetBatchedCertificateResponse;
};
export default function RedirectsTable({
redirects,
orgId,
pagination,
rowCount,
initialCertificates
}: RedirectsTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [selected, setSelected] = useState<RedirectRow | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
function refreshData() {
startTransition(() => {
try {
router.refresh();
} catch {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({ searchParams });
}, 300);
// Prefix matches/rewrites cover everything beneath the path, so show the
// implied glob rather than the bare prefix.
function withPrefixGlob(path: string) {
return path.endsWith("/") ? `${path}*` : `${path}/*`;
}
async function toggleEnabled(enabled: boolean, redirectId: number) {
try {
await api.post(`/org/${orgId}/redirects/${redirectId}`, {
enabled
});
toast({
title: t("success"),
description: t("redirectUpdated")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorUpdate"),
description: formatAxiosError(e, t("redirectErrorUpdate"))
});
}
}
function deleteRedirect(row: RedirectRow) {
startTransition(async () => {
try {
await api.delete(`/org/${orgId}/redirects/${row.redirectId}`);
setIsDeleteModalOpen(false);
setSelected(null);
toast({
title: t("success"),
description: t("redirectDeleted")
});
router.refresh();
} catch (e) {
toast({
variant: "destructive",
title: t("redirectErrorDelete"),
description: formatAxiosError(e, t("redirectErrorDelete"))
});
}
});
}
const columns = useMemo<ExtendedColumnDef<RedirectRow>[]>(
() => [
{
accessorKey: "name",
enableHiding: false,
header: () => <span className="p-3">{t("name")}</span>,
cell: ({ row }) => (
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
className="hover:underline"
>
{row.original.name}
</Link>
)
},
{
id: "niceId",
accessorKey: "niceId",
friendlyName: t("identifier"),
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => (
<code className="text-sm">{row.original.niceId}</code>
)
},
{
id: "attachedTo",
friendlyName: t("redirectAttachedTo"),
header: () => (
<span className="p-3">{t("redirectAttachedTo")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
if (redirect.resourceId && redirect.resourceNiceId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/resources/public/${redirect.resourceNiceId}`}
>
<WaypointsIcon className="size-3 text-muted-foreground" />
{redirect.resourceName}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
if (redirect.domainId) {
return (
<Button
variant="outline"
size="sm"
asChild
className="inline-flex items-center gap-1.5"
>
<Link
href={`/${orgId}/settings/domains/${redirect.domainId}`}
>
<GlobeIcon className="size-3 text-muted-foreground" />
{redirect.baseDomain}
<ArrowUpRight className="size-3" />
</Link>
</Button>
);
}
return <span>-</span>;
}
},
{
id: "source",
friendlyName: t("redirectSource"),
header: () => (
<span className="p-3">{t("redirectSource")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
// A domain-attached redirect may target a specific host
// under the base domain, e.g. old.example.com.
const domainHost = redirect.baseDomain
? [redirect.subdomain, redirect.baseDomain]
.filter(Boolean)
.join(".")
: null;
const host = redirect.resourceFullDomain ?? domainHost;
// The cert lives on whichever domain actually terminates
// TLS: the resource's domain when attached to a resource,
// otherwise the redirect's own domain.
const certDomainId =
redirect.resourceDomainId ?? redirect.domainId;
return (
<div className="flex items-center gap-2 min-w-0">
{certDomainId && host ? (
<ResourceAccessCertIndicator
orgId={orgId}
domainId={certDomainId}
fullDomain={host}
initialCertValue={
initialCertificates?.[host]
}
/>
) : null}
<code className="text-sm truncate">
{host ?? ""}
{redirect.matchPath && (
<span className="text-muted-foreground">
{redirect.pathMatchType === "prefix"
? withPrefixGlob(redirect.matchPath)
: redirect.matchPath}
</span>
)}
</code>
</div>
);
}
},
{
id: "destination",
accessorKey: "destinationHost",
friendlyName: t("redirectDestination"),
header: () => (
<span className="p-3">{t("redirectDestination")}</span>
),
cell: ({ row }) => {
const redirect = row.original;
return (
<code className="text-sm truncate">
{redirect.destinationHost}
{redirect.rewritePath && (
<span className="text-muted-foreground">
{redirect.rewritePathType === "prefix"
? withPrefixGlob(redirect.rewritePath)
: redirect.rewritePath}
</span>
)}
</code>
);
}
},
{
accessorKey: "priority",
friendlyName: t("priority"),
header: () => <span className="p-3">{t("priority")}</span>,
cell: ({ row }) => {
// 100 is the automatic default; anything else was set
// deliberately, so flag which way it deviates.
const priority = row.original.priority ?? 100;
return (
<span className="inline-flex items-center gap-1">
{priority}
{priority > 100 ? (
<ArrowUp className="size-3 text-green-500" />
) : priority < 100 ? (
<ArrowDown className="size-3 text-red-500" />
) : (
<MinusIcon className="size-3 text-muted-foreground" />
)}
</span>
);
}
},
{
accessorKey: "permanent",
friendlyName: t("redirectType"),
header: () => <span className="p-3">{t("redirectType")}</span>,
cell: ({ row }) => (
<Badge variant="secondary">
{row.original.permanent
? t("redirectTypePermanent")
: t("redirectTypeTemporary")}
</Badge>
)
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<RedirectEnabledForm
redirect={row.original}
onToggleEnabled={toggleEnabled}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
{t("edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(row.original);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/redirects/${row.original.niceId}`}
>
<Button variant="outline">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
)
}
],
[orgId, t, initialCertificates]
);
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelected(null);
}
}}
dialog={
<div className="space-y-2">
<p>{t("redirectQuestionRemove")}</p>
<p>{t("redirectMessageRemove")}</p>
</div>
}
buttonText={t("redirectDeleteConfirm")}
onConfirm={async () => deleteRedirect(selected)}
string={selected.name}
title={t("redirectDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={redirects}
addButtonText={t("redirectAdd")}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/redirects/create`)
)
}
isNavigatingToAddPage={isNavigatingToAddPage}
tableId="redirects-table"
searchPlaceholder={t("redirectsSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{
attachedTo: false,
niceId: false,
permanent: false,
priority: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
type RedirectEnabledFormProps = {
redirect: RedirectRow;
onToggleEnabled: (val: boolean, redirectId: number) => Promise<void>;
};
function RedirectEnabledForm({
redirect,
onToggleEnabled
}: RedirectEnabledFormProps) {
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
redirect.enabled
);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleEnabled(newEnabled, redirect.redirectId);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={optimisticEnabled !== redirect.enabled}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
+16 -68
View File
@@ -1,46 +1,23 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-6", className)}>{children}</div>;
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
}
export function SettingsSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
export function SettingsSection({ children }: { children: React.ReactNode }) {
return (
<div
className={cn(
"border rounded-lg bg-card p-5 flex flex-col min-h-[200px]",
className
)}
>
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
{children}
</div>
);
}
export function SettingsSectionHeader({
children,
className
children
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("text-lg space-y-0.5 pb-6", className)}>
{children}
</div>
);
return <div className="text-lg space-y-0.5 pb-6">{children}</div>;
}
export function SettingsSectionForm({
@@ -100,7 +77,7 @@ export function SettingsFormCell({
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "col-span-full",
span === "full" && "md:col-span-4",
className
)}
>
@@ -110,36 +87,23 @@ export function SettingsFormCell({
}
export function SettingsSectionTitle({
children,
className
children
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<h2
className={cn(
"text-1xl font-semibold tracking-tight flex items-center gap-2",
className
)}
>
<h2 className="text-1xl font-semibold tracking-tight flex items-center gap-2">
{children}
</h2>
);
}
export function SettingsSectionDescription({
children,
className
children
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<p className={cn("text-muted-foreground text-sm", className)}>
{children}
</p>
);
return <p className="text-muted-foreground text-sm">{children}</p>;
}
export function SettingsSubsectionHeader({
@@ -177,15 +141,11 @@ export function SettingsSubsectionDescription({
}
export function SettingsSectionBody({
children,
className
children
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("space-y-5 flex-grow", className)}>{children}</div>
);
return <div className="space-y-5 flex-grow">{children}</div>;
}
export function SettingsSectionFooter({
@@ -209,22 +169,10 @@ export function SettingsSectionFooter({
export function SettingsSectionGrid({
children,
cols = 4,
className
cols
}: {
children: React.ReactNode;
cols?: number;
className?: string;
cols: number;
}) {
return (
<div
style={{
// @ts-expect-error
"--cols": `repeat(${cols}, minmax(0, 1fr))`
}}
className={cn(`grid md:grid-cols-(--cols) gap-6`, className)}
>
{children}
</div>
);
return <div className={`grid md:grid-cols-${cols} gap-6`}>{children}</div>;
}
+2 -80
View File
@@ -20,7 +20,7 @@ import {
FaWindows
} from "react-icons/fa";
import { Download, ExternalLink } from "lucide-react";
import { SiAlpinelinux, SiKubernetes, SiNixos } from "react-icons/si";
import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem =
@@ -30,7 +30,6 @@ export type CommandItem =
const PLATFORMS = [
"linux",
"alpine",
"macos",
"docker",
"kubernetes",
@@ -66,8 +65,7 @@ export function NewtSiteInstallCommands({
);
const showSiteConfiguration = platform !== "advantech";
const supportsSshOption =
platform === "linux" || platform === "nixos" || platform === "alpine";
const supportsSshOption = platform === "linux" || platform === "nixos";
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
@@ -170,76 +168,6 @@ 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: [
{
@@ -550,8 +478,6 @@ function getPlatformIcon(platformName: Platform) {
return <FaWindows className="h-4 w-4 mr-2" />;
case "linux":
return <FaLinux className="h-4 w-4 mr-2" />;
case "alpine":
return <SiAlpinelinux className="h-4 w-4 mr-2" />;
case "macos":
return <FaApple className="h-4 w-4 mr-2" />;
case "docker":
@@ -575,8 +501,6 @@ function getPlatformName(platformName: Platform) {
return "Windows";
case "linux":
return "Linux";
case "alpine":
return "Alpine Linux";
case "macos":
return "macOS";
case "docker":
@@ -598,8 +522,6 @@ 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":
@@ -37,7 +37,6 @@ 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,
@@ -64,8 +63,7 @@ import {
} from "react";
import {
validatePolicyRulePriority,
validatePolicyRuleValue,
type PolicyRuleMatchType
validatePolicyRuleValue
} from "./policy-access-rule-validation";
import {
buildDisplayPrioritiesForResourceOverlay,
@@ -114,80 +112,6 @@ 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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className="w-full min-w-0 justify-between"
>
<span className="truncate">
{selected.length > 0 ? selected.join(", ") : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandList>
<CommandGroup>
{options.map((method) => (
<CommandItem
key={method}
value={method}
onSelect={() => toggle(method)}
>
<Check
className={`mr-2 h-4 w-4 ${
selected.includes(method)
? "opacity-100"
: "opacity-0"
}`}
/>
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function PolicyAccessRulesTable({
rules,
onRulesChange,
@@ -309,8 +233,7 @@ export function PolicyAccessRulesTable({
COUNTRY: t("country"),
COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN",
REGION: t("region"),
METHOD: t("method")
REGION: t("region")
}),
[t]
);
@@ -515,7 +438,16 @@ export function PolicyAccessRulesTable({
<Select
defaultValue={row.original.match}
disabled={readonly || isRuleLocked(row.original)}
onValueChange={(value: PolicyRuleMatchType) =>
onValueChange={(
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
@@ -526,9 +458,7 @@ export function PolicyAccessRulesTable({
? "AS15169"
: value === "REGION"
? "021"
: value === "METHOD"
? "GET"
: row.original.value
: row.original.value
})
}
>
@@ -543,9 +473,6 @@ export function PolicyAccessRulesTable({
<SelectItem value="CIDR">
{RuleMatch.CIDR}
</SelectItem>
<SelectItem value="METHOD">
{RuleMatch.METHOD}
</SelectItem>
{isMaxmindAvailable && (
<>
<SelectItem value="COUNTRY">
@@ -852,15 +779,6 @@ export function PolicyAccessRulesTable({
</Command>
</PopoverContent>
</Popover>
) : row.original.match === "METHOD" ? (
<RuleMethodSelect
value={row.original.value}
disabled={readonly || isRuleLocked(row.original)}
placeholder={t("rulesSelectMethods")}
onChange={(value) =>
updateRule(row.original.ruleId, { value })
}
/>
) : (
<Input
defaultValue={row.original.value}
@@ -2,7 +2,6 @@ import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions";
import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
@@ -20,8 +19,7 @@ export const POLICY_RULE_MATCH_TYPES = [
"COUNTRY",
"COUNTRY_IS_NOT",
"ASN",
"REGION",
"METHOD"
"REGION"
] as const;
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
@@ -86,10 +84,6 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
(value) => COUNTRIES.some((country) => country.code === value),
{ message: t("rulesErrorInvalidCountryDescription") }
);
case "METHOD":
return required.refine(isValidHttpMethodList, {
message: t("rulesErrorInvalidMethodDescription")
});
case "ASN":
return required.refine(
(value) => {
-1
View File
@@ -44,7 +44,6 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"read-only:opacity-50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"focus-visible:outline-none focus-visible:border-ring focus-visible:ring-offset-0",
className