mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-22 02:28:26 +02:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 750f267451 | |||
| 0c752c9c29 | |||
| 3392120855 | |||
| 8fcffe2202 | |||
| af1cda6278 | |||
| ae34796072 | |||
| ee08bc8927 | |||
| cf22bd1a13 | |||
| 085e85f652 | |||
| c7222b67b9 | |||
| bdb4b0c16d | |||
| e5edab546b | |||
| 7e188ffae6 | |||
| 74d10acad9 | |||
| f5018073ef | |||
| 887c6e4e8b | |||
| f71b9e7c54 | |||
| 5dd4c54ad0 | |||
| 6f3e0cf5a6 | |||
| d4488ec125 | |||
| 178af0fd7f | |||
| b8572524a4 | |||
| f691abb2b3 | |||
| 610c228d56 | |||
| b054f90149 | |||
| c290239894 | |||
| 8e2f9ea5ef | |||
| 032eeb2656 | |||
| b74ded3a9c | |||
| 5ca08d71f0 | |||
| 803fbb2ea2 | |||
| 1f453dc04f | |||
| a7d4745f93 | |||
| 64ae230d23 | |||
| 6ad09adefa | |||
| 46538eb606 | |||
| 64aa7292e6 | |||
| 2a6bd21029 | |||
| 5cd98e2790 | |||
| 4bf5efdab5 | |||
| e9e8457d06 | |||
| 8ec9fb3cb9 | |||
| c11fc86b63 | |||
| 3564674bed | |||
| 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
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import yargs from "yargs";
|
import yargs from "yargs";
|
||||||
import { hideBin } from "yargs/helpers";
|
import { hideBin } from "yargs/helpers";
|
||||||
import { setAdminCredentials } from "@cli/commands/setAdminCredentials";
|
import { setAdminCredentials } from "./commands/setAdminCredentials";
|
||||||
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys";
|
import { resetUserSecurityKeys } from "./commands/resetUserSecurityKeys";
|
||||||
import { clearExitNodes } from "./commands/clearExitNodes";
|
import { clearExitNodes } from "./commands/clearExitNodes";
|
||||||
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
import { rotateServerSecret } from "./commands/rotateServerSecret";
|
||||||
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:ee-latest
|
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:ee-latest
|
image: docker.io/fosrl/pangolin:ee-postgresql-latest
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+10
-5
@@ -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",
|
||||||
@@ -834,7 +834,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 +2829,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 +4402,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",
|
||||||
|
|||||||
+7
-5
@@ -459,13 +459,13 @@
|
|||||||
"searchApiKeys": "API sleutels zoeken...",
|
"searchApiKeys": "API sleutels zoeken...",
|
||||||
"apiKeysAdd": "API sleutel genereren",
|
"apiKeysAdd": "API sleutel genereren",
|
||||||
"apiKeysErrorDelete": "Fout bij verwijderen API sleutel",
|
"apiKeysErrorDelete": "Fout bij verwijderen API sleutel",
|
||||||
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API sleutel",
|
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API- leutel",
|
||||||
"apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?",
|
"apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?",
|
||||||
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de APIsleutel niet meer worden gebruikt.",
|
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de APIsleutel niet meer worden gebruikt.",
|
||||||
"apiKeysDeleteConfirm": "Bevestig Verwijderen API sleutel",
|
"apiKeysDeleteConfirm": "Bevestig verwijderen API sleutel",
|
||||||
"apiKeysDelete": "API sleutel verwijderen",
|
"apiKeysDelete": "API sleutel verwijderen",
|
||||||
"apiKeysManage": "API sleutels beheren",
|
"apiKeysManage": "API sleutels beheren",
|
||||||
"apiKeysDescription": "API sleutels worden gebruikt om toegang te verifiëren met de integratie API ",
|
"apiKeysDescription": "API sleutels worden gebruikt om te verifiëren met de integratie-API",
|
||||||
"orgsManage": "Organisaties Beheren",
|
"orgsManage": "Organisaties Beheren",
|
||||||
"orgsDescription": "Bekijk en beheer alle organisaties op dit systeem",
|
"orgsDescription": "Bekijk en beheer alle organisaties op dit systeem",
|
||||||
"provisioningKeysTitle": "Vertrekkende sleutel",
|
"provisioningKeysTitle": "Vertrekkende sleutel",
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatBackupTimestamp } from "./backupFileName";
|
import { formatBackupFileName, formatBackupTimestamp } from "./backupFileName";
|
||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
// Local-time constructors are used throughout, matching formatBackupTimestamp,
|
// Local-time constructors are used throughout, matching formatBackupTimestamp,
|
||||||
@@ -29,7 +29,9 @@ function testMonthIsOneIndexed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const result = formatBackupTimestamp(new Date(2026, 11, 31, 23, 59, 59));
|
const result = formatBackupTimestamp(
|
||||||
|
new Date(2026, 11, 31, 23, 59, 59)
|
||||||
|
);
|
||||||
assertEquals(
|
assertEquals(
|
||||||
result,
|
result,
|
||||||
"2026-12-31_23-59-59",
|
"2026-12-31_23-59-59",
|
||||||
@@ -73,9 +75,7 @@ function testNamesSortChronologically() {
|
|||||||
new Date(2026, 11, 31, 23, 59, 59)
|
new Date(2026, 11, 31, 23, 59, 59)
|
||||||
];
|
];
|
||||||
|
|
||||||
const sorted = taken
|
const sorted = taken.map((date) => formatBackupTimestamp(date)).sort();
|
||||||
.map((date) => formatBackupTimestamp(date))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
sorted.join(","),
|
sorted.join(","),
|
||||||
@@ -89,11 +89,48 @@ function testNamesSortChronologically() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testFormatBackupFileName() {
|
||||||
|
console.log("Running backup file name formatting tests...");
|
||||||
|
|
||||||
|
const date = new Date(2026, 8, 12, 20, 35, 56);
|
||||||
|
|
||||||
|
// With semver version string without leading 'v'
|
||||||
|
assertEquals(
|
||||||
|
formatBackupFileName("1.22.0", date),
|
||||||
|
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
|
||||||
|
"Filename must include timestamp and prefixed version tag"
|
||||||
|
);
|
||||||
|
|
||||||
|
// With version string already containing 'v'
|
||||||
|
assertEquals(
|
||||||
|
formatBackupFileName("v1.22.0", date),
|
||||||
|
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
|
||||||
|
"Filename must not duplicate 'v' prefix if already present"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without version (fallback/default)
|
||||||
|
assertEquals(
|
||||||
|
formatBackupFileName(undefined, date),
|
||||||
|
"db_2026-09-12_20-35-56.sqlite",
|
||||||
|
"Filename without version must match default timestamped format"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Distinct versions within the exact same second do not collide
|
||||||
|
const sameSecondFile1 = formatBackupFileName("1.21.0", date);
|
||||||
|
const sameSecondFile2 = formatBackupFileName("1.22.0", date);
|
||||||
|
if (sameSecondFile1 === sameSecondFile2) {
|
||||||
|
throw new Error(
|
||||||
|
"Backup file names for different versions in the same second must not collide"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run all tests
|
// Run all tests
|
||||||
try {
|
try {
|
||||||
testMonthIsOneIndexed();
|
testMonthIsOneIndexed();
|
||||||
testEveryFieldIsZeroPadded();
|
testEveryFieldIsZeroPadded();
|
||||||
testNamesSortChronologically();
|
testNamesSortChronologically();
|
||||||
|
testFormatBackupFileName();
|
||||||
console.log("All tests passed successfully!");
|
console.log("All tests passed successfully!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Test failed:", error);
|
console.error("Test failed:", error);
|
||||||
|
|||||||
@@ -26,3 +26,26 @@ export function formatBackupTimestamp(date: Date = new Date()): string {
|
|||||||
|
|
||||||
return `${datePart}_${timePart}`;
|
return `${datePart}_${timePart}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full database backup file name, including timestamp and optional version tag.
|
||||||
|
*
|
||||||
|
* When a migration version is provided, the filename includes `_v<version>`,
|
||||||
|
* preventing collisions between multiple migrations running in the same second and making it easy
|
||||||
|
* to identify the migration state contained in the backup.
|
||||||
|
*
|
||||||
|
* @param version Optional migration version being run.
|
||||||
|
* @param date The moment the backup is being taken. Defaults to now.
|
||||||
|
* @returns A filename of the form `db_YYYY-MM-DD_HH-MM-SS_v<version>.sqlite` or `db_YYYY-MM-DD_HH-MM-SS.sqlite`.
|
||||||
|
*/
|
||||||
|
export function formatBackupFileName(
|
||||||
|
version?: string,
|
||||||
|
date: Date = new Date()
|
||||||
|
): string {
|
||||||
|
const timestamp = formatBackupTimestamp(date);
|
||||||
|
if (version) {
|
||||||
|
const versionTag = version.startsWith("v") ? version : `v${version}`;
|
||||||
|
return `db_${timestamp}_${versionTag}.sqlite`;
|
||||||
|
}
|
||||||
|
return `db_${timestamp}.sqlite`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ function getSegmentRegex(patternPart: string): RegExp {
|
|||||||
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
|
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
|
||||||
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment
|
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment
|
||||||
// or a wildcard-swallowed sequence under `/public/*`.
|
// or a wildcard-swallowed sequence under `/public/*`.
|
||||||
|
//
|
||||||
|
// Applied to both the request path and the rule pattern: the pattern
|
||||||
|
// validator only accepts spaces / non-ASCII in percent-encoded form, so a
|
||||||
|
// rule like `/my%20docs/*` must be compared against the decoded segment
|
||||||
|
// `my docs`, not the literal text `my%20docs`.
|
||||||
function decodeAndResolvePath(p: string): string[] {
|
function decodeAndResolvePath(p: string): string[] {
|
||||||
const rawParts = p.split("/").filter(Boolean);
|
const rawParts = p.split("/").filter(Boolean);
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@ function decodeAndResolvePath(p: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||||
const patternParts = pattern.split("/").filter(Boolean);
|
const patternParts = decodeAndResolvePath(pattern);
|
||||||
const pathParts = decodeAndResolvePath(path);
|
const pathParts = decodeAndResolvePath(path);
|
||||||
|
|
||||||
function matchSegments(
|
function matchSegments(
|
||||||
|
|||||||
@@ -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) ||
|
||||||
|
|||||||
@@ -70,13 +70,8 @@ export async function verifyApiKeyAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKeyOrg.orgId) {
|
if (!apiKey.apiKeyOrg?.orgId) {
|
||||||
return next(
|
return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID`));
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
`API key with ID ${apiKeyId} does not have an organization ID`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
@@ -86,7 +81,7 @@ export async function verifyApiKeyAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
eq(userOrgs.orgId, apiKey.apiKeyOrg.orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export async function exportConnectionAuditLogs(
|
|||||||
|
|
||||||
const baseQuery = queryConnection(data);
|
const baseQuery = queryConnection(data);
|
||||||
|
|
||||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
|
||||||
|
|
||||||
const csvData = generateCSV(log);
|
const csvData = generateCSV(log);
|
||||||
|
|
||||||
|
|||||||
@@ -386,6 +386,38 @@ function runSpecialCharacterTests() {
|
|||||||
console.log("All special character tests passed!");
|
console.log("All special character tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runEncodedPatternTests() {
|
||||||
|
console.log("\nRunning percent-encoded pattern tests...");
|
||||||
|
|
||||||
|
// isValidUrlGlobPattern accepts percent-encoded sequences and rejects
|
||||||
|
// raw spaces / non-ASCII, so `%20` and `%C3%A9` are the only way to write
|
||||||
|
// a PATH rule for such a path. Badger sends the request path already
|
||||||
|
// decoded (Go's req.URL.Path), and isPathAllowed decodes it again, so the
|
||||||
|
// rule pattern must be decoded the same way or it can never match.
|
||||||
|
assertEquals(
|
||||||
|
isPathAllowed("/my%20docs/*", "/my docs/report.pdf"),
|
||||||
|
true,
|
||||||
|
"Percent-encoded space in pattern should match decoded request path"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isPathAllowed("/my%20docs/*", "/my%20docs/report.pdf"),
|
||||||
|
true,
|
||||||
|
"Percent-encoded space in pattern should match raw-encoded request path"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isPathAllowed("/caf%C3%A9", "/café"),
|
||||||
|
true,
|
||||||
|
"Percent-encoded UTF-8 in pattern should match decoded request path"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
isPathAllowed("/my%20docs/*", "/my-docs/report.pdf"),
|
||||||
|
false,
|
||||||
|
"Decoded pattern must still reject a different path"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("All percent-encoded pattern tests passed!");
|
||||||
|
}
|
||||||
|
|
||||||
function runRegionTests() {
|
function runRegionTests() {
|
||||||
console.log("\nRunning isIpInRegion tests...");
|
console.log("\nRunning isIpInRegion tests...");
|
||||||
|
|
||||||
@@ -446,6 +478,7 @@ function runRegionTests() {
|
|||||||
try {
|
try {
|
||||||
runTests();
|
runTests();
|
||||||
runSpecialCharacterTests();
|
runSpecialCharacterTests();
|
||||||
|
runEncodedPatternTests();
|
||||||
runRegionTests();
|
runRegionTests();
|
||||||
console.log("\n✅ All tests passed!");
|
console.log("\n✅ All tests passed!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function addPeer(
|
|||||||
.where(eq(newts.siteId, siteId))
|
.where(eq(newts.siteId, siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!newt) {
|
if (!newt) {
|
||||||
throw new Error(`Site found for site ${siteId}`);
|
throw new Error(`Newt not found for site ${siteId}`);
|
||||||
}
|
}
|
||||||
newtId = newt.newtId;
|
newtId = newt.newtId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}."
|
||||||
@@ -500,7 +509,8 @@ async function updateHttpResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// catch when the resource policy changes or gets cleared
|
// catch when the resource policy changes or gets cleared
|
||||||
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
|
if (updateData.resourcePolicyId !== undefined &&
|
||||||
|
resource.resourcePolicyId !== updateData.resourcePolicyId) {
|
||||||
await clearResourceSpecificSettings(
|
await clearResourceSpecificSettings(
|
||||||
resource.resourceId,
|
resource.resourceId,
|
||||||
resource.orgId,
|
resource.orgId,
|
||||||
@@ -695,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;
|
||||||
@@ -754,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();
|
||||||
|
|
||||||
@@ -778,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();
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export async function createSite(
|
|||||||
const { value: newClientAddress, release } =
|
const { value: newClientAddress, release } =
|
||||||
await getNextAvailableClientSubnet(orgId);
|
await getNextAvailableClientSubnet(orgId);
|
||||||
releaseSubnetLock = release;
|
releaseSubnetLock = release;
|
||||||
updatedAddress = newClientAddress.split("/")[0];
|
updatedAddress = `${newClientAddress.split("/")[0]}/${org.subnet ? org.subnet.split("/")[1] : "32"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newSite: Site | undefined;
|
let newSite: Site | undefined;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function updateSite(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sites.niceId, updateData.niceId),
|
eq(sites.niceId, updateData.niceId),
|
||||||
eq(sites.orgId, sites.orgId),
|
eq(sites.orgId, existingSite.orgId),
|
||||||
ne(sites.siteId, siteId)
|
ne(sites.siteId, siteId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
import { getSiteResourceParamsSchema } from "./getSiteResource";
|
||||||
|
|
||||||
|
function testSiteResourceIdOnlyParams() {
|
||||||
|
const result = getSiteResourceParamsSchema.safeParse({
|
||||||
|
siteResourceId: "42"
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
result.success,
|
||||||
|
true,
|
||||||
|
"siteResourceId-only integration routes should pass validation"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
assertEquals(
|
||||||
|
result.data.siteResourceId,
|
||||||
|
42,
|
||||||
|
"siteResourceId should be parsed as a number"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
result.data.orgId,
|
||||||
|
undefined,
|
||||||
|
"orgId should remain optional"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testOrgScopedParamsRemainSupported() {
|
||||||
|
const result = getSiteResourceParamsSchema.safeParse({
|
||||||
|
siteResourceId: "42",
|
||||||
|
orgId: "org-id"
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
result.success,
|
||||||
|
true,
|
||||||
|
"org-scoped routes should continue to pass validation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testInvalidSiteResourceId() {
|
||||||
|
const result = getSiteResourceParamsSchema.safeParse({
|
||||||
|
siteResourceId: "not-a-number"
|
||||||
|
});
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
result.success,
|
||||||
|
false,
|
||||||
|
"non-numeric siteResourceIds should fail validation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testSiteResourceIdOnlyParams();
|
||||||
|
testOrgScopedParamsRemainSupported();
|
||||||
|
testInvalidSiteResourceId();
|
||||||
|
|
||||||
|
console.log("All getSiteResource parameter validation tests passed.");
|
||||||
@@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const getSiteResourceParamsSchema = z.strictObject({
|
export const getSiteResourceParamsSchema = z.strictObject({
|
||||||
siteResourceId: z
|
siteResourceId: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -22,15 +22,17 @@ const getSiteResourceParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
|
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
|
||||||
if (siteResourceId && orgId) {
|
if (siteResourceId) {
|
||||||
const [siteResource] = await db
|
const [siteResource] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.where(
|
.where(
|
||||||
and(
|
orgId
|
||||||
|
? and(
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
eq(siteResources.siteResourceId, siteResourceId),
|
||||||
eq(siteResources.orgId, orgId)
|
eq(siteResources.orgId, orgId)
|
||||||
)
|
)
|
||||||
|
: eq(siteResources.siteResourceId, siteResourceId)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return siteResource;
|
return siteResource;
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { execFileSync } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const repoRoot = path.resolve(here, "..", "..");
|
||||||
|
const migrationsScript = path.join(here, "migrationsSqlite.ts");
|
||||||
|
|
||||||
|
const SEED_STATEMENTS = [
|
||||||
|
`CREATE TABLE versionMigrations (version TEXT PRIMARY KEY, executedAt INTEGER NOT NULL)`,
|
||||||
|
`INSERT INTO versionMigrations (version, executedAt) VALUES ('1.21.0', 1750000000000)`,
|
||||||
|
`CREATE TABLE sites (siteId INTEGER PRIMARY KEY AUTOINCREMENT, subnet TEXT)`,
|
||||||
|
`INSERT INTO sites (subnet) VALUES ('10.0.0.0/24')`,
|
||||||
|
`CREATE TABLE roles (roleId INTEGER PRIMARY KEY AUTOINCREMENT, orgId TEXT, isAdmin INTEGER DEFAULT 0, sshSudoMode TEXT DEFAULT 'none')`,
|
||||||
|
`INSERT INTO roles (orgId, isAdmin, sshSudoMode) VALUES ('org1', 0, 'none')`,
|
||||||
|
`CREATE TABLE licenseKey (licenseKeyId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||||
|
`CREATE TABLE targets (targetId INTEGER PRIMARY KEY AUTOINCREMENT, resourceId INTEGER, siteId INTEGER NOT NULL, ip TEXT NOT NULL, method TEXT, port INTEGER NOT NULL, internalPort INTEGER, enabled INTEGER DEFAULT 1, path TEXT, pathMatchType TEXT, rewritePath TEXT, rewritePathType TEXT, priority INTEGER DEFAULT 100, mode TEXT DEFAULT 'http', authToken TEXT)`,
|
||||||
|
`CREATE TABLE subscriptions (subscriptionId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||||
|
`CREATE TABLE clients (clientId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||||
|
`CREATE TABLE orgs (orgId TEXT PRIMARY KEY)`,
|
||||||
|
`INSERT INTO orgs (orgId) VALUES ('org1')`,
|
||||||
|
`CREATE TABLE siteResources (siteResourceId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||||
|
`CREATE TABLE eventStreamingDestinations (destinationId INTEGER PRIMARY KEY AUTOINCREMENT)`,
|
||||||
|
`CREATE TABLE roleActions (roleId INTEGER, actionId TEXT, orgId TEXT)`,
|
||||||
|
`CREATE TABLE newt (newtId INTEGER PRIMARY KEY AUTOINCREMENT)`
|
||||||
|
];
|
||||||
|
|
||||||
|
function seedDatabase(dbPath: string) {
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
try {
|
||||||
|
for (const statement of SEED_STATEMENTS) {
|
||||||
|
db.exec(statement);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableColumns(dbPath: string, tableName: string): string[] {
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||||
|
name: unknown;
|
||||||
|
}>
|
||||||
|
).map((row) => String(row.name));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function executedMigrationVersions(dbPath: string): string[] {
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
db.prepare(`SELECT version FROM versionMigrations`).all() as Array<{
|
||||||
|
version: unknown;
|
||||||
|
}>
|
||||||
|
).map((row) => String(row.version));
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations(
|
||||||
|
workdir: string,
|
||||||
|
env: Record<string, string> = {}
|
||||||
|
): {
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
} {
|
||||||
|
const tsconfig = ["tsconfig.json", "tsconfig.oss.json"]
|
||||||
|
.map((file) => path.join(repoRoot, file))
|
||||||
|
.find((file) => fs.existsSync(file));
|
||||||
|
if (!tsconfig) {
|
||||||
|
throw new Error("No tsconfig found for @server path aliases");
|
||||||
|
}
|
||||||
|
const tsxCli = path.join(
|
||||||
|
repoRoot,
|
||||||
|
"node_modules",
|
||||||
|
"tsx",
|
||||||
|
"dist",
|
||||||
|
"cli.mjs"
|
||||||
|
);
|
||||||
|
if (!fs.existsSync(tsxCli)) {
|
||||||
|
throw new Error("tsx is not installed; run npm ci first");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const output = execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[tsxCli, "--tsconfig", tsconfig, migrationsScript],
|
||||||
|
{
|
||||||
|
cwd: workdir,
|
||||||
|
timeout: 120000,
|
||||||
|
encoding: "utf8",
|
||||||
|
env: { ...process.env, NODE_ENV: "test", ...env }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return { exitCode: 0, output };
|
||||||
|
} catch (error) {
|
||||||
|
const output =
|
||||||
|
error instanceof Error
|
||||||
|
? (error as Error & { stdout?: unknown }).stdout
|
||||||
|
: "";
|
||||||
|
return { exitCode: 1, output: String(output ?? "") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestEnvironment(): string {
|
||||||
|
for (const generated of ["server/build.ts", "server/db/index.ts"]) {
|
||||||
|
if (!fs.existsSync(path.join(repoRoot, generated))) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing ${generated}; run npm run set:oss && npm run set:sqlite first`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const workdir = fs.mkdtempSync(
|
||||||
|
path.join(os.tmpdir(), "pangolin-backup-test-")
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true });
|
||||||
|
fs.copyFileSync(
|
||||||
|
path.join(repoRoot, "config", "config.example.yml"),
|
||||||
|
path.join(workdir, "config", "config.yml")
|
||||||
|
);
|
||||||
|
const traefikSrc = path.join(repoRoot, "config", "traefik");
|
||||||
|
if (fs.existsSync(traefikSrc)) {
|
||||||
|
fs.cpSync(traefikSrc, path.join(workdir, "config", "traefik"), {
|
||||||
|
recursive: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fs.symlinkSync(
|
||||||
|
path.join(repoRoot, "server"),
|
||||||
|
path.join(workdir, "server"),
|
||||||
|
process.platform === "win32" ? "junction" : "dir"
|
||||||
|
);
|
||||||
|
return workdir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function testMultipleSequentialMigrations() {
|
||||||
|
console.log("Running multiple sequential migrations test...");
|
||||||
|
const workdir = createTestEnvironment();
|
||||||
|
try {
|
||||||
|
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
|
||||||
|
const result = runMigrations(workdir);
|
||||||
|
assertEquals(result.exitCode, 0, "Seeded migrations must run cleanly");
|
||||||
|
if (!result.output.includes("All migrations completed successfully")) {
|
||||||
|
throw new Error(
|
||||||
|
"Seeded migrations did not complete; the backup assertions below would be vacuous"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||||
|
const backups = fs.existsSync(backupsDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(backupsDir)
|
||||||
|
.filter((file) => file.endsWith(".sqlite"))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Upgrading from 1.21.0 runs 1.22.0 and 1.23.0 -> produces 2 distinct backups
|
||||||
|
assertEquals(
|
||||||
|
backups.length,
|
||||||
|
2,
|
||||||
|
"Each migration must have its own distinct backup snapshot"
|
||||||
|
);
|
||||||
|
|
||||||
|
const v122Backup = backups.find((file) =>
|
||||||
|
file.includes("_v1.22.0.sqlite")
|
||||||
|
);
|
||||||
|
const v123Backup = backups.find((file) =>
|
||||||
|
file.includes("_v1.23.0.sqlite")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!v122Backup || !v123Backup) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected backups for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pre-1.22.0 snapshot state: sites has 'subnet' (not exitNodeSubnet), versions = [1.21.0]
|
||||||
|
const v122Columns = tableColumns(
|
||||||
|
path.join(backupsDir, v122Backup),
|
||||||
|
"sites"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v122Columns.includes("subnet") &&
|
||||||
|
!v122Columns.includes("exitNodeSubnet"),
|
||||||
|
true,
|
||||||
|
"Backup before 1.22.0 must retain pre-1.22.0 schema (sites.subnet)"
|
||||||
|
);
|
||||||
|
const v122Versions = executedMigrationVersions(
|
||||||
|
path.join(backupsDir, v122Backup)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v122Versions.includes("1.21.0") && !v122Versions.includes("1.22.0"),
|
||||||
|
true,
|
||||||
|
"Backup before 1.22.0 must only record version 1.21.0"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify pre-1.23.0 snapshot state: sites has 'exitNodeSubnet' (1.22.0 applied), newt has no agent
|
||||||
|
const v123Columns = tableColumns(
|
||||||
|
path.join(backupsDir, v123Backup),
|
||||||
|
"sites"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v123Columns.includes("exitNodeSubnet"),
|
||||||
|
true,
|
||||||
|
"Backup before 1.23.0 must contain successfully applied 1.22.0 schema (sites.exitNodeSubnet)"
|
||||||
|
);
|
||||||
|
const v123NewtCols = tableColumns(
|
||||||
|
path.join(backupsDir, v123Backup),
|
||||||
|
"newt"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
!v123NewtCols.includes("agent"),
|
||||||
|
true,
|
||||||
|
"Backup before 1.23.0 must not contain 1.23.0 schema changes yet"
|
||||||
|
);
|
||||||
|
const v123Versions = executedMigrationVersions(
|
||||||
|
path.join(backupsDir, v123Backup)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v123Versions.includes("1.21.0") && v123Versions.includes("1.22.0"),
|
||||||
|
true,
|
||||||
|
"Backup before 1.23.0 must record both 1.21.0 and 1.22.0"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testFailureInLaterMigrationPreservesRestorePoints() {
|
||||||
|
console.log("Running failure in later migration test...");
|
||||||
|
const workdir = createTestEnvironment();
|
||||||
|
try {
|
||||||
|
const dbPath = path.join(workdir, "config", "db", "db.sqlite");
|
||||||
|
seedDatabase(dbPath);
|
||||||
|
|
||||||
|
// Intentionally drop table 'newt' so migration 1.23.0 fails on ALTER TABLE newt ADD COLUMN agent
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.exec("DROP TABLE newt;");
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const result = runMigrations(workdir);
|
||||||
|
assertEquals(
|
||||||
|
result.exitCode,
|
||||||
|
1,
|
||||||
|
"Migration suite must fail when 1.23.0 errors"
|
||||||
|
);
|
||||||
|
|
||||||
|
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||||
|
const backups = fs.existsSync(backupsDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(backupsDir)
|
||||||
|
.filter((file) => file.endsWith(".sqlite"))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Both pre-1.22.0 and pre-1.23.0 backups must exist
|
||||||
|
assertEquals(
|
||||||
|
backups.length,
|
||||||
|
2,
|
||||||
|
"Backups for earlier successful migration and the failed migration must both exist"
|
||||||
|
);
|
||||||
|
|
||||||
|
const v122Backup = backups.find((file) =>
|
||||||
|
file.includes("_v1.22.0.sqlite")
|
||||||
|
);
|
||||||
|
const v123Backup = backups.find((file) =>
|
||||||
|
file.includes("_v1.23.0.sqlite")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!v122Backup || !v123Backup) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected restore points for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify pre-1.23.0 backup is a valid restore point with 1.22.0 changes applied
|
||||||
|
const v123SitesCols = tableColumns(
|
||||||
|
path.join(backupsDir, v123Backup),
|
||||||
|
"sites"
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v123SitesCols.includes("exitNodeSubnet"),
|
||||||
|
true,
|
||||||
|
"Pre-failure restore point must have 1.22.0 changes intact"
|
||||||
|
);
|
||||||
|
const v123Versions = executedMigrationVersions(
|
||||||
|
path.join(backupsDir, v123Backup)
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
v123Versions.includes("1.22.0"),
|
||||||
|
true,
|
||||||
|
"Pre-failure restore point must record successful 1.22.0 migration"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDisableBackupOnMigration() {
|
||||||
|
console.log("Running DISABLE_BACKUP_ON_MIGRATION test...");
|
||||||
|
const workdir = createTestEnvironment();
|
||||||
|
try {
|
||||||
|
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
|
||||||
|
const result = runMigrations(workdir, {
|
||||||
|
DISABLE_BACKUP_ON_MIGRATION: "1"
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
result.exitCode,
|
||||||
|
0,
|
||||||
|
"Migrations must succeed with backups disabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
const backupsDir = path.join(workdir, "config", "db", "backups");
|
||||||
|
const backups = fs.existsSync(backupsDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(backupsDir)
|
||||||
|
.filter((file) => file.endsWith(".sqlite"))
|
||||||
|
: [];
|
||||||
|
assertEquals(
|
||||||
|
backups.length,
|
||||||
|
0,
|
||||||
|
"No backup files should be created when DISABLE_BACKUP_ON_MIGRATION is set"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
testMultipleSequentialMigrations();
|
||||||
|
testFailureInLaterMigrationPreservesRestorePoints();
|
||||||
|
testDisableBackupOnMigration();
|
||||||
|
console.log("All backup migration regression tests passed successfully!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import path from "path";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { versionMigrations } from "../db/sqlite";
|
import { versionMigrations } from "../db/sqlite";
|
||||||
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
|
||||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
import { formatBackupFileName } from "@server/lib/backupFileName";
|
||||||
import { SqliteError } from "better-sqlite3";
|
import { SqliteError } from "better-sqlite3";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -107,7 +107,7 @@ async function run() {
|
|||||||
await runMigrations();
|
await runMigrations();
|
||||||
}
|
}
|
||||||
|
|
||||||
function backupDb() {
|
function backupDb(version?: string) {
|
||||||
// make dir config/db/backups
|
// make dir config/db/backups
|
||||||
const appPath = APP_PATH;
|
const appPath = APP_PATH;
|
||||||
const dbDir = path.join(appPath, "db");
|
const dbDir = path.join(appPath, "db");
|
||||||
@@ -120,11 +120,10 @@ function backupDb() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// copy the db.sqlite file to backups
|
// copy the db.sqlite file to backups
|
||||||
// add the date to the filename
|
// add the date and migration version to the filename
|
||||||
const date = new Date();
|
const fileName = formatBackupFileName(version);
|
||||||
const dateString = formatBackupTimestamp(date);
|
|
||||||
const dbPath = path.join(dbDir, "db.sqlite");
|
const dbPath = path.join(dbDir, "db.sqlite");
|
||||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
const backupPath = path.join(backupsDir, fileName);
|
||||||
fs.copyFileSync(dbPath, backupPath);
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +162,12 @@ export async function runMigrations() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error running migrations:", e);
|
console.error("Error running migrations:", e);
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV === "test" ||
|
||||||
|
process.env.ENVIRONMENT === "test"
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
|
||||||
);
|
);
|
||||||
@@ -197,7 +202,7 @@ async function executeScripts() {
|
|||||||
try {
|
try {
|
||||||
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
|
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
|
||||||
// Backup the database before running the migration
|
// Backup the database before running the migration
|
||||||
backupDb();
|
backupDb(migration.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
await migration.run();
|
await migration.run();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
users
|
users
|
||||||
} from "../../db/sqlite";
|
} from "../../db/sqlite";
|
||||||
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
import { formatBackupTimestamp } from "@server/lib/backupFileName";
|
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import * as yaml from "js-yaml";
|
import * as yaml from "js-yaml";
|
||||||
@@ -21,25 +20,6 @@ import { fromZodError } from "zod-validation-error";
|
|||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log("Running setup script 1.0.0-beta.9...");
|
console.log("Running setup script 1.0.0-beta.9...");
|
||||||
|
|
||||||
// make dir config/db/backups
|
|
||||||
const appPath = APP_PATH;
|
|
||||||
const dbDir = path.join(appPath, "db");
|
|
||||||
|
|
||||||
const backupsDir = path.join(dbDir, "backups");
|
|
||||||
|
|
||||||
// check if the backups directory exists and create it if it doesn't
|
|
||||||
if (!fs.existsSync(backupsDir)) {
|
|
||||||
fs.mkdirSync(backupsDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// copy the db.sqlite file to backups
|
|
||||||
// add the date to the filename
|
|
||||||
const date = new Date();
|
|
||||||
const dateString = formatBackupTimestamp(date);
|
|
||||||
const dbPath = path.join(dbDir, "db.sqlite");
|
|
||||||
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
|
|
||||||
fs.copyFileSync(dbPath, backupPath);
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
try {
|
try {
|
||||||
// Determine which config file exists
|
// Determine which config file exists
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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