mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-22 18:43:15 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fd4383396 | |||
| 70f677a277 | |||
| d38281b9f7 | |||
| 0de5c763b3 | |||
| 0364257e68 | |||
| 9de91e2f81 | |||
| d134304159 | |||
| 750f267451 | |||
| 0c752c9c29 | |||
| 3392120855 | |||
| 74d10acad9 | |||
| 887c6e4e8b | |||
| f71b9e7c54 | |||
| 5dd4c54ad0 | |||
| 6f3e0cf5a6 | |||
| d4488ec125 | |||
| b8572524a4 | |||
| b054f90149 | |||
| c290239894 | |||
| 8e2f9ea5ef | |||
| 032eeb2656 | |||
| 5ca08d71f0 | |||
| 1f453dc04f | |||
| a7d4745f93 | |||
| 6ad09adefa | |||
| a8c1ddb448 | |||
| de67f06b43 | |||
| 713e24503d | |||
| 6952d3bee7 | |||
| 8dee505eb2 | |||
| 382f5d5218 | |||
| 9d6062619c | |||
| 6c7d345f03 | |||
| c8357e8653 | |||
| 9804c0db28 |
@@ -3,18 +3,25 @@ name: ESLint
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
on:
|
||||||
pull_request:
|
workflow_dispatch:
|
||||||
paths:
|
|
||||||
- '**/*.js'
|
|
||||||
- '**/*.jsx'
|
|
||||||
- '**/*.ts'
|
|
||||||
- '**/*.tsx'
|
|
||||||
- '.eslintrc*'
|
|
||||||
- 'package.json'
|
|
||||||
- 'yarn.lock'
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- 'package-lock.json'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Linter:
|
Linter:
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# FROM node:24.18.1-slim AS base
|
# FROM node:24.18.1-slim AS base
|
||||||
FROM public.ecr.aws/docker/library/node:26.9.0-slim AS base
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ FROM base AS builder
|
|||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# FROM node:24.18.1-slim AS runner
|
# FROM node:24.18.1-slim AS runner
|
||||||
FROM public.ecr.aws/docker/library/node:26.9.0-slim AS runner
|
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:26.9.0-alpine
|
FROM node:24.18.1-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Опити за повторно",
|
"retryAttempts": "Опити за повторно",
|
||||||
"expectedResponseCodes": "Очаквани кодове за отговор",
|
"expectedResponseCodes": "Очаквани кодове за отговор",
|
||||||
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
|
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
|
||||||
"customHeaders": "Персонализирани заглавия",
|
"customRequestHeaders": "Персонализирани заглавия на заявката",
|
||||||
"customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value",
|
"customRequestHeadersDescription": "Заглавия на заявката, изпратени до целевите сървъри. По едно на ред: Заглавие-Име: стойност",
|
||||||
|
"customResponseHeaders": "Персонализирани заглавия на отговора",
|
||||||
|
"customResponseHeadersDescription": "Заглавия на отговора, върнати на клиента. По едно на ред: Заглавие-Име: стойност",
|
||||||
"headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.",
|
"headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.",
|
||||||
"saveHealthCheck": "Запазване на проверка на здравето",
|
"saveHealthCheck": "Запазване на проверка на здравето",
|
||||||
"healthCheckSaved": "Проверка на здравето е запазена",
|
"healthCheckSaved": "Проверка на здравето е запазена",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Opakovat pokusy",
|
"retryAttempts": "Opakovat pokusy",
|
||||||
"expectedResponseCodes": "Očekávané kódy odezvy",
|
"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é.",
|
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
|
||||||
"customHeaders": "Vlastní záhlaví",
|
"customRequestHeaders": "Vlastní záhlaví požadavku",
|
||||||
"customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota",
|
"customRequestHeadersDescription": "Záhlaví požadavku odeslaná do cílů. Jedno na řádek: Název-záhlaví: hodnota",
|
||||||
|
"customResponseHeaders": "Vlastní záhlaví odpovědi",
|
||||||
|
"customResponseHeadersDescription": "Záhlaví odpovědi vrácená klientovi. Jedno na řádek: Název-záhlaví: hodnota",
|
||||||
"headersValidationError": "Headers must be in the format: Header-Name: value.",
|
"headersValidationError": "Headers must be in the format: Header-Name: value.",
|
||||||
"saveHealthCheck": "Uložit kontrolu stavu",
|
"saveHealthCheck": "Uložit kontrolu stavu",
|
||||||
"healthCheckSaved": "Kontrola stavu uložena",
|
"healthCheckSaved": "Kontrola stavu uložena",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Forsøg igen",
|
"retryAttempts": "Forsøg igen",
|
||||||
"expectedResponseCodes": "Forventede svarkoder",
|
"expectedResponseCodes": "Forventede svarkoder",
|
||||||
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sund status. Hvis den bliver stående tom, regnes 200-300 som sund.",
|
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sund status. Hvis den bliver stående tom, regnes 200-300 som sund.",
|
||||||
"customHeaders": "Brugerdefinerede headers",
|
"customRequestHeaders": "Brugerdefinerede anmodnings-headers",
|
||||||
"customHeadersDescription": "Headers som er adskilt med linje: Overskriftsnavn: værdi",
|
"customRequestHeadersDescription": "Anmodnings-headers som sendes til destinationerne. Én pr. linje: Header-navn: værdi",
|
||||||
|
"customResponseHeaders": "Brugerdefinerede svar-headers",
|
||||||
|
"customResponseHeadersDescription": "Svar-headers som sendes tilbage til klienten. Én pr. linje: Header-navn: værdi",
|
||||||
"headersValidationError": "Header skal være i formatet: header-navn: værdi.",
|
"headersValidationError": "Header skal være i formatet: header-navn: værdi.",
|
||||||
"saveHealthCheck": "Gem Sundhedstjek",
|
"saveHealthCheck": "Gem Sundhedstjek",
|
||||||
"healthCheckSaved": "Sundhedstjek Gemt",
|
"healthCheckSaved": "Sundhedstjek Gemt",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Wiederholungsversuche",
|
"retryAttempts": "Wiederholungsversuche",
|
||||||
"expectedResponseCodes": "Erwartete Antwortcodes",
|
"expectedResponseCodes": "Erwartete Antwortcodes",
|
||||||
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
|
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
|
||||||
"customHeaders": "Eigene Kopfzeilen",
|
"customRequestHeaders": "Eigene Anfrage-Header",
|
||||||
"customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert",
|
"customRequestHeadersDescription": "Anfrage-Header, die an die Ziele gesendet werden. Eine pro Zeile: Header-Name: Wert",
|
||||||
|
"customResponseHeaders": "Eigene Antwort-Header",
|
||||||
|
"customResponseHeadersDescription": "Antwort-Header, die an den Client zurückgesendet werden. Eine pro Zeile: Header-Name: Wert",
|
||||||
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
|
"headersValidationError": "Header müssen im Format Header-Name: Wert sein.",
|
||||||
"saveHealthCheck": "Gesundheits-Check speichern",
|
"saveHealthCheck": "Gesundheits-Check speichern",
|
||||||
"healthCheckSaved": "Gesundheits-Check gespeichert",
|
"healthCheckSaved": "Gesundheits-Check gespeichert",
|
||||||
|
|||||||
+28
-10
@@ -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.",
|
"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.",
|
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
|
||||||
"siteRestarted": "Site restarted",
|
"siteRestarted": "Site restarted",
|
||||||
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
|
"siteRestartedDescription": "The site has been restarted.",
|
||||||
"siteErrorRestart": "Failed to restart site",
|
"siteErrorRestart": "Failed to restart site",
|
||||||
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
||||||
"siteSettingDescription": "Configure the settings on the site",
|
"siteSettingDescription": "Configure the settings on the site",
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"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.",
|
"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",
|
"licenseBillingBannerTitle": "Manage License Billing",
|
||||||
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
|
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page. You can generate additional licenses below to increase server capacity or deploy more instances.",
|
||||||
"licenseBillingBannerButton": "Go to Billing",
|
"licenseBillingBannerButton": "Go to Billing",
|
||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
@@ -558,6 +558,10 @@
|
|||||||
"licenseBannerDescription": "Unlock enterprise features for your self-hosted Pangolin instance. Purchase a license key to activate premium capabilities, then add it below.",
|
"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",
|
"licenseBannerGetLicense": "Get a License",
|
||||||
"licenseBannerViewDocs": "View Documentation",
|
"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",
|
"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.",
|
"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",
|
"licenseKeyActivated": "License key activated",
|
||||||
@@ -576,10 +580,10 @@
|
|||||||
"licenseQuestionRemove": "Are you sure you want to delete the license key ?",
|
"licenseQuestionRemove": "Are you sure you want to delete the license key ?",
|
||||||
"licenseKeyDelete": "Delete License Key",
|
"licenseKeyDelete": "Delete License Key",
|
||||||
"licenseKeyDeleteConfirm": "Confirm Delete License Key",
|
"licenseKeyDeleteConfirm": "Confirm Delete License Key",
|
||||||
"licenseTitle": "Manage License Status",
|
"licenseTitle": "Manage Licenses",
|
||||||
"licenseTitleDescription": "View and manage license keys in the system",
|
"licenseTitleDescription": "View and manage license keys in the system",
|
||||||
"licenseHost": "Host License",
|
"licenseHost": "License",
|
||||||
"licenseHostDescription": "Manage the main license key for the host.",
|
"licenseHostDescription": "See the license tier and host information",
|
||||||
"licensedNot": "Not Licensed",
|
"licensedNot": "Not Licensed",
|
||||||
"hostId": "Host ID",
|
"hostId": "Host ID",
|
||||||
"licenseReckeckAll": "Recheck All Keys",
|
"licenseReckeckAll": "Recheck All Keys",
|
||||||
@@ -589,7 +593,16 @@
|
|||||||
"licensePurchase": "Purchase License",
|
"licensePurchase": "Purchase License",
|
||||||
"licensePurchaseSites": "Purchase Additional Sites",
|
"licensePurchaseSites": "Purchase Additional Sites",
|
||||||
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
|
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
|
||||||
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
|
"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",
|
||||||
"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.}}",
|
"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",
|
"licenseFee": "License fee",
|
||||||
"licensePriceSite": "Price per site",
|
"licensePriceSite": "Price per site",
|
||||||
@@ -834,7 +847,7 @@
|
|||||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||||
"rulesErrorValidation": "Invalid rules",
|
"rulesErrorValidation": "Invalid rules",
|
||||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||||
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, ASN, or method).",
|
||||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||||
"rulesErrorInvalidCountry": "Invalid country",
|
"rulesErrorInvalidCountry": "Invalid country",
|
||||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||||
@@ -2829,8 +2842,10 @@
|
|||||||
"retryAttempts": "Retry Attempts",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||||
"customHeaders": "Custom Request Headers",
|
"customRequestHeaders": "Custom Request Headers",
|
||||||
"customHeadersDescription": "Request headers sent to the downstream targets. Headers new line separated: Header-Name: value",
|
"customRequestHeadersDescription": "Request headers sent to the downstream targets. One per line: Header-Name: value",
|
||||||
|
"customResponseHeaders": "Custom Response Headers",
|
||||||
|
"customResponseHeadersDescription": "Response headers sent back to the client. One per line: Header-Name: value",
|
||||||
"headersValidationError": "Headers must be in the format: Header-Name: value",
|
"headersValidationError": "Headers must be in the format: Header-Name: value",
|
||||||
"saveHealthCheck": "Save Health Check",
|
"saveHealthCheck": "Save Health Check",
|
||||||
"healthCheckSaved": "Health Check Saved",
|
"healthCheckSaved": "Health Check Saved",
|
||||||
@@ -4400,5 +4415,8 @@
|
|||||||
"sessionToolbarShow": "Show toolbar",
|
"sessionToolbarShow": "Show toolbar",
|
||||||
"sessionToolbarHide": "Hide toolbar",
|
"sessionToolbarHide": "Hide toolbar",
|
||||||
"actionUpdateSiteApprovals": "Update Site Approvals",
|
"actionUpdateSiteApprovals": "Update Site Approvals",
|
||||||
"check": "Check"
|
"check": "Check",
|
||||||
|
"rulesErrorInvalidMethod": "Invalid HTTP method",
|
||||||
|
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
|
||||||
|
"rulesSelectMethods": "Select methods"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Intentos de Reintento",
|
"retryAttempts": "Intentos de Reintento",
|
||||||
"expectedResponseCodes": "Códigos de respuesta esperados",
|
"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.",
|
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
|
||||||
"customHeaders": "Cabeceras personalizadas",
|
"customRequestHeaders": "Cabeceras de solicitud personalizadas",
|
||||||
"customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor",
|
"customRequestHeadersDescription": "Cabeceras de solicitud enviadas a los destinos. Una por línea: Nombre-Cabecera: valor",
|
||||||
|
"customResponseHeaders": "Cabeceras de respuesta personalizadas",
|
||||||
|
"customResponseHeadersDescription": "Cabeceras de respuesta devueltas al cliente. Una por línea: Nombre-Cabecera: valor",
|
||||||
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
|
"headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.",
|
||||||
"saveHealthCheck": "Guardar Chequeo de Salud",
|
"saveHealthCheck": "Guardar Chequeo de Salud",
|
||||||
"healthCheckSaved": "Chequeo de Salud Guardado",
|
"healthCheckSaved": "Chequeo de Salud Guardado",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Tentatives de réessai",
|
"retryAttempts": "Tentatives de réessai",
|
||||||
"expectedResponseCodes": "Codes de réponse attendus",
|
"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.",
|
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
|
||||||
"customHeaders": "En-têtes personnalisés",
|
"customRequestHeaders": "En-têtes de requête personnalisés",
|
||||||
"customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur",
|
"customRequestHeadersDescription": "En-têtes de requête envoyés aux cibles. Un par ligne : Nom-En-tête : valeur",
|
||||||
|
"customResponseHeaders": "En-têtes de réponse personnalisés",
|
||||||
|
"customResponseHeadersDescription": "En-têtes de réponse renvoyés au client. Un par ligne : Nom-En-tête : valeur",
|
||||||
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
|
"headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.",
|
||||||
"saveHealthCheck": "Sauvegarder la vérification de l'état de santé",
|
"saveHealthCheck": "Sauvegarder la vérification de l'état de santé",
|
||||||
"healthCheckSaved": "Vérification de l'état de santé enregistrée",
|
"healthCheckSaved": "Vérification de l'état de santé enregistrée",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Tentativi di Riprova",
|
"retryAttempts": "Tentativi di Riprova",
|
||||||
"expectedResponseCodes": "Codici di Risposta Attesi",
|
"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.",
|
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
|
||||||
"customHeaders": "Intestazioni Personalizzate",
|
"customRequestHeaders": "Intestazioni di richiesta personalizzate",
|
||||||
"customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore",
|
"customRequestHeadersDescription": "Intestazioni di richiesta inviate ai target. Una per riga: Nome-Intestazione: valore",
|
||||||
|
"customResponseHeaders": "Intestazioni di risposta personalizzate",
|
||||||
|
"customResponseHeadersDescription": "Intestazioni di risposta restituite al client. Una per riga: Nome-Intestazione: valore",
|
||||||
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
|
"headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.",
|
||||||
"saveHealthCheck": "Salva Controllo Salute",
|
"saveHealthCheck": "Salva Controllo Salute",
|
||||||
"healthCheckSaved": "Controllo Salute Salvato",
|
"healthCheckSaved": "Controllo Salute Salvato",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "재시도 횟수",
|
"retryAttempts": "재시도 횟수",
|
||||||
"expectedResponseCodes": "예상 응답 코드",
|
"expectedResponseCodes": "예상 응답 코드",
|
||||||
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
|
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
|
||||||
"customHeaders": "사용자 정의 헤더",
|
"customRequestHeaders": "사용자 정의 요청 헤더",
|
||||||
"customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value",
|
"customRequestHeadersDescription": "다운스트림 대상으로 전송되는 요청 헤더. 한 줄에 하나씩: 헤더-이름: 값",
|
||||||
|
"customResponseHeaders": "사용자 정의 응답 헤더",
|
||||||
|
"customResponseHeadersDescription": "클라이언트로 반환되는 응답 헤더. 한 줄에 하나씩: 헤더-이름: 값",
|
||||||
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
|
"headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.",
|
||||||
"saveHealthCheck": "상태 확인 저장",
|
"saveHealthCheck": "상태 확인 저장",
|
||||||
"healthCheckSaved": "상태 확인이 저장되었습니다.",
|
"healthCheckSaved": "상태 확인이 저장되었습니다.",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Forsøk på nytt",
|
"retryAttempts": "Forsøk på nytt",
|
||||||
"expectedResponseCodes": "Forventede svarkoder",
|
"expectedResponseCodes": "Forventede svarkoder",
|
||||||
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
|
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
|
||||||
"customHeaders": "Egendefinerte topptekster",
|
"customRequestHeaders": "Egendefinerte forespørselshoder",
|
||||||
"customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi",
|
"customRequestHeadersDescription": "Forespørselshoder sendt til målene. Én per linje: Header-Navn: verdi",
|
||||||
|
"customResponseHeaders": "Egendefinerte svarhoder",
|
||||||
|
"customResponseHeadersDescription": "Svarhoder sendt tilbake til klienten. Én per linje: Header-Navn: verdi",
|
||||||
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
|
"headersValidationError": "Topptekst må være i formatet: header-navn: verdi.",
|
||||||
"saveHealthCheck": "Lagre Helsekontroll",
|
"saveHealthCheck": "Lagre Helsekontroll",
|
||||||
"healthCheckSaved": "Helsekontroll Lagret",
|
"healthCheckSaved": "Helsekontroll Lagret",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Herhaal Pogingen",
|
"retryAttempts": "Herhaal Pogingen",
|
||||||
"expectedResponseCodes": "Verwachte Reactiecodes",
|
"expectedResponseCodes": "Verwachte Reactiecodes",
|
||||||
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
|
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
|
||||||
"customHeaders": "Aangepaste headers",
|
"customRequestHeaders": "Aangepaste verzoekheaders",
|
||||||
"customHeadersDescription": "Kopregeleinde: Header-Naam: waarde",
|
"customRequestHeadersDescription": "Verzoekheaders die worden doorgestuurd naar het doel. Per regel één: Header-Naam: waarde",
|
||||||
|
"customResponseHeaders": "Aangepaste antwoordheaders",
|
||||||
|
"customResponseHeadersDescription": "Antwoordheaders die worden teruggestuurd naar de client. Per regel één: Header-Naam: waarde",
|
||||||
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
|
"headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.",
|
||||||
"saveHealthCheck": "Opslaan Gezondheidscontrole",
|
"saveHealthCheck": "Opslaan Gezondheidscontrole",
|
||||||
"healthCheckSaved": "Gezondheidscontrole Opgeslagen",
|
"healthCheckSaved": "Gezondheidscontrole Opgeslagen",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Próby Ponowienia",
|
"retryAttempts": "Próby Ponowienia",
|
||||||
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
|
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
|
||||||
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
|
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
|
||||||
"customHeaders": "Niestandardowe nagłówki",
|
"customRequestHeaders": "Niestandardowe nagłówki żądania",
|
||||||
"customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość",
|
"customRequestHeadersDescription": "Nagłówki żądania wysyłane do celów. Jeden w wierszu: Nazwa-Nagłówka: wartość",
|
||||||
|
"customResponseHeaders": "Niestandardowe nagłówki odpowiedzi",
|
||||||
|
"customResponseHeadersDescription": "Nagłówki odpowiedzi zwracane do klienta. Jeden w wierszu: Nazwa-Nagłówka: wartość",
|
||||||
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
|
"headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.",
|
||||||
"saveHealthCheck": "Zapisz Kontrolę Zdrowia",
|
"saveHealthCheck": "Zapisz Kontrolę Zdrowia",
|
||||||
"healthCheckSaved": "Kontrola Zdrowia Zapisana",
|
"healthCheckSaved": "Kontrola Zdrowia Zapisana",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Tentativas de Repetição",
|
"retryAttempts": "Tentativas de Repetição",
|
||||||
"expectedResponseCodes": "Códigos de Resposta Esperados",
|
"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.",
|
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
|
||||||
"customHeaders": "Cabeçalhos Personalizados",
|
"customRequestHeaders": "Cabeçalhos de Pedido Personalizados",
|
||||||
"customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor",
|
"customRequestHeadersDescription": "Cabeçalhos de pedido enviados para os destinos. Um por linha: Nome-Cabeçalho: valor",
|
||||||
|
"customResponseHeaders": "Cabeçalhos de Resposta Personalizados",
|
||||||
|
"customResponseHeadersDescription": "Cabeçalhos de resposta enviados de volta ao cliente. Um por linha: Nome-Cabeçalho: valor",
|
||||||
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
|
"headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.",
|
||||||
"saveHealthCheck": "Salvar Verificação de Saúde",
|
"saveHealthCheck": "Salvar Verificação de Saúde",
|
||||||
"healthCheckSaved": "Verificação de Saúde Salva",
|
"healthCheckSaved": "Verificação de Saúde Salva",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Количество попыток повторного запроса",
|
"retryAttempts": "Количество попыток повторного запроса",
|
||||||
"expectedResponseCodes": "Ожидаемые коды ответов",
|
"expectedResponseCodes": "Ожидаемые коды ответов",
|
||||||
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
|
||||||
"customHeaders": "Пользовательские заголовки",
|
"customRequestHeaders": "Пользовательские заголовки запроса",
|
||||||
"customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение",
|
"customRequestHeadersDescription": "Заголовки запроса, отправляемые целевым серверам. По одному в строке: Имя-Заголовка: значение",
|
||||||
|
"customResponseHeaders": "Пользовательские заголовки ответа",
|
||||||
|
"customResponseHeadersDescription": "Заголовки ответа, возвращаемые клиенту. По одному в строке: Имя-Заголовка: значение",
|
||||||
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
|
"headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.",
|
||||||
"saveHealthCheck": "Сохранить проверку здоровья",
|
"saveHealthCheck": "Сохранить проверку здоровья",
|
||||||
"healthCheckSaved": "Проверка здоровья сохранена",
|
"healthCheckSaved": "Проверка здоровья сохранена",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "Tekrar Deneme Girişimleri",
|
"retryAttempts": "Tekrar Deneme Girişimleri",
|
||||||
"expectedResponseCodes": "Beklenen Yanıt Kodları",
|
"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.",
|
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
|
||||||
"customHeaders": "Özel Başlıklar",
|
"customRequestHeaders": "Özel İstek Başlıkları",
|
||||||
"customHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer",
|
"customRequestHeadersDescription": "Hedeflere gönderilen istek başlıkları. Satır başına bir tane: Başlık-Adı: değer",
|
||||||
|
"customResponseHeaders": "Özel Yanıt Başlıkları",
|
||||||
|
"customResponseHeadersDescription": "İstemciye geri gönderilen yanıt başlıkları. Satır başına bir tane: Başlık-Adı: değer",
|
||||||
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
|
"headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.",
|
||||||
"saveHealthCheck": "Sağlık Kontrolünü Kaydet",
|
"saveHealthCheck": "Sağlık Kontrolünü Kaydet",
|
||||||
"healthCheckSaved": "Sağlık Kontrolü Kaydedildi",
|
"healthCheckSaved": "Sağlık Kontrolü Kaydedildi",
|
||||||
|
|||||||
+4
-2
@@ -2826,8 +2826,10 @@
|
|||||||
"retryAttempts": "重试次数",
|
"retryAttempts": "重试次数",
|
||||||
"expectedResponseCodes": "期望响应代码",
|
"expectedResponseCodes": "期望响应代码",
|
||||||
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。",
|
||||||
"customHeaders": "自定义标题",
|
"customRequestHeaders": "自定义请求标头",
|
||||||
"customHeadersDescription": "头部新行分隔:头部名称:值",
|
"customRequestHeadersDescription": "转发到目标的请求标头。每行一个:标头名称:值",
|
||||||
|
"customResponseHeaders": "自定义响应标头",
|
||||||
|
"customResponseHeadersDescription": "返回给客户端的响应标头。每行一个:标头名称:值",
|
||||||
"headersValidationError": "头部必须是格式:头部名称:值。",
|
"headersValidationError": "头部必须是格式:头部名称:值。",
|
||||||
"saveHealthCheck": "保存健康检查",
|
"saveHealthCheck": "保存健康检查",
|
||||||
"healthCheckSaved": "健康检查已保存",
|
"healthCheckSaved": "健康检查已保存",
|
||||||
|
|||||||
+4
-2
@@ -1551,8 +1551,10 @@
|
|||||||
"retryAttempts": "重試次數",
|
"retryAttempts": "重試次數",
|
||||||
"expectedResponseCodes": "期望響應代碼",
|
"expectedResponseCodes": "期望響應代碼",
|
||||||
"expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。",
|
"expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。",
|
||||||
"customHeaders": "自訂 Headers",
|
"customRequestHeaders": "自訂請求 Headers",
|
||||||
"customHeadersDescription": "Header 斷行分隔:Header 名稱:值",
|
"customRequestHeadersDescription": "轉發至目標的請求標頭。每行一個:Header-名稱:值",
|
||||||
|
"customResponseHeaders": "自訂回應 Headers",
|
||||||
|
"customResponseHeadersDescription": "回傳給客戶端的回應標頭。每行一個:Header-名稱:值",
|
||||||
"headersValidationError": "Header 必須是格式:Header 名稱:值。",
|
"headersValidationError": "Header 必須是格式:Header 名稱:值。",
|
||||||
"saveHealthCheck": "保存健康檢查",
|
"saveHealthCheck": "保存健康檢查",
|
||||||
"healthCheckSaved": "健康檢查已保存",
|
"healthCheckSaved": "健康檢查已保存",
|
||||||
|
|||||||
+76
-47
@@ -4,42 +4,18 @@
|
|||||||
"iPad2,2": "iPad 2",
|
"iPad2,2": "iPad 2",
|
||||||
"iPad2,3": "iPad 2",
|
"iPad2,3": "iPad 2",
|
||||||
"iPad2,4": "iPad 2",
|
"iPad2,4": "iPad 2",
|
||||||
"iPad3,1": "iPad 3rd Gen",
|
|
||||||
"iPad3,3": "iPad 3rd Gen",
|
|
||||||
"iPad3,2": "iPad 3rd Gen",
|
|
||||||
"iPad3,4": "iPad 4th Gen",
|
|
||||||
"iPad3,5": "iPad 4th Gen",
|
|
||||||
"iPad3,6": "iPad 4th Gen",
|
|
||||||
"iPad6,11": "iPad 9.7 5th Gen",
|
|
||||||
"iPad6,12": "iPad 9.7 5th Gen",
|
|
||||||
"iPad7,5": "iPad 9.7 6th Gen",
|
|
||||||
"iPad7,6": "iPad 9.7 6th Gen",
|
|
||||||
"iPad7,11": "iPad 10.2 7th Gen",
|
|
||||||
"iPad7,12": "iPad 10.2 7th Gen",
|
|
||||||
"iPad11,6": "iPad 10.2 8th Gen",
|
|
||||||
"iPad11,7": "iPad 10.2 8th Gen",
|
|
||||||
"iPad12,1": "iPad 10.2 9th Gen",
|
|
||||||
"iPad12,2": "iPad 10.2 9th Gen",
|
|
||||||
"iPad13,18": "iPad 10.9 10th Gen",
|
|
||||||
"iPad13,19": "iPad 10.9 10th Gen",
|
|
||||||
"iPad4,1": "iPad Air",
|
|
||||||
"iPad4,2": "iPad Air",
|
|
||||||
"iPad4,3": "iPad Air",
|
|
||||||
"iPad5,3": "iPad Air 2",
|
|
||||||
"iPad5,4": "iPad Air 2",
|
|
||||||
"iPad11,3": "iPad Air 3rd Gen",
|
|
||||||
"iPad11,4": "iPad Air 3rd Gen",
|
|
||||||
"iPad13,1": "iPad Air 4th Gen",
|
|
||||||
"iPad13,2": "iPad Air 4th Gen",
|
|
||||||
"iPad13,16": "iPad Air 5th Gen",
|
|
||||||
"iPad13,17": "iPad Air 5th Gen",
|
|
||||||
"iPad14,8": "iPad Air M2 11",
|
|
||||||
"iPad14,9": "iPad Air M2 11",
|
|
||||||
"iPad14,10": "iPad Air M2 13",
|
|
||||||
"iPad14,11": "iPad Air M2 13",
|
|
||||||
"iPad2,5": "iPad mini",
|
"iPad2,5": "iPad mini",
|
||||||
"iPad2,6": "iPad mini",
|
"iPad2,6": "iPad mini",
|
||||||
"iPad2,7": "iPad mini",
|
"iPad2,7": "iPad mini",
|
||||||
|
"iPad3,1": "iPad 3rd Gen",
|
||||||
|
"iPad3,2": "iPad 3rd Gen",
|
||||||
|
"iPad3,3": "iPad 3rd Gen",
|
||||||
|
"iPad3,4": "iPad 4th Gen",
|
||||||
|
"iPad3,5": "iPad 4th Gen",
|
||||||
|
"iPad3,6": "iPad 4th Gen",
|
||||||
|
"iPad4,1": "iPad Air",
|
||||||
|
"iPad4,2": "iPad Air",
|
||||||
|
"iPad4,3": "iPad Air",
|
||||||
"iPad4,4": "iPad mini 2",
|
"iPad4,4": "iPad mini 2",
|
||||||
"iPad4,5": "iPad mini 2",
|
"iPad4,5": "iPad mini 2",
|
||||||
"iPad4,6": "iPad mini 2",
|
"iPad4,6": "iPad mini 2",
|
||||||
@@ -48,18 +24,22 @@
|
|||||||
"iPad4,9": "iPad mini 3",
|
"iPad4,9": "iPad mini 3",
|
||||||
"iPad5,1": "iPad mini 4",
|
"iPad5,1": "iPad mini 4",
|
||||||
"iPad5,2": "iPad mini 4",
|
"iPad5,2": "iPad mini 4",
|
||||||
"iPad11,1": "iPad mini 5th Gen",
|
"iPad5,3": "iPad Air 2",
|
||||||
"iPad11,2": "iPad mini 5th Gen",
|
"iPad5,4": "iPad Air 2",
|
||||||
"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,3": "iPad Pro 9.7",
|
"iPad6,3": "iPad Pro 9.7",
|
||||||
"iPad6,4": "iPad Pro 9.7",
|
"iPad6,4": "iPad Pro 9.7",
|
||||||
"iPad7,3": "iPad Pro 10.5",
|
"iPad6,7": "iPad Pro 12.9",
|
||||||
"iPad7,4": "iPad Pro 10.5",
|
"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,1": "iPad Pro 12.9",
|
||||||
"iPad7,2": "iPad Pro 12.9",
|
"iPad7,2": "iPad Pro 12.9",
|
||||||
|
"iPad7,3": "iPad Pro 10.5",
|
||||||
|
"iPad7,4": "iPad Pro 10.5",
|
||||||
|
"iPad7,5": "iPad 9.7 6th Gen",
|
||||||
|
"iPad7,6": "iPad 9.7 6th Gen",
|
||||||
|
"iPad7,11": "iPad 10.2 7th Gen",
|
||||||
|
"iPad7,12": "iPad 10.2 7th Gen",
|
||||||
"iPad8,1": "iPad Pro 11",
|
"iPad8,1": "iPad Pro 11",
|
||||||
"iPad8,2": "iPad Pro 11",
|
"iPad8,2": "iPad Pro 11",
|
||||||
"iPad8,3": "iPad Pro 11",
|
"iPad8,3": "iPad Pro 11",
|
||||||
@@ -72,6 +52,16 @@
|
|||||||
"iPad8,10": "iPad Pro 11",
|
"iPad8,10": "iPad Pro 11",
|
||||||
"iPad8,11": "iPad Pro 12.9",
|
"iPad8,11": "iPad Pro 12.9",
|
||||||
"iPad8,12": "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,4": "iPad Pro 11",
|
||||||
"iPad13,5": "iPad Pro 11",
|
"iPad13,5": "iPad Pro 11",
|
||||||
"iPad13,6": "iPad Pro 11",
|
"iPad13,6": "iPad Pro 11",
|
||||||
@@ -80,14 +70,40 @@
|
|||||||
"iPad13,9": "iPad Pro 12.9",
|
"iPad13,9": "iPad Pro 12.9",
|
||||||
"iPad13,10": "iPad Pro 12.9",
|
"iPad13,10": "iPad Pro 12.9",
|
||||||
"iPad13,11": "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,3": "iPad Pro 11",
|
||||||
"iPad14,4": "iPad Pro 11",
|
"iPad14,4": "iPad Pro 11",
|
||||||
"iPad14,5": "iPad Pro 12.9",
|
"iPad14,5": "iPad Pro 12.9",
|
||||||
"iPad14,6": "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,3": "iPad Pro M4 11",
|
||||||
"iPad16,4": "iPad Pro M4 11",
|
"iPad16,4": "iPad Pro M4 11",
|
||||||
"iPad16,5": "iPad Pro M4 13",
|
"iPad16,5": "iPad Pro M4 13",
|
||||||
"iPad16,6": "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,1": "iPhone",
|
||||||
"iPhone1,2": "iPhone 3G",
|
"iPhone1,2": "iPhone 3G",
|
||||||
"iPhone2,1": "iPhone 3GS",
|
"iPhone2,1": "iPhone 3GS",
|
||||||
@@ -101,20 +117,20 @@
|
|||||||
"iPhone5,4": "iPhone 5c",
|
"iPhone5,4": "iPhone 5c",
|
||||||
"iPhone6,1": "iPhone 5s",
|
"iPhone6,1": "iPhone 5s",
|
||||||
"iPhone6,2": "iPhone 5s",
|
"iPhone6,2": "iPhone 5s",
|
||||||
"iPhone7,2": "iPhone 6",
|
|
||||||
"iPhone7,1": "iPhone 6 Plus",
|
"iPhone7,1": "iPhone 6 Plus",
|
||||||
|
"iPhone7,2": "iPhone 6",
|
||||||
"iPhone8,1": "iPhone 6s",
|
"iPhone8,1": "iPhone 6s",
|
||||||
"iPhone8,2": "iPhone 6s Plus",
|
"iPhone8,2": "iPhone 6s Plus",
|
||||||
"iPhone8,4": "iPhone SE",
|
"iPhone8,4": "iPhone SE",
|
||||||
"iPhone9,1": "iPhone 7",
|
"iPhone9,1": "iPhone 7",
|
||||||
"iPhone9,3": "iPhone 7",
|
|
||||||
"iPhone9,2": "iPhone 7 Plus",
|
"iPhone9,2": "iPhone 7 Plus",
|
||||||
|
"iPhone9,3": "iPhone 7",
|
||||||
"iPhone9,4": "iPhone 7 Plus",
|
"iPhone9,4": "iPhone 7 Plus",
|
||||||
"iPhone10,1": "iPhone 8",
|
"iPhone10,1": "iPhone 8",
|
||||||
"iPhone10,4": "iPhone 8",
|
|
||||||
"iPhone10,2": "iPhone 8 Plus",
|
"iPhone10,2": "iPhone 8 Plus",
|
||||||
"iPhone10,5": "iPhone 8 Plus",
|
|
||||||
"iPhone10,3": "iPhone X",
|
"iPhone10,3": "iPhone X",
|
||||||
|
"iPhone10,4": "iPhone 8",
|
||||||
|
"iPhone10,5": "iPhone 8 Plus",
|
||||||
"iPhone10,6": "iPhone X",
|
"iPhone10,6": "iPhone X",
|
||||||
"iPhone11,2": "iPhone Xs",
|
"iPhone11,2": "iPhone Xs",
|
||||||
"iPhone11,6": "iPhone Xs Max",
|
"iPhone11,6": "iPhone Xs Max",
|
||||||
@@ -127,10 +143,10 @@
|
|||||||
"iPhone13,2": "iPhone 12",
|
"iPhone13,2": "iPhone 12",
|
||||||
"iPhone13,3": "iPhone 12 Pro",
|
"iPhone13,3": "iPhone 12 Pro",
|
||||||
"iPhone13,4": "iPhone 12 Pro Max",
|
"iPhone13,4": "iPhone 12 Pro Max",
|
||||||
"iPhone14,4": "iPhone 13 mini",
|
|
||||||
"iPhone14,5": "iPhone 13",
|
|
||||||
"iPhone14,2": "iPhone 13 Pro",
|
"iPhone14,2": "iPhone 13 Pro",
|
||||||
"iPhone14,3": "iPhone 13 Pro Max",
|
"iPhone14,3": "iPhone 13 Pro Max",
|
||||||
|
"iPhone14,4": "iPhone 13 mini",
|
||||||
|
"iPhone14,5": "iPhone 13",
|
||||||
"iPhone14,6": "iPhone SE",
|
"iPhone14,6": "iPhone SE",
|
||||||
"iPhone14,7": "iPhone 14",
|
"iPhone14,7": "iPhone 14",
|
||||||
"iPhone14,8": "iPhone 14 Plus",
|
"iPhone14,8": "iPhone 14 Plus",
|
||||||
@@ -140,6 +156,19 @@
|
|||||||
"iPhone15,5": "iPhone 15 Plus",
|
"iPhone15,5": "iPhone 15 Plus",
|
||||||
"iPhone16,1": "iPhone 15 Pro",
|
"iPhone16,1": "iPhone 15 Pro",
|
||||||
"iPhone16,2": "iPhone 15 Pro Max",
|
"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",
|
"iPod1,1": "iPod touch Original",
|
||||||
"iPod2,1": "iPod touch 2nd",
|
"iPod2,1": "iPod touch 2nd",
|
||||||
"iPod3,1": "iPod touch 3rd Gen",
|
"iPod3,1": "iPod touch 3rd Gen",
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ export const resources = pgTable(
|
|||||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
requestHeaders: text("requestHeaders"),
|
||||||
|
responseHeaders: text("responseHeaders"),
|
||||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||||
@@ -1137,6 +1138,7 @@ export const resourceRules = pgTable("resourceRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(), // CIDR, PATH, IP
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
@@ -1161,6 +1163,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ export const resources = sqliteTable(
|
|||||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
requestHeaders: text("requestHeaders"),
|
||||||
|
responseHeaders: text("responseHeaders"),
|
||||||
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
@@ -1409,6 +1410,7 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(), // CIDR, PATH, IP
|
.notNull(), // CIDR, PATH, IP
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
@@ -1465,6 +1467,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
| "COUNTRY_IS_NOT"
|
| "COUNTRY_IS_NOT"
|
||||||
| "ASN"
|
| "ASN"
|
||||||
| "REGION"
|
| "REGION"
|
||||||
|
| "METHOD"
|
||||||
>()
|
>()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{ label: "Time", value: new Date().toUTCString() },
|
{ label: "Time (UTC)", value: new Date().toUTCString() },
|
||||||
...dataItems
|
...dataItems
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => {
|
|||||||
</EmailText>
|
</EmailText>
|
||||||
{isTestAlert && (
|
{isTestAlert && (
|
||||||
<EmailText>
|
<EmailText>
|
||||||
This is a test alert. No action is required,
|
This is a test alert. No action is required, and
|
||||||
and no real event has occurred.
|
no real event has occurred.
|
||||||
</EmailText>
|
</EmailText>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from "./features";
|
|||||||
export * from "./limitsService";
|
export * from "./limitsService";
|
||||||
export * from "./getOrgTierData";
|
export * from "./getOrgTierData";
|
||||||
export * from "./createCustomer";
|
export * from "./createCustomer";
|
||||||
|
export * from "./linkEmailOrg";
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export async function linkEmailOrg(
|
||||||
|
orgId: string,
|
||||||
|
email: string | null | undefined
|
||||||
|
): Promise<void> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
|||||||
import { pickPort } from "@server/routers/target/helpers";
|
import { pickPort } from "@server/routers/target/helpers";
|
||||||
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
|
import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern,
|
||||||
|
parseHttpMethodList
|
||||||
|
} from "../validators";
|
||||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
||||||
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
|
||||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||||
@@ -255,10 +261,18 @@ export async function updatePublicResources(
|
|||||||
resourceData.ssl == undefined || resourceData.ssl == null
|
resourceData.ssl == undefined || resourceData.ssl == null
|
||||||
? true
|
? true
|
||||||
: resourceData.ssl;
|
: resourceData.ssl;
|
||||||
let headers = "";
|
// `headers` is a deprecated alias for `requestHeaders`
|
||||||
if (resourceData.headers) {
|
const mergedRequestHeaders = [
|
||||||
headers = JSON.stringify(resourceData.headers);
|
...(resourceData.headers ?? []),
|
||||||
}
|
...(resourceData.requestHeaders ?? [])
|
||||||
|
];
|
||||||
|
const requestHeaders =
|
||||||
|
mergedRequestHeaders.length > 0
|
||||||
|
? JSON.stringify(mergedRequestHeaders)
|
||||||
|
: null;
|
||||||
|
const responseHeaders = resourceData.responseHeaders?.length
|
||||||
|
? JSON.stringify(resourceData.responseHeaders)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (resourceData.policy) {
|
if (resourceData.policy) {
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -397,7 +411,8 @@ export async function updatePublicResources(
|
|||||||
? resourceData.auth["whitelist-users"].length >
|
? resourceData.auth["whitelist-users"].length >
|
||||||
0
|
0
|
||||||
: false,
|
: false,
|
||||||
headers: headers || null,
|
requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
applyRules:
|
applyRules:
|
||||||
resourceData.rules &&
|
resourceData.rules &&
|
||||||
resourceData.rules.length > 0,
|
resourceData.rules.length > 0,
|
||||||
@@ -593,7 +608,8 @@ export async function updatePublicResources(
|
|||||||
setHostHeader: resourceData["host-header"] || null,
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
tlsServerName:
|
tlsServerName:
|
||||||
resourceData["tls-server-name"] || null,
|
resourceData["tls-server-name"] || null,
|
||||||
headers: headers || null,
|
requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
maintenanceModeEnabled:
|
maintenanceModeEnabled:
|
||||||
resourceData.maintenance?.enabled,
|
resourceData.maintenance?.enabled,
|
||||||
maintenanceModeType: resourceData.maintenance?.type,
|
maintenanceModeType: resourceData.maintenance?.type,
|
||||||
@@ -1216,7 +1232,8 @@ export async function updatePublicResources(
|
|||||||
setHostHeader: resourceData["host-header"] || null,
|
setHostHeader: resourceData["host-header"] || null,
|
||||||
tlsServerName: resourceData["tls-server-name"] || null,
|
tlsServerName: resourceData["tls-server-name"] || null,
|
||||||
ssl: resourceSsl,
|
ssl: resourceSsl,
|
||||||
headers: headers || null,
|
requestHeaders,
|
||||||
|
responseHeaders,
|
||||||
applyRules:
|
applyRules:
|
||||||
resourceData.rules && resourceData.rules.length > 0,
|
resourceData.rules && resourceData.rules.length > 0,
|
||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
@@ -1453,6 +1470,10 @@ function getRuleValue(match: string, value: string) {
|
|||||||
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
||||||
return value.toUpperCase();
|
return value.toUpperCase();
|
||||||
}
|
}
|
||||||
|
// normalize the method list so it is stored as "POST,PUT"
|
||||||
|
if (match === "METHOD") {
|
||||||
|
return parseHttpMethodList(value).join(",");
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1473,6 +1494,10 @@ function validateRule(rule: any) {
|
|||||||
if (!isValidRegionId(rule.value)) {
|
if (!isValidRegionId(rule.value)) {
|
||||||
throw new Error(`Invalid region ID provided: ${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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import logger from "@server/logger";
|
|||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
import { hashPassword } from "@server/auth/password";
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import {
|
||||||
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
|
isValidIP,
|
||||||
|
isValidUrlGlobPattern,
|
||||||
|
ResourceRuleMatchType
|
||||||
|
} from "../validators";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
import { findOrgUsersByIdentifier } from "./findOrgUser";
|
||||||
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
|
|||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
rule.match === "method" &&
|
||||||
|
!isValidHttpMethodList(rule.value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
|||||||
return "PASS";
|
return "PASS";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuleMatch(
|
function getRuleMatch(input: string): ResourceRuleMatchType {
|
||||||
input: string
|
return input.toUpperCase() as ResourceRuleMatchType;
|
||||||
): "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(
|
async function syncRolePolicies(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|||||||
import { portRangeStringSchema } from "@server/lib/ip";
|
import { portRangeStringSchema } from "@server/lib/ip";
|
||||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
import { isValidHttpMethodList } from "@server/lib/validators";
|
||||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
|
|||||||
export const RuleSchema = z
|
export const RuleSchema = z
|
||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
|
match: z.enum([
|
||||||
|
"cidr",
|
||||||
|
"path",
|
||||||
|
"ip",
|
||||||
|
"country",
|
||||||
|
"country_is_not",
|
||||||
|
"asn",
|
||||||
|
"region",
|
||||||
|
"method"
|
||||||
|
]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional(),
|
||||||
enabled: z.boolean().optional().default(true)
|
enabled: z.boolean().optional().default(true)
|
||||||
@@ -207,6 +217,19 @@ export const RuleSchema = z
|
|||||||
message:
|
message:
|
||||||
"Value must be a valid UN M.49 region or subregion ID when match is 'region'"
|
"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({
|
export const HeaderSchema = z.object({
|
||||||
@@ -303,7 +326,9 @@ export const PublicResourceSchema = z
|
|||||||
auth: AuthSchema.optional(),
|
auth: AuthSchema.optional(),
|
||||||
"host-header": z.string().optional(),
|
"host-header": z.string().optional(),
|
||||||
"tls-server-name": z.string().optional(),
|
"tls-server-name": z.string().optional(),
|
||||||
headers: z.array(HeaderSchema).optional(),
|
headers: z.array(HeaderSchema).optional(), // deprecated alias for requestHeaders
|
||||||
|
requestHeaders: z.array(HeaderSchema).optional(),
|
||||||
|
responseHeaders: z.array(HeaderSchema).optional(),
|
||||||
rules: z.array(RuleSchema).optional(),
|
rules: z.array(RuleSchema).optional(),
|
||||||
maintenance: MaintenanceSchema.optional(),
|
maintenance: MaintenanceSchema.optional(),
|
||||||
"auth-daemon": AuthDaemonSchema.optional(),
|
"auth-daemon": AuthDaemonSchema.optional(),
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export async function getTraefikConfig(
|
|||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader,
|
setHostHeader: resources.setHostHeader,
|
||||||
enableProxy: resources.enableProxy,
|
enableProxy: resources.enableProxy,
|
||||||
headers: resources.headers,
|
requestHeaders: resources.requestHeaders,
|
||||||
|
responseHeaders: resources.responseHeaders,
|
||||||
proxyProtocol: resources.proxyProtocol,
|
proxyProtocol: resources.proxyProtocol,
|
||||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||||
wildcard: resources.wildcard,
|
wildcard: resources.wildcard,
|
||||||
@@ -225,7 +226,8 @@ export async function getTraefikConfig(
|
|||||||
setHostHeader: row.setHostHeader,
|
setHostHeader: row.setHostHeader,
|
||||||
enableProxy: row.enableProxy,
|
enableProxy: row.enableProxy,
|
||||||
targets: [],
|
targets: [],
|
||||||
headers: row.headers,
|
requestHeaders: row.requestHeaders,
|
||||||
|
responseHeaders: row.responseHeaders,
|
||||||
proxyProtocol: row.proxyProtocol,
|
proxyProtocol: row.proxyProtocol,
|
||||||
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
|
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
|
||||||
path: row.path, // the targets will all have the same path
|
path: row.path, // the targets will all have the same path
|
||||||
@@ -405,7 +407,8 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// Handle custom headers middleware
|
// Handle custom headers middleware
|
||||||
const customHeadersMiddleware = buildCustomHeadersMiddleware(
|
const customHeadersMiddleware = buildCustomHeadersMiddleware(
|
||||||
resource.headers,
|
resource.requestHeaders,
|
||||||
|
resource.responseHeaders,
|
||||||
resource.setHostHeader,
|
resource.setHostHeader,
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,46 +1,78 @@
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
/**
|
function parseHeaders(
|
||||||
* Build the customRequestHeaders middleware definition for a resource's
|
headers: string,
|
||||||
* custom headers + setHostHeader config. Returns null when there are no
|
label: string,
|
||||||
* headers to set, so the caller can skip attaching the middleware.
|
|
||||||
*/
|
|
||||||
export function buildCustomHeadersMiddleware(
|
|
||||||
headers: string | null | undefined,
|
|
||||||
setHostHeader: string | null | undefined,
|
|
||||||
resourceId: number
|
resourceId: number
|
||||||
): { headers: { customRequestHeaders: { [key: string]: string } } } | null {
|
): { name: string; value: string }[] {
|
||||||
const headersObj: { [key: string]: string } = {};
|
|
||||||
|
|
||||||
if (headers) {
|
|
||||||
let headersArr: { name: string; value: string }[] = [];
|
|
||||||
try {
|
try {
|
||||||
headersArr = JSON.parse(headers) as {
|
return JSON.parse(headers) as {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Failed to parse headers for resource ${resourceId}: ${e}`
|
`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.
|
||||||
|
*/
|
||||||
|
export function buildCustomHeadersMiddleware(
|
||||||
|
requestHeaders: string | null | undefined,
|
||||||
|
responseHeaders: 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 } = {};
|
||||||
|
|
||||||
|
if (requestHeaders) {
|
||||||
|
parseHeaders(requestHeaders, "requestHeaders", resourceId).forEach(
|
||||||
|
(header) => {
|
||||||
|
requestHeadersObj[header.name] = header.value;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
headersArr.forEach((header) => {
|
|
||||||
headersObj[header.name] = header.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setHostHeader) {
|
if (setHostHeader) {
|
||||||
headersObj["Host"] = setHostHeader;
|
requestHeadersObj["Host"] = setHostHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(headersObj).length === 0) {
|
if (responseHeaders) {
|
||||||
|
parseHeaders(responseHeaders, "responseHeaders", resourceId).forEach(
|
||||||
|
(header) => {
|
||||||
|
responseHeadersObj[header.name] = header.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRequestHeaders = Object.keys(requestHeadersObj).length > 0;
|
||||||
|
const hasResponseHeaders = Object.keys(responseHeadersObj).length > 0;
|
||||||
|
|
||||||
|
if (!hasRequestHeaders && !hasResponseHeaders) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
customRequestHeaders: headersObj
|
...(hasRequestHeaders && {
|
||||||
|
customRequestHeaders: requestHeadersObj
|
||||||
|
}),
|
||||||
|
...(hasResponseHeaders && {
|
||||||
|
customResponseHeaders: responseHeadersObj
|
||||||
|
})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
getResourceRuleValueValidationError,
|
getResourceRuleValueValidationError,
|
||||||
isValidDomain,
|
isValidDomain,
|
||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern,
|
||||||
|
parseHttpMethodList
|
||||||
} from "./validators";
|
} from "./validators";
|
||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals, assertEqualsObj } from "@test/assert";
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
console.log("Running domain validation tests...");
|
console.log("Running domain validation tests...");
|
||||||
@@ -295,6 +296,44 @@ function runTests() {
|
|||||||
"Invalid ASN should return an error"
|
"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!");
|
console.log("All tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
|
|||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
"COUNTRY_IS_NOT",
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION",
|
||||||
|
"METHOD"
|
||||||
] as const;
|
] 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 type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
||||||
|
|
||||||
export function getResourceRuleValueValidationError(
|
export function getResourceRuleValueValidationError(
|
||||||
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
|
|||||||
return COUNTRIES.some((country) => country.code === value)
|
return COUNTRIES.some((country) => country.code === value)
|
||||||
? null
|
? null
|
||||||
: "Invalid country code provided";
|
: "Invalid country code provided";
|
||||||
|
case "METHOD":
|
||||||
|
return isValidHttpMethodList(value)
|
||||||
|
? null
|
||||||
|
: "Invalid HTTP method provided";
|
||||||
case "ASN":
|
case "ASN":
|
||||||
const normalizedValue = value.trim().toUpperCase();
|
const normalizedValue = value.trim().toUpperCase();
|
||||||
return /^AS\d+$/.test(normalizedValue) ||
|
return /^AS\d+$/.test(normalizedValue) ||
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
export * from "./getOrgTierData";
|
export * from "./getOrgTierData";
|
||||||
export * from "./createCustomer";
|
export * from "./createCustomer";
|
||||||
|
export * from "./linkEmailOrg";
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
export async function linkEmailOrg(
|
||||||
|
orgId: string,
|
||||||
|
email: string | null | undefined
|
||||||
|
): Promise<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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,7 +133,8 @@ export async function getTraefikConfig(
|
|||||||
tlsServerName: resources.tlsServerName,
|
tlsServerName: resources.tlsServerName,
|
||||||
setHostHeader: resources.setHostHeader,
|
setHostHeader: resources.setHostHeader,
|
||||||
enableProxy: resources.enableProxy,
|
enableProxy: resources.enableProxy,
|
||||||
headers: resources.headers,
|
requestHeaders: resources.requestHeaders,
|
||||||
|
responseHeaders: resources.responseHeaders,
|
||||||
proxyProtocol: resources.proxyProtocol,
|
proxyProtocol: resources.proxyProtocol,
|
||||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||||
wildcard: resources.wildcard,
|
wildcard: resources.wildcard,
|
||||||
@@ -275,7 +276,8 @@ export async function getTraefikConfig(
|
|||||||
setHostHeader: row.setHostHeader,
|
setHostHeader: row.setHostHeader,
|
||||||
enableProxy: row.enableProxy,
|
enableProxy: row.enableProxy,
|
||||||
targets: [],
|
targets: [],
|
||||||
headers: row.headers,
|
requestHeaders: row.requestHeaders,
|
||||||
|
responseHeaders: row.responseHeaders,
|
||||||
proxyProtocol: row.proxyProtocol,
|
proxyProtocol: row.proxyProtocol,
|
||||||
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
|
proxyProtocolVersion: row.proxyProtocolVersion ?? 1,
|
||||||
path: row.path, // the targets will all have the same path
|
path: row.path, // the targets will all have the same path
|
||||||
@@ -712,7 +714,8 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const customHeadersMiddleware = buildCustomHeadersMiddleware(
|
const customHeadersMiddleware = buildCustomHeadersMiddleware(
|
||||||
resource.headers,
|
resource.requestHeaders,
|
||||||
|
resource.responseHeaders,
|
||||||
resource.setHostHeader,
|
resource.setHostHeader,
|
||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,27 @@ type ValidateLicenseAPIResponse = {
|
|||||||
status: number;
|
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 = {
|
type TokenPayload = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
type: LicenseKeyType;
|
type: LicenseKeyType;
|
||||||
@@ -276,6 +297,11 @@ LQIDAQAB
|
|||||||
if (!apiResponse?.success) {
|
if (!apiResponse?.success) {
|
||||||
throw new Error(apiResponse?.error);
|
throw new Error(apiResponse?.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`License server response: ${JSON.stringify(apiResponse)}`
|
||||||
|
);
|
||||||
|
|
||||||
// Reset failure count on success
|
// Reset failure count on success
|
||||||
this.phoneHomeFailureCount = 0;
|
this.phoneHomeFailureCount = 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -338,6 +364,11 @@ LQIDAQAB
|
|||||||
licenseKeyRes,
|
licenseKeyRes,
|
||||||
this.publicKey
|
this.publicKey
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Decoded license key ${key.licenseKey}: ${JSON.stringify(payload)}`
|
||||||
|
);
|
||||||
|
|
||||||
cached.valid = payload.valid;
|
cached.valid = payload.valid;
|
||||||
cached.type = payload.type;
|
cached.type = payload.type;
|
||||||
cached.tier = payload.tier;
|
cached.tier = payload.tier;
|
||||||
@@ -370,13 +401,52 @@ 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
|
// 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) {
|
for (const key of keys) {
|
||||||
const cached = newCache.get(key.licenseKey)!;
|
const cached = newCache.get(key.licenseKey)!;
|
||||||
|
|
||||||
if (cached.type === "host") {
|
if (cached.type === "host") {
|
||||||
status.isLicenseValid = cached.valid;
|
if (
|
||||||
status.tier = cached.tier;
|
!selectedHostKey ||
|
||||||
|
(cached.valid && !selectedHostKey.valid) ||
|
||||||
|
(cached.valid === selectedHostKey.valid &&
|
||||||
|
tierRank(cached.tier) >
|
||||||
|
tierRank(selectedHostKey.tier))
|
||||||
|
) {
|
||||||
|
selectedHostKey = cached;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cached.valid) {
|
if (!cached.valid) {
|
||||||
@@ -393,6 +463,11 @@ LQIDAQAB
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedHostKey) {
|
||||||
|
status.isLicenseValid = selectedHostKey.valid;
|
||||||
|
status.tier = selectedHostKey.tier;
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate license if over user or site limits
|
// Invalidate license if over user or site limits
|
||||||
if (
|
if (
|
||||||
(status.maxSites !== undefined &&
|
(status.maxSites !== undefined &&
|
||||||
@@ -415,6 +490,8 @@ LQIDAQAB
|
|||||||
this.checkInProgress = false;
|
this.checkInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.debug(`Computed license status: ${JSON.stringify(status)}`);
|
||||||
|
|
||||||
this.statusCache.set(this.statusKey, status, 0);
|
this.statusCache.set(this.statusKey, status, 0);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||||
import { isPathAllowed } from "@server/lib/pathMatch";
|
import { isPathAllowed } from "@server/lib/pathMatch";
|
||||||
|
import { parseHttpMethodList } from "@server/lib/validators";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -163,6 +164,7 @@ export async function verifyResourceSession(
|
|||||||
path,
|
path,
|
||||||
headers,
|
headers,
|
||||||
query,
|
query,
|
||||||
|
method,
|
||||||
badgerVersion
|
badgerVersion
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
@@ -293,7 +295,8 @@ export async function verifyResourceSession(
|
|||||||
clientIp,
|
clientIp,
|
||||||
path,
|
path,
|
||||||
ipCC,
|
ipCC,
|
||||||
ipAsn
|
ipAsn,
|
||||||
|
method
|
||||||
);
|
);
|
||||||
|
|
||||||
if (action == "ACCEPT") {
|
if (action == "ACCEPT") {
|
||||||
@@ -1429,7 +1432,8 @@ async function checkRules(
|
|||||||
clientIp: string | undefined,
|
clientIp: string | undefined,
|
||||||
path: string | undefined,
|
path: string | undefined,
|
||||||
ipCC?: string,
|
ipCC?: string,
|
||||||
ipAsn?: number
|
ipAsn?: number,
|
||||||
|
method?: string
|
||||||
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||||
const ruleCacheKey = `rules:${resourceId}`;
|
const ruleCacheKey = `rules:${resourceId}`;
|
||||||
|
|
||||||
@@ -1504,12 +1508,24 @@ async function checkRules(
|
|||||||
(await isIpInRegion(ipCC, rule.value))
|
(await isIpInRegion(ipCC, rule.value))
|
||||||
) {
|
) {
|
||||||
return rule.action as any;
|
return rule.action as any;
|
||||||
|
} else if (
|
||||||
|
method &&
|
||||||
|
rule.match == "METHOD" &&
|
||||||
|
isMethodAllowed(rule.value, method)
|
||||||
|
) {
|
||||||
|
return rule.action as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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 };
|
export { isPathAllowed };
|
||||||
|
|
||||||
async function isIpInGeoIP(
|
async function isIpInGeoIP(
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
|
|
||||||
|
// Error codes for registration failures
|
||||||
|
export const NewtErrorCodes = {
|
||||||
|
NO_AVAILABLE_SUBNET: {
|
||||||
|
code: "NO_AVAILABLE_SUBNET",
|
||||||
|
message:
|
||||||
|
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Helper function to send registration error
|
||||||
|
export async function sendNewtError(
|
||||||
|
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
||||||
|
newtId: string
|
||||||
|
) {
|
||||||
|
sendToClient(newtId, {
|
||||||
|
type: "newt/error",
|
||||||
|
data: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
|||||||
import { fetchContainers } from "./dockerSocket";
|
import { fetchContainers } from "./dockerSocket";
|
||||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
|
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||||
|
|
||||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
logger.error(
|
logger.error(
|
||||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||||
);
|
);
|
||||||
|
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
|
|||||||
HOLEPUNCH_MISSING: {
|
HOLEPUNCH_MISSING: {
|
||||||
code: "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.`
|
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;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||||
{ orgId: client.orgId, clientId: client.clientId }
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
);
|
);
|
||||||
|
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { defaultRoleAllowedActions } from "../role";
|
import { defaultRoleAllowedActions } from "../role";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { isValidCIDR } from "@server/lib/validators";
|
import { isValidCIDR } from "@server/lib/validators";
|
||||||
import { createCustomer } from "#dynamic/lib/billing";
|
import { createCustomer, linkEmailOrg } from "#dynamic/lib/billing";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
|
import { LimitId, limitsService, freeLimitSet } from "@server/lib/billing";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -425,6 +425,7 @@ export async function createOrg(
|
|||||||
customerId
|
customerId
|
||||||
); // Only 1 because we are creating the org
|
); // Only 1 because we are creating the org
|
||||||
}
|
}
|
||||||
|
await linkEmailOrg(orgId, req.user?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (numOrgs) {
|
if (numOrgs) {
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ async function queryInlinePolicy(resourcePolicyId: number) {
|
|||||||
|
|
||||||
export type GetResourceResponse = Omit<
|
export type GetResourceResponse = Omit<
|
||||||
NonNullable<Awaited<ReturnType<typeof query>>>,
|
NonNullable<Awaited<ReturnType<typeof query>>>,
|
||||||
"headers"
|
"requestHeaders" | "responseHeaders"
|
||||||
> & {
|
> & {
|
||||||
headers: { name: string; value: string }[] | null;
|
requestHeaders: { name: string; value: string }[] | null;
|
||||||
|
responseHeaders: { name: string; value: string }[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
@@ -186,9 +187,12 @@ export async function getResource(
|
|||||||
return response<GetResourceResponse>(res, {
|
return response<GetResourceResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
...returnData,
|
...returnData,
|
||||||
headers: returnData.headers
|
requestHeaders: returnData.requestHeaders
|
||||||
? JSON.parse(returnData.headers)
|
? JSON.parse(returnData.requestHeaders)
|
||||||
: returnData.headers
|
: returnData.requestHeaders,
|
||||||
|
responseHeaders: returnData.responseHeaders
|
||||||
|
? JSON.parse(returnData.responseHeaders)
|
||||||
|
: returnData.responseHeaders
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -100,6 +100,14 @@ const updateHttpResourceBodySchema = z
|
|||||||
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
|
"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
|
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() }))
|
.array(z.strictObject({ name: z.string(), value: z.string() }))
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -163,12 +171,13 @@ const updateHttpResourceBodySchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.headers) {
|
|
||||||
// HTTP header names must be valid token characters (RFC 7230)
|
|
||||||
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||||
return data.headers.every((h) => validHeaderName.test(h.name));
|
const allHeaders = [
|
||||||
}
|
...(data.headers ?? []),
|
||||||
return true;
|
...(data.requestHeaders ?? []),
|
||||||
|
...(data.responseHeaders ?? [])
|
||||||
|
];
|
||||||
|
return allHeaders.every((h) => validHeaderName.test(h.name));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
|
error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)."
|
||||||
@@ -176,14 +185,13 @@ const updateHttpResourceBodySchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.headers) {
|
|
||||||
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
|
|
||||||
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||||
return data.headers.every((h) =>
|
const allHeaders = [
|
||||||
validHeaderValue.test(h.value)
|
...(data.headers ?? []),
|
||||||
);
|
...(data.requestHeaders ?? []),
|
||||||
}
|
...(data.responseHeaders ?? [])
|
||||||
return true;
|
];
|
||||||
|
return allHeaders.every((h) => validHeaderValue.test(h.value));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
|
error: "Header values may only contain printable ASCII characters and horizontal whitespace."
|
||||||
@@ -191,16 +199,17 @@ const updateHttpResourceBodySchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.headers) {
|
|
||||||
// Reject Traefik template syntax {{word}} in names or values
|
|
||||||
const templatePattern = /\{\{[^}]+\}\}/;
|
const templatePattern = /\{\{[^}]+\}\}/;
|
||||||
return data.headers.every(
|
const allHeaders = [
|
||||||
|
...(data.headers ?? []),
|
||||||
|
...(data.requestHeaders ?? []),
|
||||||
|
...(data.responseHeaders ?? [])
|
||||||
|
];
|
||||||
|
return allHeaders.every(
|
||||||
(h) =>
|
(h) =>
|
||||||
!templatePattern.test(h.name) &&
|
!templatePattern.test(h.name) &&
|
||||||
!templatePattern.test(h.value)
|
!templatePattern.test(h.value)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
error: "Header names and values must not contain template expressions such as {{value}}."
|
error: "Header names and values must not contain template expressions such as {{value}}."
|
||||||
@@ -696,13 +705,32 @@ async function updateHttpResource(
|
|||||||
await createCertificate(domainId, fullDomain, db);
|
await createCertificate(domainId, fullDomain, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
let headers = undefined;
|
let requestHeaders = undefined;
|
||||||
if (updateData.headers) {
|
const mergedRequestHeaders = [
|
||||||
headers = JSON.stringify(updateData.headers);
|
...(updateData.headers ?? []),
|
||||||
} else if (updateData.headers === null) {
|
...(updateData.requestHeaders ?? [])
|
||||||
headers = null;
|
];
|
||||||
|
if (
|
||||||
|
updateData.headers !== undefined ||
|
||||||
|
updateData.requestHeaders !== undefined
|
||||||
|
) {
|
||||||
|
requestHeaders =
|
||||||
|
mergedRequestHeaders.length > 0
|
||||||
|
? JSON.stringify(mergedRequestHeaders)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let responseHeaders = undefined;
|
||||||
|
if (updateData.responseHeaders) {
|
||||||
|
responseHeaders = JSON.stringify(updateData.responseHeaders);
|
||||||
|
} else if (updateData.responseHeaders === null) {
|
||||||
|
responseHeaders = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData.headers = undefined;
|
||||||
|
updateData.requestHeaders = undefined;
|
||||||
|
updateData.responseHeaders = undefined;
|
||||||
|
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
updateData.maintenanceModeEnabled = undefined;
|
updateData.maintenanceModeEnabled = undefined;
|
||||||
updateData.maintenanceModeType = undefined;
|
updateData.maintenanceModeType = undefined;
|
||||||
@@ -755,7 +783,7 @@ async function updateHttpResource(
|
|||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ ...resourceOnlyData, headers })
|
.set({ ...resourceOnlyData, requestHeaders, responseHeaders })
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -779,7 +807,7 @@ async function updateHttpResource(
|
|||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ ...updateData, headers })
|
.set({ ...updateData, requestHeaders, responseHeaders })
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ function ProxyResourceHttpForm({
|
|||||||
message: t("proxyErrorInvalidHeader")
|
message: t("proxyErrorInvalidHeader")
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
headers: z
|
requestHeaders: z
|
||||||
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
|
.nullable(),
|
||||||
|
responseHeaders: z
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
.nullable()
|
.nullable()
|
||||||
});
|
});
|
||||||
@@ -131,19 +134,21 @@ function ProxyResourceHttpForm({
|
|||||||
ssl: resource.ssl,
|
ssl: resource.ssl,
|
||||||
tlsServerName: resource.tlsServerName || "",
|
tlsServerName: resource.tlsServerName || "",
|
||||||
setHostHeader: resource.setHostHeader || "",
|
setHostHeader: resource.setHostHeader || "",
|
||||||
headers: resource.headers
|
requestHeaders: resource.requestHeaders,
|
||||||
|
responseHeaders: resource.responseHeaders
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||||
const [headersValid, setHeadersValid] = useState(true);
|
const [requestHeadersValid, setRequestHeadersValid] = useState(true);
|
||||||
|
const [responseHeadersValid, setResponseHeadersValid] = useState(true);
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
|
||||||
if (!headersValid) {
|
if (!requestHeadersValid || !responseHeadersValid) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("settingsErrorUpdate"),
|
title: t("settingsErrorUpdate"),
|
||||||
@@ -162,7 +167,8 @@ function ProxyResourceHttpForm({
|
|||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || null,
|
tlsServerName: data.tlsServerName || null,
|
||||||
setHostHeader: data.setHostHeader || null,
|
setHostHeader: data.setHostHeader || null,
|
||||||
headers: data.headers || null
|
requestHeaders: data.requestHeaders || null,
|
||||||
|
responseHeaders: data.responseHeaders || null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -183,7 +189,8 @@ function ProxyResourceHttpForm({
|
|||||||
ssl: data.ssl,
|
ssl: data.ssl,
|
||||||
tlsServerName: data.tlsServerName || null,
|
tlsServerName: data.tlsServerName || null,
|
||||||
setHostHeader: data.setHostHeader || null,
|
setHostHeader: data.setHostHeader || null,
|
||||||
headers: data.headers || null
|
requestHeaders: data.requestHeaders || null,
|
||||||
|
responseHeaders: data.responseHeaders || null
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -324,11 +331,11 @@ function ProxyResourceHttpForm({
|
|||||||
<SettingsFormCell span="full">
|
<SettingsFormCell span="full">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="headers"
|
name="requestHeaders"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("customHeaders")}
|
{t("customRequestHeaders")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<HeadersInput
|
<HeadersInput
|
||||||
@@ -337,14 +344,46 @@ function ProxyResourceHttpForm({
|
|||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
onValidityChange={
|
onValidityChange={
|
||||||
setHeadersValid
|
setRequestHeadersValid
|
||||||
}
|
}
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{t(
|
||||||
"customHeadersDescription"
|
"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"
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -362,7 +401,11 @@ function ProxyResourceHttpForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading || !headersValid}
|
disabled={
|
||||||
|
saveLoading ||
|
||||||
|
!requestHeadersValid ||
|
||||||
|
!responseHeadersValid
|
||||||
|
}
|
||||||
form="http-settings-form"
|
form="http-settings-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("saveSettings")}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function ProxyResourceProtocolForm({
|
|||||||
message: t("proxyErrorInvalidHeader")
|
message: t("proxyErrorInvalidHeader")
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
headers: z
|
requestHeaders: z
|
||||||
.array(z.object({ name: z.string(), value: z.string() }))
|
.array(z.object({ name: z.string(), value: z.string() }))
|
||||||
.nullable(),
|
.nullable(),
|
||||||
proxyProtocol: z.boolean().optional(),
|
proxyProtocol: z.boolean().optional(),
|
||||||
@@ -124,7 +124,7 @@ function ProxyResourceProtocolForm({
|
|||||||
resolver: zodResolver(proxySettingsSchema),
|
resolver: zodResolver(proxySettingsSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
setHostHeader: resource.setHostHeader || "",
|
setHostHeader: resource.setHostHeader || "",
|
||||||
headers: resource.headers,
|
requestHeaders: resource.requestHeaders,
|
||||||
proxyProtocol: resource.proxyProtocol || false,
|
proxyProtocol: resource.proxyProtocol || false,
|
||||||
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
proxyProtocolVersion: resource.proxyProtocolVersion || 1
|
||||||
}
|
}
|
||||||
|
|||||||
+166
-26
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { LicenseKeyCache } from "@server/license/license";
|
import { LicenseKeyCache, LicenseKeyTier } from "@server/license/license";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionFooter
|
SettingsSectionFooter
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
|
import { Progress } from "@app/components/ui/progress";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Heart,
|
Heart,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
ShoppingCart,
|
||||||
TicketCheck
|
TicketCheck
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -63,12 +65,24 @@ import { useTranslations } from "next-intl";
|
|||||||
const ENTERPRISE_DOCS_URL =
|
const ENTERPRISE_DOCS_URL =
|
||||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||||
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
|
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 obfuscateLicenseKey(key: string): string {
|
function getTierLabel(
|
||||||
if (key.length <= 8) return key;
|
tier: LicenseKeyTier | undefined,
|
||||||
const firstPart = key.substring(0, 4);
|
t: (key: string) => string
|
||||||
const lastPart = key.substring(key.length - 4);
|
): string {
|
||||||
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
switch (tier) {
|
||||||
|
case "enterprise":
|
||||||
|
return t("licenseTierEnterprise");
|
||||||
|
case "tier1":
|
||||||
|
return t("licenseTierTier1");
|
||||||
|
case "tier2":
|
||||||
|
return t("licenseTierTier2");
|
||||||
|
case "personal":
|
||||||
|
default:
|
||||||
|
return t("licenseTierPersonal");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LicensePage() {
|
export default function LicensePage() {
|
||||||
@@ -80,7 +94,6 @@ export default function LicensePage() {
|
|||||||
useState<LicenseKeyCache | null>(null);
|
useState<LicenseKeyCache | null>(null);
|
||||||
|
|
||||||
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
||||||
const [hostLicense, setHostLicense] = useState<string | null>(null);
|
|
||||||
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
|
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
|
||||||
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
|
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
|
||||||
|
|
||||||
@@ -128,12 +141,6 @@ export default function LicensePage() {
|
|||||||
);
|
);
|
||||||
const keys = response.data.data;
|
const keys = response.data.data;
|
||||||
setRows(keys);
|
setRows(keys);
|
||||||
const hostKey = keys.find((key) => key.type === "host");
|
|
||||||
if (hostKey) {
|
|
||||||
setHostLicense(hostKey.licenseKey);
|
|
||||||
} else {
|
|
||||||
setHostLicense(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: t("licenseErrorKeyLoad"),
|
title: t("licenseErrorKeyLoad"),
|
||||||
@@ -388,6 +395,33 @@ export default function LicensePage() {
|
|||||||
</DismissableBanner>
|
</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"> */}
|
{/* <Alert variant="neutral" className="mb-6"> */}
|
||||||
{/* <InfoIcon className="h-4 w-4" /> */}
|
{/* <InfoIcon className="h-4 w-4" /> */}
|
||||||
{/* <AlertTitle className="font-semibold"> */}
|
{/* <AlertTitle className="font-semibold"> */}
|
||||||
@@ -399,6 +433,111 @@ export default function LicensePage() {
|
|||||||
{/* </Alert> */}
|
{/* </Alert> */}
|
||||||
|
|
||||||
<SettingsContainer>
|
<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")}
|
||||||
|
</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>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SSTitle>{t("licenseHost")}</SSTitle>
|
<SSTitle>{t("licenseHost")}</SSTitle>
|
||||||
@@ -422,6 +561,19 @@ export default function LicensePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{licenseStatus?.hostId && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
@@ -430,19 +582,6 @@ export default function LicensePage() {
|
|||||||
<CopyTextBox text={licenseStatus.hostId} />
|
<CopyTextBox text={licenseStatus.hostId} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hostLicense && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t("licenseKey")}
|
|
||||||
</div>
|
|
||||||
<CopyTextBox
|
|
||||||
text={hostLicense}
|
|
||||||
displayText={obfuscateLicenseKey(
|
|
||||||
hostLicense
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
@@ -455,6 +594,7 @@ export default function LicensePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
</SettingsSectionGrid>
|
||||||
<LicenseKeysDataTable
|
<LicenseKeysDataTable
|
||||||
licenseKeys={rows}
|
licenseKeys={rows}
|
||||||
onDelete={(key) => {
|
onDelete={(key) => {
|
||||||
|
|||||||
@@ -122,11 +122,20 @@ export default async function ResourceAuthPage(props: {
|
|||||||
|
|
||||||
if (searchParams.redirect) {
|
if (searchParams.redirect) {
|
||||||
try {
|
try {
|
||||||
|
const redirectTarget = new URL(searchParams.redirect);
|
||||||
const serverResourceHost = new URL(authInfo.url).host;
|
const serverResourceHost = new URL(authInfo.url).host;
|
||||||
const redirectHost = new URL(searchParams.redirect).host;
|
const redirectHost = redirectTarget.host;
|
||||||
const redirectPort = new URL(searchParams.redirect).port;
|
const redirectPort = redirectTarget.port;
|
||||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
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 = (
|
const wildcardMatchesRedirect = (
|
||||||
wildcardDomain: string,
|
wildcardDomain: string,
|
||||||
host: string
|
host: string
|
||||||
@@ -136,14 +145,16 @@ export default async function ResourceAuthPage(props: {
|
|||||||
return host.endsWith(suffix) && host.length > suffix.length;
|
return host.endsWith(suffix) && host.length > suffix.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (serverResourceHost === redirectHost) {
|
if (
|
||||||
redirectUrl = searchParams.redirect;
|
isHttpTarget &&
|
||||||
} else if (serverResourceHostWithPort === redirectHost) {
|
(serverResourceHost === redirectHost ||
|
||||||
redirectUrl = searchParams.redirect;
|
serverResourceHostWithPort === redirectHost ||
|
||||||
} else if (
|
(authInfo.wildcard &&
|
||||||
authInfo.wildcard &&
|
|
||||||
authInfo.fullDomain &&
|
authInfo.fullDomain &&
|
||||||
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
|
wildcardMatchesRedirect(
|
||||||
|
authInfo.fullDomain,
|
||||||
|
redirectHost
|
||||||
|
)))
|
||||||
) {
|
) {
|
||||||
redirectUrl = searchParams.redirect;
|
redirectUrl = searchParams.redirect;
|
||||||
}
|
}
|
||||||
@@ -283,7 +294,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
variant: idp.type
|
variant: idp.variant ?? idp.type
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1355,7 +1355,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t(
|
{t(
|
||||||
"customHeaders"
|
"customRequestHeaders"
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -1379,7 +1379,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{t(
|
||||||
"customHeadersDescription"
|
"customRequestHeadersDescription"
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -733,7 +733,7 @@ export function HealthCheckFormFields({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("customHeaders")}
|
{t("customRequestHeaders")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<HeadersInput
|
<HeadersInput
|
||||||
@@ -750,7 +750,7 @@ export function HealthCheckFormFields({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{t(
|
||||||
"customHeadersDescription"
|
"customRequestHeadersDescription"
|
||||||
)}
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@@ -112,6 +112,54 @@ 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",
|
accessorKey: "terminateAt",
|
||||||
friendlyName: t("licenseTableValidUntil"),
|
friendlyName: t("licenseTableValidUntil"),
|
||||||
@@ -158,6 +206,7 @@ export function LicenseKeysDataTable({
|
|||||||
title={t("licenseKeys")}
|
title={t("licenseKeys")}
|
||||||
searchPlaceholder={t("licenseKeySearch")}
|
searchPlaceholder={t("licenseKeySearch")}
|
||||||
searchColumn="licenseKey"
|
searchColumn="licenseKey"
|
||||||
|
defaultSort={{ id: "terminateAt", desc: false }}
|
||||||
onAdd={onCreate}
|
onAdd={onCreate}
|
||||||
addButtonText={t("licenseKeyAdd")}
|
addButtonText={t("licenseKeyAdd")}
|
||||||
enableColumnVisibility={true}
|
enableColumnVisibility={true}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
FaWindows
|
FaWindows
|
||||||
} from "react-icons/fa";
|
} from "react-icons/fa";
|
||||||
import { Download, ExternalLink } from "lucide-react";
|
import { Download, ExternalLink } from "lucide-react";
|
||||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
import { SiAlpinelinux, SiKubernetes, SiNixos } from "react-icons/si";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
|
||||||
export type CommandItem =
|
export type CommandItem =
|
||||||
@@ -30,6 +30,7 @@ export type CommandItem =
|
|||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
"linux",
|
"linux",
|
||||||
|
"alpine",
|
||||||
"macos",
|
"macos",
|
||||||
"docker",
|
"docker",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
@@ -65,7 +66,8 @@ export function NewtSiteInstallCommands({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const showSiteConfiguration = platform !== "advantech";
|
const showSiteConfiguration = platform !== "advantech";
|
||||||
const supportsSshOption = platform === "linux" || platform === "nixos";
|
const supportsSshOption =
|
||||||
|
platform === "linux" || platform === "nixos" || platform === "alpine";
|
||||||
|
|
||||||
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
|
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
|
||||||
const acceptClientsEnv = !acceptClients
|
const acceptClientsEnv = !acceptClients
|
||||||
@@ -168,6 +170,76 @@ sudo systemctl enable --now pangolin-site`
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
alpine: {
|
||||||
|
Run: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Manual OpenRC Service": [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("envFile"),
|
||||||
|
command: `sudo tee /etc/conf.d/pangolin-site > /dev/null << 'EOF'
|
||||||
|
export SITE_ID=${id}
|
||||||
|
export SITE_SECRET=${secret}
|
||||||
|
export PANGOLIN_ENDPOINT=${endpoint}${
|
||||||
|
!acceptClients
|
||||||
|
? `
|
||||||
|
export DISABLE_CLIENTS=true`
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
!allowPangolinSsh
|
||||||
|
? `
|
||||||
|
export DISABLE_SSH=true`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/conf.d/pangolin-site`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("serviceFile"),
|
||||||
|
command: `sudo tee /etc/init.d/pangolin-site > /dev/null << 'EOF'
|
||||||
|
#!/sbin/openrc-run
|
||||||
|
|
||||||
|
name="pangolin-site"
|
||||||
|
description="Pangolin Site"
|
||||||
|
|
||||||
|
command="/usr/local/bin/pangolin"
|
||||||
|
command_args="up site"
|
||||||
|
command_background="yes"
|
||||||
|
supervisor="supervise-daemon"
|
||||||
|
|
||||||
|
pidfile="/run/pangolin-site.pid"
|
||||||
|
output_log="/var/log/pangolin-site.log"
|
||||||
|
error_log="/var/log/pangolin-site.err"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
after firewall
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
sudo chmod +x /etc/init.d/pangolin-site`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("enableAndStart"),
|
||||||
|
command: `sudo rc-update add pangolin-site default
|
||||||
|
sudo rc-service pangolin-site start`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("check"),
|
||||||
|
command: `sudo rc-service pangolin-site status`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
macos: {
|
macos: {
|
||||||
Run: [
|
Run: [
|
||||||
{
|
{
|
||||||
@@ -478,6 +550,8 @@ function getPlatformIcon(platformName: Platform) {
|
|||||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||||
case "linux":
|
case "linux":
|
||||||
return <FaLinux className="h-4 w-4 mr-2" />;
|
return <FaLinux className="h-4 w-4 mr-2" />;
|
||||||
|
case "alpine":
|
||||||
|
return <SiAlpinelinux className="h-4 w-4 mr-2" />;
|
||||||
case "macos":
|
case "macos":
|
||||||
return <FaApple className="h-4 w-4 mr-2" />;
|
return <FaApple className="h-4 w-4 mr-2" />;
|
||||||
case "docker":
|
case "docker":
|
||||||
@@ -501,6 +575,8 @@ function getPlatformName(platformName: Platform) {
|
|||||||
return "Windows";
|
return "Windows";
|
||||||
case "linux":
|
case "linux":
|
||||||
return "Linux";
|
return "Linux";
|
||||||
|
case "alpine":
|
||||||
|
return "Alpine Linux";
|
||||||
case "macos":
|
case "macos":
|
||||||
return "macOS";
|
return "macOS";
|
||||||
case "docker":
|
case "docker":
|
||||||
@@ -522,6 +598,8 @@ function getArchitectures(platform: Platform) {
|
|||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "linux":
|
case "linux":
|
||||||
return ["Run", "Systemd Service", "Manual Systemd Service"];
|
return ["Run", "Systemd Service", "Manual Systemd Service"];
|
||||||
|
case "alpine":
|
||||||
|
return ["Run", "Manual OpenRC Service"];
|
||||||
case "macos":
|
case "macos":
|
||||||
return ["Run", "Service"];
|
return ["Run", "Service"];
|
||||||
case "windows":
|
case "windows":
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { MAJOR_ASNS } from "@server/db/asns";
|
import { MAJOR_ASNS } from "@server/db/asns";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
import { REGIONS, getRegionNameById } from "@server/db/regions";
|
||||||
|
import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -63,7 +64,8 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
validatePolicyRulePriority,
|
validatePolicyRulePriority,
|
||||||
validatePolicyRuleValue
|
validatePolicyRuleValue,
|
||||||
|
type PolicyRuleMatchType
|
||||||
} from "./policy-access-rule-validation";
|
} from "./policy-access-rule-validation";
|
||||||
import {
|
import {
|
||||||
buildDisplayPrioritiesForResourceOverlay,
|
buildDisplayPrioritiesForResourceOverlay,
|
||||||
@@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) {
|
|||||||
return "";
|
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({
|
export function PolicyAccessRulesTable({
|
||||||
rules,
|
rules,
|
||||||
onRulesChange,
|
onRulesChange,
|
||||||
@@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({
|
|||||||
COUNTRY: t("country"),
|
COUNTRY: t("country"),
|
||||||
COUNTRY_IS_NOT: t("countryIsNot"),
|
COUNTRY_IS_NOT: t("countryIsNot"),
|
||||||
ASN: "ASN",
|
ASN: "ASN",
|
||||||
REGION: t("region")
|
REGION: t("region"),
|
||||||
|
METHOD: t("method")
|
||||||
}),
|
}),
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
@@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({
|
|||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.match}
|
defaultValue={row.original.match}
|
||||||
disabled={readonly || isRuleLocked(row.original)}
|
disabled={readonly || isRuleLocked(row.original)}
|
||||||
onValueChange={(
|
onValueChange={(value: PolicyRuleMatchType) =>
|
||||||
value:
|
|
||||||
| "CIDR"
|
|
||||||
| "IP"
|
|
||||||
| "PATH"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
) =>
|
|
||||||
updateRule(row.original.ruleId, {
|
updateRule(row.original.ruleId, {
|
||||||
match: value,
|
match: value,
|
||||||
value:
|
value:
|
||||||
@@ -458,6 +526,8 @@ export function PolicyAccessRulesTable({
|
|||||||
? "AS15169"
|
? "AS15169"
|
||||||
: value === "REGION"
|
: value === "REGION"
|
||||||
? "021"
|
? "021"
|
||||||
|
: value === "METHOD"
|
||||||
|
? "GET"
|
||||||
: row.original.value
|
: row.original.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -473,6 +543,9 @@ export function PolicyAccessRulesTable({
|
|||||||
<SelectItem value="CIDR">
|
<SelectItem value="CIDR">
|
||||||
{RuleMatch.CIDR}
|
{RuleMatch.CIDR}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="METHOD">
|
||||||
|
{RuleMatch.METHOD}
|
||||||
|
</SelectItem>
|
||||||
{isMaxmindAvailable && (
|
{isMaxmindAvailable && (
|
||||||
<>
|
<>
|
||||||
<SelectItem value="COUNTRY">
|
<SelectItem value="COUNTRY">
|
||||||
@@ -779,6 +852,15 @@ export function PolicyAccessRulesTable({
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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
|
<Input
|
||||||
defaultValue={row.original.value}
|
defaultValue={row.original.value}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { COUNTRIES } from "@server/db/countries";
|
|||||||
import { isValidRegionId } from "@server/db/regions";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
isValidCIDR,
|
||||||
|
isValidHttpMethodList,
|
||||||
isValidIP,
|
isValidIP,
|
||||||
isValidUrlGlobPattern
|
isValidUrlGlobPattern
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
@@ -19,7 +20,8 @@ export const POLICY_RULE_MATCH_TYPES = [
|
|||||||
"COUNTRY",
|
"COUNTRY",
|
||||||
"COUNTRY_IS_NOT",
|
"COUNTRY_IS_NOT",
|
||||||
"ASN",
|
"ASN",
|
||||||
"REGION"
|
"REGION",
|
||||||
|
"METHOD"
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
|
||||||
@@ -84,6 +86,10 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
|
|||||||
(value) => COUNTRIES.some((country) => country.code === value),
|
(value) => COUNTRIES.some((country) => country.code === value),
|
||||||
{ message: t("rulesErrorInvalidCountryDescription") }
|
{ message: t("rulesErrorInvalidCountryDescription") }
|
||||||
);
|
);
|
||||||
|
case "METHOD":
|
||||||
|
return required.refine(isValidHttpMethodList, {
|
||||||
|
message: t("rulesErrorInvalidMethodDescription")
|
||||||
|
});
|
||||||
case "ASN":
|
case "ASN":
|
||||||
return required.refine(
|
return required.refine(
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user