mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
@@ -13,6 +13,8 @@ managed:
|
|||||||
app:
|
app:
|
||||||
dashboard_url: "https://{{.DashboardDomain}}"
|
dashboard_url: "https://{{.DashboardDomain}}"
|
||||||
log_level: "info"
|
log_level: "info"
|
||||||
|
telemetry:
|
||||||
|
anonymous_usage: true
|
||||||
|
|
||||||
domains:
|
domains:
|
||||||
domain1:
|
domain1:
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
|
"internationaldomaindetected": "International Domain Detected",
|
||||||
|
"willbestoredas": "Will be stored as:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
|
"internationaldomaindetected": "Detekována mezinárodní doména",
|
||||||
|
"willbestoredas": "Bude uloženo jako:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
|
"autoLoginRedirecting": "Weiterleitung zur Anmeldung...",
|
||||||
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
||||||
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
"autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.",
|
||||||
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL."
|
"autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.",
|
||||||
|
"internationaldomaindetected": "Internationale Domäne erkannt",
|
||||||
|
"willbestoredas": "Wird gespeichert als:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@
|
|||||||
"actionGetSiteResource": "Get Site Resource",
|
"actionGetSiteResource": "Get Site Resource",
|
||||||
"actionListSiteResources": "List Site Resources",
|
"actionListSiteResources": "List Site Resources",
|
||||||
"actionUpdateSiteResource": "Update Site Resource",
|
"actionUpdateSiteResource": "Update Site Resource",
|
||||||
|
"actionListInvitations": "List Invitations",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"searchProgress": "Search...",
|
"searchProgress": "Search...",
|
||||||
@@ -1457,5 +1458,43 @@
|
|||||||
"autoLoginRedirecting": "Redirecting to login...",
|
"autoLoginRedirecting": "Redirecting to login...",
|
||||||
"autoLoginError": "Auto Login Error",
|
"autoLoginError": "Auto Login Error",
|
||||||
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
"autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL."
|
"autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.",
|
||||||
|
"managedSelfHosted": {
|
||||||
|
"title": "Managed Self-Hosted",
|
||||||
|
"description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles",
|
||||||
|
"introTitle": "Managed Self-Hosted Pangolin",
|
||||||
|
"introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.",
|
||||||
|
"introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:",
|
||||||
|
"benefitSimplerOperations": {
|
||||||
|
"title": "Simpler operations",
|
||||||
|
"description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box."
|
||||||
|
},
|
||||||
|
"benefitAutomaticUpdates": {
|
||||||
|
"title": "Automatic updates",
|
||||||
|
"description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time."
|
||||||
|
},
|
||||||
|
"benefitLessMaintenance": {
|
||||||
|
"title": "Less maintenance",
|
||||||
|
"description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud."
|
||||||
|
},
|
||||||
|
"benefitCloudFailover": {
|
||||||
|
"title": "Cloud failover",
|
||||||
|
"description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online."
|
||||||
|
},
|
||||||
|
"benefitHighAvailability": {
|
||||||
|
"title": "High availability (PoPs)",
|
||||||
|
"description": "You can also attach multiple nodes to your account for redundancy and better performance."
|
||||||
|
},
|
||||||
|
"benefitFutureEnhancements": {
|
||||||
|
"title": "Future enhancements",
|
||||||
|
"description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust."
|
||||||
|
},
|
||||||
|
"docsAlert": {
|
||||||
|
"text": "Learn more about the Managed Self-Hosted option in our",
|
||||||
|
"documentation": "documentation"
|
||||||
|
},
|
||||||
|
"convertButton": "Convert This Node to Managed Self-Hosted"
|
||||||
|
},
|
||||||
|
"internationaldomaindetected": "International Domain Detected",
|
||||||
|
"willbestoredas": "Will be stored as:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
|
"autoLoginRedirecting": "Redirigiendo al inicio de sesión...",
|
||||||
"autoLoginError": "Error de inicio de sesión automático",
|
"autoLoginError": "Error de inicio de sesión automático",
|
||||||
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
|
"autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.",
|
||||||
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación."
|
"autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.",
|
||||||
|
"internationaldomaindetected": "Dominio internacional detectado",
|
||||||
|
"willbestoredas": "Se almacenará como: "
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirection vers la connexion...",
|
"autoLoginRedirecting": "Redirection vers la connexion...",
|
||||||
"autoLoginError": "Erreur de connexion automatique",
|
"autoLoginError": "Erreur de connexion automatique",
|
||||||
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
|
"autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.",
|
||||||
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification."
|
"autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.",
|
||||||
|
"internationaldomaindetected": "Domaine international détecté",
|
||||||
|
"willbestoredas": "Sera stocké comme:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Reindirizzamento al login...",
|
"autoLoginRedirecting": "Reindirizzamento al login...",
|
||||||
"autoLoginError": "Errore di Accesso Automatico",
|
"autoLoginError": "Errore di Accesso Automatico",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
"autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.",
|
||||||
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione."
|
"autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.",
|
||||||
|
"internationaldomaindetected": "Rilevato dominio internazionale",
|
||||||
|
"willbestoredas": "Verrà archiviato come:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
|
"autoLoginRedirecting": "로그인으로 리디렉션 중...",
|
||||||
"autoLoginError": "자동 로그인 오류",
|
"autoLoginError": "자동 로그인 오류",
|
||||||
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
||||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패."
|
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
|
||||||
|
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||||
|
"willbestoredas": "다음과 같이 저장됩니다."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
||||||
"autoLoginError": "Auto Login Fout",
|
"autoLoginError": "Auto Login Fout",
|
||||||
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
"autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.",
|
||||||
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt."
|
"autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.",
|
||||||
|
"internationaldomaindetected": "Internationaal Domein Gedetecteerd",
|
||||||
|
"willbestoredas": "Wordt opgeslagen als:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Przekierowanie do logowania...",
|
"autoLoginRedirecting": "Przekierowanie do logowania...",
|
||||||
"autoLoginError": "Błąd automatycznego logowania",
|
"autoLoginError": "Błąd automatycznego logowania",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
|
"autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.",
|
||||||
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania."
|
"autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.",
|
||||||
|
"internationaldomaindetected": "Wykryto domenę międzynarodową",
|
||||||
|
"willbestoredas": "Będzie przechowywane jako:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Redirecionando para login...",
|
"autoLoginRedirecting": "Redirecionando para login...",
|
||||||
"autoLoginError": "Erro de Login Automático",
|
"autoLoginError": "Erro de Login Automático",
|
||||||
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
|
"autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.",
|
||||||
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação."
|
"autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.",
|
||||||
|
"internationaldomaindetected": "Domínio internacional detetado",
|
||||||
|
"willbestoredas": "Será armazenado como:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Перенаправление к входу...",
|
"autoLoginRedirecting": "Перенаправление к входу...",
|
||||||
"autoLoginError": "Ошибка автоматического входа",
|
"autoLoginError": "Ошибка автоматического входа",
|
||||||
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
||||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации."
|
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
|
||||||
|
"internationaldomaindetected": "Обнаружен международный домен",
|
||||||
|
"willbestoredas": "Будет сохранен как:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
||||||
"autoLoginError": "Otomatik Giriş Hatası",
|
"autoLoginError": "Otomatik Giriş Hatası",
|
||||||
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
|
"autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.",
|
||||||
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı."
|
"autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.",
|
||||||
|
"internationaldomaindetected": "Uluslararası Etki Alanı Algılandı",
|
||||||
|
"willbestoredas": "Şu şekilde saklanacaktır:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1457,5 +1457,7 @@
|
|||||||
"autoLoginRedirecting": "重定向到登录...",
|
"autoLoginRedirecting": "重定向到登录...",
|
||||||
"autoLoginError": "自动登录错误",
|
"autoLoginError": "自动登录错误",
|
||||||
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
||||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。"
|
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
|
||||||
|
"internationaldomaindetected": "检测到国际域名",
|
||||||
|
"willbestoredas": "将存储为:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export const configSchema = z
|
|||||||
.default("/var/dynamic/router_config.yml"),
|
.default("/var/dynamic/router_config.yml"),
|
||||||
static_domains: z.array(z.string()).optional().default([]),
|
static_domains: z.array(z.string()).optional().default([]),
|
||||||
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
|
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
|
||||||
|
allow_raw_resources: z.boolean().optional().default(true),
|
||||||
file_mode: z.boolean().optional().default(false)
|
file_mode: z.boolean().optional().default(false)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
|
|||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
|
|||||||
Array<{
|
Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
export const subdomainSchema = z
|
export const subdomainSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
|
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/,
|
||||||
"Invalid subdomain format"
|
"Invalid subdomain format"
|
||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long")
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
@@ -12,7 +12,8 @@ export const subdomainSchema = z
|
|||||||
export const tlsNameSchema = z
|
export const tlsNameSchema = z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/,
|
/^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/,
|
||||||
"Invalid subdomain format"
|
"Invalid subdomain format"
|
||||||
)
|
)
|
||||||
.transform((val) => val.toLowerCase());
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
||||||
|
|||||||
235
server/lib/traefikConfig.test.ts
Normal file
235
server/lib/traefikConfig.test.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { assertEquals } from "@test/assert";
|
||||||
|
import { isDomainCoveredByWildcard } from "./traefikConfig";
|
||||||
|
|
||||||
|
function runTests() {
|
||||||
|
console.log('Running wildcard domain coverage tests...');
|
||||||
|
|
||||||
|
// Test case 1: Basic wildcard certificate at example.com
|
||||||
|
const basicWildcardCerts = new Map([
|
||||||
|
['example.com', { exists: true, wildcard: true }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should match first-level subdomains
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('level1.example.com', basicWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Wildcard cert at example.com should match level1.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('api.example.com', basicWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Wildcard cert at example.com should match api.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('www.example.com', basicWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Wildcard cert at example.com should match www.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should match the root domain (exact match)
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('example.com', basicWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Wildcard cert at example.com should match example.com itself'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT match second-level subdomains
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('level2.level1.example.com', basicWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Wildcard cert at example.com should NOT match level2.level1.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('deep.nested.subdomain.example.com', basicWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should NOT match different domains
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('test.otherdomain.com', basicWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Wildcard cert at example.com should NOT match test.otherdomain.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('notexample.com', basicWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Wildcard cert at example.com should NOT match notexample.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 2: Multiple wildcard certificates
|
||||||
|
const multipleWildcardCerts = new Map([
|
||||||
|
['example.com', { exists: true, wildcard: true }],
|
||||||
|
['test.org', { exists: true, wildcard: true }],
|
||||||
|
['api.service.net', { exists: true, wildcard: true }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('app.example.com', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of first wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('staging.test.org', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of second wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('v1.api.service.net', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of third wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('deep.nested.api.service.net', multipleWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Should NOT match multi-level subdomain of third wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test exact domain matches for multiple certs
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('example.com', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match exact domain of first wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('test.org', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match exact domain of second wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('api.service.net', multipleWildcardCerts),
|
||||||
|
true,
|
||||||
|
'Should match exact domain of third wildcard cert'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 3: Non-wildcard certificates (should not match anything)
|
||||||
|
const nonWildcardCerts = new Map([
|
||||||
|
['example.com', { exists: true, wildcard: false }],
|
||||||
|
['specific.domain.com', { exists: true, wildcard: false }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('sub.example.com', nonWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Non-wildcard cert should not match subdomains'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('example.com', nonWildcardCerts),
|
||||||
|
false,
|
||||||
|
'Non-wildcard cert should not match even exact domain via this function'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 4: Non-existent certificates (should not match)
|
||||||
|
const nonExistentCerts = new Map([
|
||||||
|
['example.com', { exists: false, wildcard: true }],
|
||||||
|
['missing.com', { exists: false, wildcard: true }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('sub.example.com', nonExistentCerts),
|
||||||
|
false,
|
||||||
|
'Non-existent wildcard cert should not match'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 5: Edge cases with special domain names
|
||||||
|
const specialDomainCerts = new Map([
|
||||||
|
['localhost', { exists: true, wildcard: true }],
|
||||||
|
['127-0-0-1.nip.io', { exists: true, wildcard: true }],
|
||||||
|
['xn--e1afmkfd.xn--p1ai', { exists: true, wildcard: true }] // IDN domain
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('app.localhost', specialDomainCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of localhost wildcard'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('test.127-0-0-1.nip.io', specialDomainCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of nip.io wildcard'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('sub.xn--e1afmkfd.xn--p1ai', specialDomainCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain of IDN wildcard'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 6: Empty input and edge cases
|
||||||
|
const emptyCerts = new Map();
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('any.domain.com', emptyCerts),
|
||||||
|
false,
|
||||||
|
'Empty certificate map should not match any domain'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 7: Domains with single character components
|
||||||
|
const singleCharCerts = new Map([
|
||||||
|
['a.com', { exists: true, wildcard: true }],
|
||||||
|
['x.y.z', { exists: true, wildcard: true }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('b.a.com', singleCharCerts),
|
||||||
|
true,
|
||||||
|
'Should match single character subdomain'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('w.x.y.z', singleCharCerts),
|
||||||
|
true,
|
||||||
|
'Should match single character subdomain of multi-part domain'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('v.w.x.y.z', singleCharCerts),
|
||||||
|
false,
|
||||||
|
'Should NOT match multi-level subdomain of single char domain'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test case 8: Domains with numbers and hyphens
|
||||||
|
const numericCerts = new Map([
|
||||||
|
['api-v2.service-1.com', { exists: true, wildcard: true }],
|
||||||
|
['123.456.net', { exists: true, wildcard: true }]
|
||||||
|
]);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('staging.api-v2.service-1.com', numericCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain with hyphens and numbers'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('test.123.456.net', numericCerts),
|
||||||
|
true,
|
||||||
|
'Should match subdomain with numeric components'
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
isDomainCoveredByWildcard('deep.staging.api-v2.service-1.com', numericCerts),
|
||||||
|
false,
|
||||||
|
'Should NOT match multi-level subdomain with hyphens and numbers'
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('All wildcard domain coverage tests passed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all tests
|
||||||
|
try {
|
||||||
|
runTests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Test failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export class TraefikConfigManager {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
lastModified: Date | null;
|
lastModified: Date | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
wildcard: boolean | null;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
lastModified: Date | null;
|
lastModified: Date | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
|
wildcard: boolean;
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
> {
|
> {
|
||||||
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
|
|||||||
const certPath = path.join(domainDir, "cert.pem");
|
const certPath = path.join(domainDir, "cert.pem");
|
||||||
const keyPath = path.join(domainDir, "key.pem");
|
const keyPath = path.join(domainDir, "key.pem");
|
||||||
const lastUpdatePath = path.join(domainDir, ".last_update");
|
const lastUpdatePath = path.join(domainDir, ".last_update");
|
||||||
|
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||||
|
|
||||||
const certExists = await this.fileExists(certPath);
|
const certExists = await this.fileExists(certPath);
|
||||||
const keyExists = await this.fileExists(keyPath);
|
const keyExists = await this.fileExists(keyPath);
|
||||||
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
||||||
|
const wildcardExists = await this.fileExists(wildcardPath);
|
||||||
|
|
||||||
let lastModified: Date | null = null;
|
let lastModified: Date | null = null;
|
||||||
const expiresAt: Date | null = null;
|
const expiresAt: Date | null = null;
|
||||||
|
let wildcard = false;
|
||||||
|
|
||||||
if (lastUpdateExists) {
|
if (lastUpdateExists) {
|
||||||
try {
|
try {
|
||||||
@@ -161,10 +166,26 @@ export class TraefikConfigManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is a wildcard certificate
|
||||||
|
if (wildcardExists) {
|
||||||
|
try {
|
||||||
|
const wildcardContent = fs
|
||||||
|
.readFileSync(wildcardPath, "utf8")
|
||||||
|
.trim();
|
||||||
|
wildcard = wildcardContent === "true";
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Could not read wildcard file for ${domain}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.set(domain, {
|
state.set(domain, {
|
||||||
exists: certExists && keyExists,
|
exists: certExists && keyExists,
|
||||||
lastModified,
|
lastModified,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
wildcard
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch if domains have changed
|
// Filter out domains covered by wildcard certificates
|
||||||
|
const domainsNeedingCerts = new Set<string>();
|
||||||
|
for (const domain of currentDomains) {
|
||||||
|
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||||
|
domainsNeedingCerts.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch if domains needing certificates have changed
|
||||||
|
const lastDomainsNeedingCerts = new Set<string>();
|
||||||
|
for (const domain of this.lastKnownDomains) {
|
||||||
|
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||||
|
lastDomainsNeedingCerts.add(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.lastKnownDomains.size !== currentDomains.size ||
|
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
|
||||||
!Array.from(this.lastKnownDomains).every((domain) =>
|
!Array.from(domainsNeedingCerts).every((domain) =>
|
||||||
currentDomains.has(domain)
|
lastDomainsNeedingCerts.has(domain)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
logger.info("Fetching certificates due to domain changes");
|
logger.info(
|
||||||
|
"Fetching certificates due to domain changes (after wildcard filtering)"
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any local certificates are missing or appear to be outdated
|
// Check if any local certificates are missing or appear to be outdated
|
||||||
for (const domain of currentDomains) {
|
for (const domain of domainsNeedingCerts) {
|
||||||
const localState = this.lastLocalCertificateState.get(domain);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (!localState || !localState.exists) {
|
if (!localState || !localState.exists) {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
|
|||||||
let validCertificates: Array<{
|
let validCertificates: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (this.shouldFetchCertificates(domains)) {
|
if (this.shouldFetchCertificates(domains)) {
|
||||||
// Get valid certificates for active domains
|
// Filter out domains that are already covered by wildcard certificates
|
||||||
if (config.isManagedMode()) {
|
const domainsToFetch = new Set<string>();
|
||||||
validCertificates =
|
for (const domain of domains) {
|
||||||
await getValidCertificatesForDomainsHybrid(domains);
|
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||||
} else {
|
domainsToFetch.add(domain);
|
||||||
validCertificates =
|
} else {
|
||||||
await getValidCertificatesForDomains(domains);
|
logger.debug(
|
||||||
|
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.lastCertificateFetch = new Date();
|
|
||||||
this.lastKnownDomains = new Set(domains);
|
|
||||||
|
|
||||||
logger.info(
|
if (domainsToFetch.size > 0) {
|
||||||
`Fetched ${validCertificates.length} certificates from remote`
|
// Get valid certificates for domains not covered by wildcards
|
||||||
);
|
if (config.isManagedMode()) {
|
||||||
|
validCertificates =
|
||||||
|
await getValidCertificatesForDomainsHybrid(
|
||||||
|
domainsToFetch
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
validCertificates =
|
||||||
|
await getValidCertificatesForDomains(
|
||||||
|
domainsToFetch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.lastCertificateFetch = new Date();
|
||||||
|
this.lastKnownDomains = new Set(domains);
|
||||||
|
|
||||||
// Download and decrypt new certificates
|
logger.info(
|
||||||
await this.processValidCertificates(validCertificates);
|
`Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Download and decrypt new certificates
|
||||||
|
await this.processValidCertificates(validCertificates);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
"All domains are covered by existing wildcard certificates, no fetch needed"
|
||||||
|
);
|
||||||
|
this.lastCertificateFetch = new Date();
|
||||||
|
this.lastKnownDomains = new Set(domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always ensure all existing certificates (including wildcards) are in the config
|
||||||
|
await this.updateDynamicConfigFromLocalCerts(domains);
|
||||||
} else {
|
} else {
|
||||||
const timeSinceLastFetch = this.lastCertificateFetch
|
const timeSinceLastFetch = this.lastCertificateFetch
|
||||||
? Math.round(
|
? Math.round(
|
||||||
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
|
|||||||
// Clear existing certificates and rebuild from local state
|
// Clear existing certificates and rebuild from local state
|
||||||
dynamicConfig.tls.certificates = [];
|
dynamicConfig.tls.certificates = [];
|
||||||
|
|
||||||
|
// Keep track of certificates we've already added to avoid duplicates
|
||||||
|
const addedCertPaths = new Set<string>();
|
||||||
|
|
||||||
for (const domain of domains) {
|
for (const domain of domains) {
|
||||||
|
// First, try to find an exact match certificate
|
||||||
const localState = this.lastLocalCertificateState.get(domain);
|
const localState = this.lastLocalCertificateState.get(domain);
|
||||||
if (localState && localState.exists) {
|
if (localState && localState.exists) {
|
||||||
const domainDir = path.join(
|
const domainDir = path.join(
|
||||||
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
|
|||||||
const certPath = path.join(domainDir, "cert.pem");
|
const certPath = path.join(domainDir, "cert.pem");
|
||||||
const keyPath = path.join(domainDir, "key.pem");
|
const keyPath = path.join(domainDir, "key.pem");
|
||||||
|
|
||||||
const certEntry = {
|
if (!addedCertPaths.has(certPath)) {
|
||||||
certFile: certPath,
|
const certEntry = {
|
||||||
keyFile: keyPath
|
certFile: certPath,
|
||||||
};
|
keyFile: keyPath
|
||||||
dynamicConfig.tls.certificates.push(certEntry);
|
};
|
||||||
|
dynamicConfig.tls.certificates.push(certEntry);
|
||||||
|
addedCertPaths.add(certPath);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, check for wildcard certificates that cover this domain
|
||||||
|
for (const [certDomain, certState] of this.lastLocalCertificateState) {
|
||||||
|
if (certState.exists && certState.wildcard) {
|
||||||
|
// Check if this wildcard certificate covers the domain
|
||||||
|
if (domain.endsWith("." + certDomain)) {
|
||||||
|
// Verify it's only one level deep (wildcard only covers one level)
|
||||||
|
const prefix = domain.substring(
|
||||||
|
0,
|
||||||
|
domain.length - ("." + certDomain).length
|
||||||
|
);
|
||||||
|
if (!prefix.includes(".")) {
|
||||||
|
const domainDir = path.join(
|
||||||
|
config.getRawConfig().traefik.certificates_path,
|
||||||
|
certDomain
|
||||||
|
);
|
||||||
|
const certPath = path.join(domainDir, "cert.pem");
|
||||||
|
const keyPath = path.join(domainDir, "key.pem");
|
||||||
|
|
||||||
|
if (!addedCertPaths.has(certPath)) {
|
||||||
|
const certEntry = {
|
||||||
|
certFile: certPath,
|
||||||
|
keyFile: keyPath
|
||||||
|
};
|
||||||
|
dynamicConfig.tls.certificates.push(certEntry);
|
||||||
|
addedCertPaths.add(certPath);
|
||||||
|
}
|
||||||
|
break; // Found a wildcard that covers this domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +683,7 @@ export class TraefikConfigManager {
|
|||||||
validCertificates: Array<{
|
validCertificates: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
wildcard: boolean | null;
|
||||||
certFile: string | null;
|
certFile: string | null;
|
||||||
keyFile: string | null;
|
keyFile: string | null;
|
||||||
expiresAt: Date | null;
|
expiresAt: Date | null;
|
||||||
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if this is a wildcard certificate and store it
|
||||||
|
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||||
|
fs.writeFileSync(
|
||||||
|
wildcardPath,
|
||||||
|
cert.wildcard ? "true" : "false",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Certificate updated for domain: ${cert.domain}`
|
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update local state tracking
|
// Update local state tracking
|
||||||
this.lastLocalCertificateState.set(cert.domain, {
|
this.lastLocalCertificateState.set(cert.domain, {
|
||||||
exists: true,
|
exists: true,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
expiresAt: cert.expiresAt
|
expiresAt: cert.expiresAt,
|
||||||
|
wildcard: cert.wildcard
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
|
|||||||
this.lastLocalCertificateState.delete(dirName);
|
this.lastLocalCertificateState.delete(dirName);
|
||||||
|
|
||||||
// Remove from dynamic config
|
// Remove from dynamic config
|
||||||
const certFilePath = path.join(
|
const certFilePath = path.join(domainDir, "cert.pem");
|
||||||
domainDir,
|
const keyFilePath = path.join(domainDir, "key.pem");
|
||||||
"cert.pem"
|
|
||||||
);
|
|
||||||
const keyFilePath = path.join(
|
|
||||||
domainDir,
|
|
||||||
"key.pem"
|
|
||||||
);
|
|
||||||
const before = dynamicConfig.tls.certificates.length;
|
const before = dynamicConfig.tls.certificates.length;
|
||||||
dynamicConfig.tls.certificates =
|
dynamicConfig.tls.certificates =
|
||||||
dynamicConfig.tls.certificates.filter(
|
dynamicConfig.tls.certificates.filter(
|
||||||
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
|
|||||||
monitorInterval: number;
|
monitorInterval: number;
|
||||||
lastCertificateFetch: Date | null;
|
lastCertificateFetch: Date | null;
|
||||||
localCertificateCount: number;
|
localCertificateCount: number;
|
||||||
|
wildcardCertificates: string[];
|
||||||
|
domainsCoveredByWildcards: string[];
|
||||||
} {
|
} {
|
||||||
|
const wildcardCertificates: string[] = [];
|
||||||
|
const domainsCoveredByWildcards: string[] = [];
|
||||||
|
|
||||||
|
// Find wildcard certificates
|
||||||
|
for (const [domain, state] of this.lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard) {
|
||||||
|
wildcardCertificates.push(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find domains covered by wildcards
|
||||||
|
for (const domain of this.activeDomains) {
|
||||||
|
if (isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||||
|
domainsCoveredByWildcards.push(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRunning: this.isRunning,
|
isRunning: this.isRunning,
|
||||||
activeDomains: Array.from(this.activeDomains),
|
activeDomains: Array.from(this.activeDomains),
|
||||||
monitorInterval:
|
monitorInterval:
|
||||||
config.getRawConfig().traefik.monitor_interval || 5000,
|
config.getRawConfig().traefik.monitor_interval || 5000,
|
||||||
lastCertificateFetch: this.lastCertificateFetch,
|
lastCertificateFetch: this.lastCertificateFetch,
|
||||||
localCertificateCount: this.lastLocalCertificateState.size
|
localCertificateCount: this.lastLocalCertificateState.size,
|
||||||
|
wildcardCertificates,
|
||||||
|
domainsCoveredByWildcards
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is covered by existing wildcard certificates
|
||||||
|
*/
|
||||||
|
export function isDomainCoveredByWildcard(domain: string, lastLocalCertificateState: Map<string, { exists: boolean; wildcard: boolean | null }>): boolean {
|
||||||
|
for (const [certDomain, state] of lastLocalCertificateState) {
|
||||||
|
if (state.exists && state.wildcard) {
|
||||||
|
// If stored as example.com but is wildcard, check subdomains
|
||||||
|
if (domain.endsWith("." + certDomain)) {
|
||||||
|
// Check that it's only one level deep (wildcard only covers one level)
|
||||||
|
const prefix = domain.substring(
|
||||||
|
0,
|
||||||
|
domain.length - ("." + certDomain).length
|
||||||
|
);
|
||||||
|
// If prefix contains a dot, it's more than one level deep
|
||||||
|
if (!prefix.includes(".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function verifyRoleAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roleIds } = req.body;
|
const roleIds = req.body?.roleIds;
|
||||||
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
||||||
|
|
||||||
if (allRoleIds.length === 0) {
|
if (allRoleIds.length === 0) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ authenticated.delete(
|
|||||||
"/org/:orgId",
|
"/org/:orgId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
verifyUserIsOrgOwner,
|
verifyUserIsOrgOwner,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||||
org.deleteOrg
|
org.deleteOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,13 @@ authenticated.get(
|
|||||||
domain.listDomains
|
domain.listDomains
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/invitations",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listInvitations),
|
||||||
|
user.listInvitations
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/create-invite",
|
"/org/:orgId/create-invite",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -49,19 +49,7 @@ export async function deleteOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
// Check if the user has permission to list sites
|
|
||||||
const hasPermission = await checkUserActionPermission(
|
|
||||||
ActionsEnum.deleteOrg,
|
|
||||||
req
|
|
||||||
);
|
|
||||||
if (!hasPermission) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have permission to perform this action"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const [org] = await db
|
const [org] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(orgs)
|
.from(orgs)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { apiKeys, roleResources, roles } from "@server/db";
|
import { apiKeys, roleResources, roles } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -74,13 +74,18 @@ export async function setResourceRoles(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
// get the resource
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!resource) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"Organization not found"
|
"Resource not found"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,7 +97,7 @@ export async function setResourceRoles(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roles.name, "Admin"),
|
eq(roles.name, "Admin"),
|
||||||
eq(roles.orgId, orgId)
|
eq(roles.orgId, resource.orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export async function createSite(
|
|||||||
type,
|
type,
|
||||||
dockerSocketEnabled: false,
|
dockerSocketEnabled: false,
|
||||||
online: true,
|
online: true,
|
||||||
subnet: "0.0.0.0/0"
|
subnet: "0.0.0.0/32"
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { db, exitNodes } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import { and, eq, inArray, or, isNull, ne } from "drizzle-orm";
|
import { and, eq, inArray, or, isNull, ne, isNotNull } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
|
|||||||
eq(sites.exitNodeId, exitNodeId),
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
isNull(sites.exitNodeId)
|
isNull(sites.exitNodeId)
|
||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes)
|
inArray(sites.type, siteTypes),
|
||||||
|
config.getRawConfig().traefik.allow_raw_resources
|
||||||
|
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
|
||||||
|
: eq(resources.http, true),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -58,18 +58,23 @@ export async function addUserRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
// get the role
|
||||||
|
const [role] = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!role) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingUser.length === 0) {
|
if (existingUser.length === 0) {
|
||||||
@@ -93,7 +98,7 @@ export async function addUserRole(
|
|||||||
const roleExists = await db
|
const roleExists = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
|
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (roleExists.length === 0) {
|
if (roleExists.length === 0) {
|
||||||
@@ -108,7 +113,7 @@ export async function addUserRole(
|
|||||||
const newUserRole = await db
|
const newUserRole = await db
|
||||||
.update(userOrgs)
|
.update(userOrgs)
|
||||||
.set({ roleId })
|
.set({ roleId })
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage,
|
||||||
|
FormDescription
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
|||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||||
import { InfoIcon, AlertTriangle } from "lucide-react";
|
import { InfoIcon, AlertTriangle, Globe } from "lucide-react";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
import {
|
||||||
InfoSection,
|
InfoSection,
|
||||||
@@ -43,9 +44,58 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { toASCII, toUnicode } from 'punycode';
|
||||||
|
|
||||||
|
|
||||||
|
// Helper functions for Unicode domain handling
|
||||||
|
function toPunycode(domain: string): string {
|
||||||
|
try {
|
||||||
|
const parts = toASCII(domain);
|
||||||
|
return parts;
|
||||||
|
} catch (error) {
|
||||||
|
return domain.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPunycode(domain: string): string {
|
||||||
|
try {
|
||||||
|
const parts = toUnicode(domain);
|
||||||
|
return parts;
|
||||||
|
} catch (error) {
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDomainFormat(domain: string): boolean {
|
||||||
|
const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/;
|
||||||
|
|
||||||
|
if (!unicodeRegex.test(domain)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = domain.split('.');
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (part.length > 63) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain.length > 253) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
baseDomain: z.string().min(1, "Domain is required"),
|
baseDomain: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Domain is required")
|
||||||
|
.refine((val) => isValidDomainFormat(val), "Invalid domain format")
|
||||||
|
.transform((val) => toPunycode(val)),
|
||||||
type: z.enum(["ns", "cname", "wildcard"])
|
type: z.enum(["ns", "cname", "wildcard"])
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,8 +159,14 @@ export default function CreateDomainForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainType = form.watch("type");
|
|
||||||
const baseDomain = form.watch("baseDomain");
|
const baseDomain = form.watch("baseDomain");
|
||||||
|
const domainInputValue = form.watch("baseDomain") || "";
|
||||||
|
|
||||||
|
const punycodePreview = useMemo(() => {
|
||||||
|
if (!domainInputValue) return "";
|
||||||
|
const punycode = toPunycode(domainInputValue);
|
||||||
|
return punycode !== domainInputValue.toLowerCase() ? punycode : "";
|
||||||
|
}, [domainInputValue]);
|
||||||
|
|
||||||
let domainOptions: any = [];
|
let domainOptions: any = [];
|
||||||
if (build == "enterprise" || build == "saas") {
|
if (build == "enterprise" || build == "saas") {
|
||||||
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
|
|||||||
<FormLabel>{t("domain")}</FormLabel>
|
<FormLabel>{t("domain")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="example.com"
|
placeholder="example.com, café.com, 日本.com"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
{punycodePreview && (
|
||||||
|
<FormDescription className="flex items-center gap-2 text-xs">
|
||||||
|
<Alert>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -206,66 +275,73 @@ export default function CreateDomainForm({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{createdDomain.nsRecords &&
|
{createdDomain.nsRecords &&
|
||||||
createdDomain.nsRecords.length > 0 && (
|
createdDomain.nsRecords.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium mb-3">
|
<h3 className="font-medium mb-3">
|
||||||
{t("createDomainNsRecords")}
|
{t("createDomainNsRecords")}
|
||||||
</h3>
|
</h3>
|
||||||
<InfoSections cols={1}>
|
<InfoSections cols={1}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
{t("createDomainRecord")}
|
{t("createDomainRecord")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"createDomainType"
|
"createDomainType"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<span className="text-sm font-mono">
|
||||||
NS
|
NS
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{t(
|
{t(
|
||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{baseDomain}
|
<span className="text-sm font-mono block">
|
||||||
</span>
|
{fromPunycode(baseDomain)}
|
||||||
</div>
|
</span>
|
||||||
<span className="text-sm font-medium">
|
{fromPunycode(baseDomain) !== baseDomain && (
|
||||||
{t(
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
"createDomainValue"
|
({baseDomain})
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
{createdDomain.nsRecords.map(
|
|
||||||
(
|
|
||||||
nsRecord,
|
|
||||||
index
|
|
||||||
) => (
|
|
||||||
<div
|
|
||||||
className="flex justify-between items-center"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<CopyToClipboard
|
|
||||||
text={
|
|
||||||
nsRecord
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
)}
|
<span className="text-sm font-medium">
|
||||||
</div>
|
{t(
|
||||||
</InfoSectionContent>
|
"createDomainValue"
|
||||||
</InfoSection>
|
)}
|
||||||
</InfoSections>
|
</span>
|
||||||
</div>
|
{createdDomain.nsRecords.map(
|
||||||
)}
|
(
|
||||||
|
nsRecord,
|
||||||
|
index
|
||||||
|
) => (
|
||||||
|
<div
|
||||||
|
className="flex justify-between items-center"
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={
|
||||||
|
nsRecord
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{createdDomain.cnameRecords &&
|
{createdDomain.cnameRecords &&
|
||||||
createdDomain.cnameRecords.length > 0 && (
|
createdDomain.cnameRecords.length > 0 && (
|
||||||
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
cnameRecord.baseDomain
|
{fromPunycode(cnameRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({cnameRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
aRecord.baseDomain
|
{fromPunycode(aRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({aRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -390,7 +476,7 @@ export default function CreateDomainForm({
|
|||||||
{
|
{
|
||||||
aRecord.value
|
aRecord.value
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
|
|||||||
"createDomainName"
|
"createDomainName"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-mono">
|
<div className="text-right">
|
||||||
{
|
<span className="text-sm font-mono block">
|
||||||
txtRecord.baseDomain
|
{fromPunycode(txtRecord.baseDomain)}
|
||||||
}
|
</span>
|
||||||
</span>
|
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
({txtRecord.baseDomain})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
@@ -513,4 +604,4 @@ export default function CreateDomainForm({
|
|||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListDomainsResponse } from "@server/routers/domain";
|
import { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
|
|||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
AxiosResponse<ListDomainsResponse>
|
AxiosResponse<ListDomainsResponse>
|
||||||
>(`/org/${params.orgId}/domains`, await authCookieHeader());
|
>(`/org/${params.orgId}/domains`, await authCookieHeader());
|
||||||
domains = res.data.data.domains as DomainRow[];
|
|
||||||
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
|
|
||||||
|
domains = rawDomains.map((domain) => ({
|
||||||
|
...domain,
|
||||||
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
interface DomainOption {
|
interface DomainOption {
|
||||||
baseDomain: string;
|
baseDomain: string;
|
||||||
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
|
|||||||
key={option.domainId}
|
key={option.domainId}
|
||||||
value={option.domainId}
|
value={option.domainId}
|
||||||
>
|
>
|
||||||
.{option.baseDomain}
|
.{toUnicode(option.baseDomain)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||||
const { resource, authInfo } = useResourceContext();
|
const { resource, authInfo } = useResourceContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
|
||||||
|
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
@@ -34,9 +38,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{authInfo.password ||
|
{authInfo.password ||
|
||||||
authInfo.pincode ||
|
authInfo.pincode ||
|
||||||
authInfo.sso ||
|
authInfo.sso ||
|
||||||
authInfo.whitelist ? (
|
authInfo.whitelist ? (
|
||||||
<div className="flex items-start space-x-2 text-green-500">
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
<span>{t("protected")}</span>
|
<span>{t("protected")}</span>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ import {
|
|||||||
import DomainPicker from "@app/components/DomainPicker";
|
import DomainPicker from "@app/components/DomainPicker";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||||
|
import { DomainRow } from "../../../domains/DomainsTable";
|
||||||
|
import { toASCII, toUnicode } from "punycode";
|
||||||
|
|
||||||
export default function GeneralForm() {
|
export default function GeneralForm() {
|
||||||
const [formKey, setFormKey] = useState(0);
|
const [formKey, setFormKey] = useState(0);
|
||||||
@@ -79,12 +82,13 @@ export default function GeneralForm() {
|
|||||||
|
|
||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||||
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||||
);
|
);
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
domainId: string;
|
domainId: string;
|
||||||
subdomain?: string;
|
subdomain?: string;
|
||||||
fullDomain: string;
|
fullDomain: string;
|
||||||
|
baseDomain: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const GeneralFormSchema = z
|
const GeneralFormSchema = z
|
||||||
@@ -153,7 +157,11 @@ export default function GeneralForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
const domains = res.data.data.domains;
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
|
const domains = rawDomains.map((domain) => ({
|
||||||
|
...domain,
|
||||||
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
|
}));
|
||||||
setBaseDomains(domains);
|
setBaseDomains(domains);
|
||||||
setFormKey((key) => key + 1);
|
setFormKey((key) => key + 1);
|
||||||
}
|
}
|
||||||
@@ -178,7 +186,7 @@ export default function GeneralForm() {
|
|||||||
{
|
{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
subdomain: data.subdomain,
|
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
|
||||||
domainId: data.domainId,
|
domainId: data.domainId,
|
||||||
proxyPort: data.proxyPort,
|
proxyPort: data.proxyPort,
|
||||||
// ...(!resource.http && {
|
// ...(!resource.http && {
|
||||||
@@ -317,10 +325,10 @@ export default function GeneralForm() {
|
|||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
? parseInt(
|
? parseInt(
|
||||||
e
|
e
|
||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -441,7 +449,8 @@ export default function GeneralForm() {
|
|||||||
const selected = {
|
const selected = {
|
||||||
domainId: res.domainId,
|
domainId: res.domainId,
|
||||||
subdomain: res.subdomain,
|
subdomain: res.subdomain,
|
||||||
fullDomain: res.fullDomain
|
fullDomain: res.fullDomain,
|
||||||
|
baseDomain: res.baseDomain
|
||||||
};
|
};
|
||||||
setSelectedDomain(selected);
|
setSelectedDomain(selected);
|
||||||
}}
|
}}
|
||||||
@@ -454,18 +463,23 @@ export default function GeneralForm() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedDomain) {
|
if (selectedDomain) {
|
||||||
setResourceFullDomain(
|
const sanitizedSubdomain = selectedDomain.subdomain
|
||||||
selectedDomain.fullDomain
|
? finalizeSubdomainSanitize(selectedDomain.subdomain)
|
||||||
);
|
: "";
|
||||||
form.setValue(
|
|
||||||
"domainId",
|
const sanitizedFullDomain = sanitizedSubdomain
|
||||||
selectedDomain.domainId
|
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||||
);
|
: selectedDomain.baseDomain;
|
||||||
form.setValue(
|
|
||||||
"subdomain",
|
setResourceFullDomain(sanitizedFullDomain);
|
||||||
selectedDomain.subdomain
|
form.setValue("domainId", selectedDomain.domainId);
|
||||||
);
|
form.setValue("subdomain", sanitizedSubdomain);
|
||||||
|
|
||||||
setEditDomainOpen(false);
|
setEditDomainOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
description: `Final domain: ${sanitizedFullDomain}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ import {
|
|||||||
CommandList
|
CommandList
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
|
||||||
const addTargetSchema = z.object({
|
const addTargetSchema = z.object({
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
@@ -417,11 +418,11 @@ export default function ReverseProxyTargets(props: {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -545,7 +546,7 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -614,31 +615,31 @@ export default function ReverseProxyTargets(props: {
|
|||||||
},
|
},
|
||||||
...(resource.http
|
...(resource.http
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -647,13 +648,25 @@ export default function ReverseProxyTargets(props: {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.ip}
|
defaultValue={row.original.ip}
|
||||||
className="min-w-[150px]"
|
className="min-w-[150px]"
|
||||||
onBlur={(e) =>
|
onBlur={(e) => {
|
||||||
updateTarget(row.original.targetId, {
|
const parsed = parseHostTarget(e.target.value);
|
||||||
...row.original,
|
if (parsed) {
|
||||||
ip: e.target.value
|
updateTarget(row.original.targetId, {
|
||||||
})
|
...row.original,
|
||||||
}
|
method: parsed.protocol,
|
||||||
|
ip: parsed.host,
|
||||||
|
port: parsed.port
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
ip: e.target.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -785,21 +798,21 @@ export default function ReverseProxyTargets(props: {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -865,18 +878,18 @@ export default function ReverseProxyTargets(props: {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt" ? (() => {
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||||
return (
|
return (
|
||||||
<ContainersSelector
|
<ContainersSelector
|
||||||
site={selectedSite}
|
site={selectedSite}
|
||||||
containers={dockerState.containers}
|
containers={dockerState.containers}
|
||||||
isAvailable={dockerState.isAvailable}
|
isAvailable={dockerState.isAvailable}
|
||||||
onContainerSelect={handleContainerSelect}
|
onContainerSelect={handleContainerSelect}
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})() : null;
|
})() : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -942,11 +955,22 @@ export default function ReverseProxyTargets(props: {
|
|||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>
|
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||||
{t("targetAddr")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input id="ip" {...field} />
|
<Input
|
||||||
|
id="ip"
|
||||||
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const parsed = parseHostTarget(e.target.value);
|
||||||
|
if (parsed) {
|
||||||
|
addTargetForm.setValue("method", parsed.protocol);
|
||||||
|
addTargetForm.setValue("ip", parsed.host);
|
||||||
|
addTargetForm.setValue("port", parsed.port);
|
||||||
|
} else {
|
||||||
|
field.onBlur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1048,12 +1072,12 @@ export default function ReverseProxyTargets(props: {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
|
|||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { ListTargetsResponse } from "@server/routers/target";
|
import { ListTargetsResponse } from "@server/routers/target";
|
||||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||||
|
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||||
|
import { toASCII, toUnicode } from 'punycode';
|
||||||
|
import { DomainRow } from "../../domains/DomainsTable";
|
||||||
|
|
||||||
const baseResourceFormSchema = z.object({
|
const baseResourceFormSchema = z.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
@@ -164,12 +167,12 @@ export default function Page() {
|
|||||||
...(!env.flags.allowRawResources
|
...(!env.flags.allowRawResources
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: "raw" as ResourceType,
|
id: "raw" as ResourceType,
|
||||||
title: t("resourceRaw"),
|
title: t("resourceRaw"),
|
||||||
description: t("resourceRawDescription")
|
description: t("resourceRawDescription")
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseForm = useForm<BaseResourceFormValues>({
|
const baseForm = useForm<BaseResourceFormValues>({
|
||||||
@@ -301,11 +304,11 @@ export default function Page() {
|
|||||||
targets.map((target) =>
|
targets.map((target) =>
|
||||||
target.targetId === targetId
|
target.targetId === targetId
|
||||||
? {
|
? {
|
||||||
...target,
|
...target,
|
||||||
...data,
|
...data,
|
||||||
updated: true,
|
updated: true,
|
||||||
siteType: site?.type || null
|
siteType: site?.type || null
|
||||||
}
|
}
|
||||||
: target
|
: target
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -326,7 +329,7 @@ export default function Page() {
|
|||||||
if (isHttp) {
|
if (isHttp) {
|
||||||
const httpData = httpForm.getValues();
|
const httpData = httpForm.getValues();
|
||||||
Object.assign(payload, {
|
Object.assign(payload, {
|
||||||
subdomain: httpData.subdomain,
|
subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
|
||||||
domainId: httpData.domainId,
|
domainId: httpData.domainId,
|
||||||
protocol: "tcp"
|
protocol: "tcp"
|
||||||
});
|
});
|
||||||
@@ -468,7 +471,11 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
const domains = res.data.data.domains;
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
|
const domains = rawDomains.map((domain) => ({
|
||||||
|
...domain,
|
||||||
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
|
}));
|
||||||
setBaseDomains(domains);
|
setBaseDomains(domains);
|
||||||
// if (domains.length) {
|
// if (domains.length) {
|
||||||
// httpForm.setValue("domainId", domains[0].domainId);
|
// httpForm.setValue("domainId", domains[0].domainId);
|
||||||
@@ -520,7 +527,7 @@ export default function Page() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!row.original.siteId &&
|
!row.original.siteId &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.original.siteId
|
{row.original.siteId
|
||||||
@@ -589,31 +596,31 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
...(baseForm.watch("http")
|
...(baseForm.watch("http")
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
accessorKey: "method",
|
accessorKey: "method",
|
||||||
header: t("method"),
|
header: t("method"),
|
||||||
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
cell: ({ row }: { row: Row<LocalTarget> }) => (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={row.original.method ?? ""}
|
defaultValue={row.original.method ?? ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
updateTarget(row.original.targetId, {
|
updateTarget(row.original.targetId, {
|
||||||
...row.original,
|
...row.original,
|
||||||
method: value
|
method: value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{row.original.method}
|
{row.original.method}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="http">http</SelectItem>
|
<SelectItem value="http">http</SelectItem>
|
||||||
<SelectItem value="https">https</SelectItem>
|
<SelectItem value="https">https</SelectItem>
|
||||||
<SelectItem value="h2c">h2c</SelectItem>
|
<SelectItem value="h2c">h2c</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
@@ -622,12 +629,23 @@ export default function Page() {
|
|||||||
<Input
|
<Input
|
||||||
defaultValue={row.original.ip}
|
defaultValue={row.original.ip}
|
||||||
className="min-w-[150px]"
|
className="min-w-[150px]"
|
||||||
onBlur={(e) =>
|
onBlur={(e) => {
|
||||||
updateTarget(row.original.targetId, {
|
const parsed = parseHostTarget(e.target.value);
|
||||||
...row.original,
|
|
||||||
ip: e.target.value
|
if (parsed) {
|
||||||
})
|
updateTarget(row.original.targetId, {
|
||||||
}
|
...row.original,
|
||||||
|
method: parsed.protocol,
|
||||||
|
ip: parsed.host,
|
||||||
|
port: parsed.port ? Number(parsed.port) : undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTarget(row.original.targetId, {
|
||||||
|
...row.original,
|
||||||
|
ip: e.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -909,10 +927,10 @@ export default function Page() {
|
|||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
? parseInt(
|
? parseInt(
|
||||||
e
|
e
|
||||||
.target
|
.target
|
||||||
.value
|
.value
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1015,21 +1033,21 @@ export default function Page() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"justify-between flex-1",
|
"justify-between flex-1",
|
||||||
!field.value &&
|
!field.value &&
|
||||||
"text-muted-foreground"
|
"text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value
|
||||||
? sites.find(
|
? sites.find(
|
||||||
(
|
(
|
||||||
site
|
site
|
||||||
) =>
|
) =>
|
||||||
site.siteId ===
|
site.siteId ===
|
||||||
field.value
|
field.value
|
||||||
)
|
)
|
||||||
?.name
|
?.name
|
||||||
: t(
|
: t(
|
||||||
"siteSelect"
|
"siteSelect"
|
||||||
)}
|
)}
|
||||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1097,18 +1115,18 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
return selectedSite &&
|
return selectedSite &&
|
||||||
selectedSite.type ===
|
selectedSite.type ===
|
||||||
"newt" ? (() => {
|
"newt" ? (() => {
|
||||||
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
const dockerState = getDockerStateForSite(selectedSite.siteId);
|
||||||
return (
|
return (
|
||||||
<ContainersSelector
|
<ContainersSelector
|
||||||
site={selectedSite}
|
site={selectedSite}
|
||||||
containers={dockerState.containers}
|
containers={dockerState.containers}
|
||||||
isAvailable={dockerState.isAvailable}
|
isAvailable={dockerState.isAvailable}
|
||||||
onContainerSelect={handleContainerSelect}
|
onContainerSelect={handleContainerSelect}
|
||||||
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
onRefresh={() => refreshContainersForSite(selectedSite.siteId)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})() : null;
|
})() : null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1176,21 +1194,25 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={
|
control={addTargetForm.control}
|
||||||
addTargetForm.control
|
|
||||||
}
|
|
||||||
name="ip"
|
name="ip"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="relative">
|
<FormItem className="relative">
|
||||||
<FormLabel>
|
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||||
{t(
|
|
||||||
"targetAddr"
|
|
||||||
)}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
id="ip"
|
id="ip"
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const parsed = parseHostTarget(e.target.value);
|
||||||
|
if (parsed) {
|
||||||
|
addTargetForm.setValue("method", parsed.protocol);
|
||||||
|
addTargetForm.setValue("ip", parsed.host);
|
||||||
|
addTargetForm.setValue("port", parsed.port);
|
||||||
|
} else {
|
||||||
|
field.onBlur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1270,12 +1292,12 @@ export default function Page() {
|
|||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header
|
header
|
||||||
.column
|
.column
|
||||||
.columnDef
|
.columnDef
|
||||||
.header,
|
.header,
|
||||||
header.getContext()
|
header.getContext()
|
||||||
)}
|
)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
|||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
type ResourcesPageProps = {
|
type ResourcesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
id: resource.resourceId,
|
id: resource.resourceId,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
|
||||||
|
|
||||||
|
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
} from "@app/components/ui/collapsible";
|
} from "@app/components/ui/collapsible";
|
||||||
import AccessTokenSection from "./AccessTokenUsage";
|
import AccessTokenSection from "./AccessTokenUsage";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toUnicode } from 'punycode';
|
||||||
|
|
||||||
type FormProps = {
|
type FormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
|
|||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
resourceId: r.resourceId,
|
resourceId: r.resourceId,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -18,30 +20,27 @@ import {
|
|||||||
ExternalLink
|
ExternalLink
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function ManagedPage() {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
export default async function ManagedPage() {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Managed Self-Hosted"
|
title={t("managedSelfHosted.title")}
|
||||||
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
|
description={t("managedSelfHosted.description")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
<strong>Managed Self-Hosted Pangolin</strong> is a
|
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
|
||||||
deployment option designed for people who want
|
{t("managedSelfHosted.introDescription")}
|
||||||
simplicity and extra reliability while still keeping
|
|
||||||
their data private and self-hosted.
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-6">
|
||||||
With this option, you still run your own Pangolin
|
{t("managedSelfHosted.introDetail")}
|
||||||
node — your tunnels, SSL termination, and traffic
|
|
||||||
all stay on your server. The difference is that
|
|
||||||
management and monitoring are handled through our
|
|
||||||
cloud dashboard, which unlocks a number of benefits:
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 py-4">
|
<div className="grid gap-4 md:grid-cols-2 py-4">
|
||||||
@@ -50,13 +49,14 @@ export default async function ManagedPage() {
|
|||||||
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
<CheckCircle className="w-5 h-5 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Simpler operations
|
{t(
|
||||||
|
"managedSelfHosted.benefitSimplerOperations.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No need to run your own mail server
|
{t(
|
||||||
or set up complex alerting. You'll
|
"managedSelfHosted.benefitSimplerOperations.description"
|
||||||
get health checks and downtime
|
)}
|
||||||
alerts out of the box.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,13 +65,14 @@ export default async function ManagedPage() {
|
|||||||
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
<RefreshCw className="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Automatic updates
|
{t(
|
||||||
|
"managedSelfHosted.benefitAutomaticUpdates.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The cloud dashboard evolves quickly,
|
{t(
|
||||||
so you get new features and bug
|
"managedSelfHosted.benefitAutomaticUpdates.description"
|
||||||
fixes without having to manually
|
)}
|
||||||
pull new containers every time.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,12 +81,14 @@ export default async function ManagedPage() {
|
|||||||
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
|
<Wrench className="w-5 h-5 text-orange-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Less maintenance
|
{t(
|
||||||
|
"managedSelfHosted.benefitLessMaintenance.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No database migrations, backups, or
|
{t(
|
||||||
extra infrastructure to manage. We
|
"managedSelfHosted.benefitLessMaintenance.description"
|
||||||
handle that in the cloud.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,13 +99,14 @@ export default async function ManagedPage() {
|
|||||||
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
<Activity className="w-5 h-5 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Cloud failover
|
{t(
|
||||||
|
"managedSelfHosted.benefitCloudFailover.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
If your node goes down, your tunnels
|
{t(
|
||||||
can temporarily fail over to our
|
"managedSelfHosted.benefitCloudFailover.description"
|
||||||
cloud points of presence until you
|
)}
|
||||||
bring it back online.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,12 +114,14 @@ export default async function ManagedPage() {
|
|||||||
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
|
<Shield className="w-5 h-5 text-indigo-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
High availability (PoPs)
|
{t(
|
||||||
|
"managedSelfHosted.benefitHighAvailability.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You can also attach multiple nodes
|
{t(
|
||||||
to your account for redundancy and
|
"managedSelfHosted.benefitHighAvailability.description"
|
||||||
better performance.
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,13 +130,14 @@ export default async function ManagedPage() {
|
|||||||
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
<Zap className="w-5 h-5 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">
|
<h4 className="font-medium">
|
||||||
Future enhancements
|
{t(
|
||||||
|
"managedSelfHosted.benefitFutureEnhancements.title"
|
||||||
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
We're planning to add more
|
{t(
|
||||||
analytics, alerting, and management
|
"managedSelfHosted.benefitFutureEnhancements.description"
|
||||||
tools to make your deployment even
|
)}
|
||||||
more robust.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
|
|||||||
variant="neutral"
|
variant="neutral"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Read the docs to learn more about the Managed
|
{t("managedSelfHosted.docsAlert.text")}{" "}
|
||||||
Self-Hosted option in our{" "}
|
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
href="https://docs.digpangolin.com/manage/managed"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline text-primary flex items-center gap-1"
|
className="hover:underline text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
documentation
|
{t("managedSelfHosted.docsAlert.documentation")}
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
href="https://docs.digpangolin.com/self-host/convert-managed"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline text-primary flex items-center gap-1"
|
className="hover:underline text-primary flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Button>
|
<Button>
|
||||||
Convert This Node to Managed Self-Hosted
|
{t("managedSelfHosted.convertButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
|
|||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
error = formatAxiosError(e);
|
error = formatAxiosError(e);
|
||||||
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cardType() {
|
function cardType() {
|
||||||
if (error.includes(t('inviteErrorWrongUser'))) {
|
if (error.includes("Invite is not for this user")) {
|
||||||
return "wrong_user";
|
return "wrong_user";
|
||||||
} else if (
|
} else if (
|
||||||
error.includes(t('inviteErrorUserNotExists'))
|
error.includes("User does not exist. Please create an account first.")
|
||||||
) {
|
) {
|
||||||
return "user_does_not_exist";
|
return "user_does_not_exist";
|
||||||
} else if (error.includes(t('inviteErrorLoginRequired'))) {
|
} else if (error.includes("You must be logged in to accept an invite")) {
|
||||||
return "not_logged_in";
|
return "not_logged_in";
|
||||||
} else {
|
} else {
|
||||||
return "rejected";
|
return "rejected";
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
sanitizeInputRaw,
|
||||||
|
finalizeSubdomainSanitize,
|
||||||
|
validateByDomainType,
|
||||||
|
isValidSubdomainStructure
|
||||||
|
} from "@/lib/subdomain-utils";
|
||||||
|
import { toUnicode } from "punycode";
|
||||||
|
|
||||||
type OrganizationDomain = {
|
type OrganizationDomain = {
|
||||||
domainId: string;
|
domainId: string;
|
||||||
@@ -120,6 +127,7 @@ export default function DomainPicker2({
|
|||||||
)
|
)
|
||||||
.map((domain) => ({
|
.map((domain) => ({
|
||||||
...domain,
|
...domain,
|
||||||
|
baseDomain: toUnicode(domain.baseDomain),
|
||||||
type: domain.type as "ns" | "cname" | "wildcard"
|
type: domain.type as "ns" | "cname" | "wildcard"
|
||||||
}));
|
}));
|
||||||
setOrganizationDomains(domains);
|
setOrganizationDomains(domains);
|
||||||
@@ -255,108 +263,64 @@ export default function DomainPicker2({
|
|||||||
|
|
||||||
const dropdownOptions = generateDropdownOptions();
|
const dropdownOptions = generateDropdownOptions();
|
||||||
|
|
||||||
const validateSubdomain = (
|
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||||
subdomain: string,
|
const sanitized = finalizeSubdomainSanitize(sub);
|
||||||
baseDomain: DomainOption
|
|
||||||
): boolean => {
|
|
||||||
if (!baseDomain) return false;
|
|
||||||
|
|
||||||
if (baseDomain.type === "provided-search") {
|
if (!sanitized) {
|
||||||
return /^[a-zA-Z0-9-]+$/.test(subdomain);
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Invalid subdomain",
|
||||||
|
description: `The input "${sub}" was removed because it's not valid.`,
|
||||||
|
});
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseDomain.type === "organization") {
|
const ok = validateByDomainType(sanitized, {
|
||||||
if (baseDomain.domainType === "cname") {
|
type: base.type === "provided-search" ? "provided-search" : "organization",
|
||||||
return subdomain === "";
|
domainType: base.domainType
|
||||||
} else if (baseDomain.domainType === "ns") {
|
});
|
||||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
|
||||||
} else if (baseDomain.domainType === "wildcard") {
|
if (!ok) {
|
||||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
toast({
|
||||||
}
|
variant: "destructive",
|
||||||
|
title: "Invalid subdomain",
|
||||||
|
description: `"${sub}" could not be made valid for ${base.domain}.`,
|
||||||
|
});
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if (sub !== sanitized) {
|
||||||
};
|
toast({
|
||||||
|
title: "Subdomain sanitized",
|
||||||
// Handle base domain selection
|
description: `"${sub}" was corrected to "${sanitized}"`,
|
||||||
const handleBaseDomainSelect = (option: DomainOption) => {
|
|
||||||
setSelectedBaseDomain(option);
|
|
||||||
setOpen(false);
|
|
||||||
|
|
||||||
if (option.domainType === "cname") {
|
|
||||||
setSubdomainInput("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.type === "provided-search") {
|
|
||||||
setUserInput("");
|
|
||||||
setAvailableOptions([]);
|
|
||||||
setSelectedProvidedDomain(null);
|
|
||||||
onDomainChange?.({
|
|
||||||
domainId: option.domainId!,
|
|
||||||
type: "organization",
|
|
||||||
subdomain: undefined,
|
|
||||||
fullDomain: option.domain,
|
|
||||||
baseDomain: option.domain
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (option.type === "organization") {
|
return sanitized;
|
||||||
if (option.domainType === "cname") {
|
|
||||||
onDomainChange?.({
|
|
||||||
domainId: option.domainId!,
|
|
||||||
type: "organization",
|
|
||||||
subdomain: undefined,
|
|
||||||
fullDomain: option.domain,
|
|
||||||
baseDomain: option.domain
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onDomainChange?.({
|
|
||||||
domainId: option.domainId!,
|
|
||||||
type: "organization",
|
|
||||||
subdomain: undefined,
|
|
||||||
fullDomain: option.domain,
|
|
||||||
baseDomain: option.domain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubdomainChange = (value: string) => {
|
const handleSubdomainChange = (value: string) => {
|
||||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
const raw = sanitizeInputRaw(value);
|
||||||
setSubdomainInput(validInput);
|
setSubdomainInput(raw);
|
||||||
|
|
||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
|
|
||||||
if (selectedBaseDomain && selectedBaseDomain.type === "organization") {
|
if (selectedBaseDomain?.type === "organization") {
|
||||||
const isValid = validateSubdomain(validInput, selectedBaseDomain);
|
const fullDomain = raw
|
||||||
if (isValid) {
|
? `${raw}.${selectedBaseDomain.domain}`
|
||||||
const fullDomain = validInput
|
: selectedBaseDomain.domain;
|
||||||
? `${validInput}.${selectedBaseDomain.domain}`
|
|
||||||
: selectedBaseDomain.domain;
|
onDomainChange?.({
|
||||||
onDomainChange?.({
|
domainId: selectedBaseDomain.domainId!,
|
||||||
domainId: selectedBaseDomain.domainId!,
|
type: "organization",
|
||||||
type: "organization",
|
subdomain: raw || undefined,
|
||||||
subdomain: validInput || undefined,
|
fullDomain,
|
||||||
fullDomain: fullDomain,
|
baseDomain: selectedBaseDomain.domain
|
||||||
baseDomain: selectedBaseDomain.domain
|
});
|
||||||
});
|
|
||||||
} else if (validInput === "") {
|
|
||||||
onDomainChange?.({
|
|
||||||
domainId: selectedBaseDomain.domainId!,
|
|
||||||
type: "organization",
|
|
||||||
subdomain: undefined,
|
|
||||||
fullDomain: selectedBaseDomain.domain,
|
|
||||||
baseDomain: selectedBaseDomain.domain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProvidedDomainInputChange = (value: string) => {
|
const handleProvidedDomainInputChange = (value: string) => {
|
||||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
setUserInput(value);
|
||||||
setUserInput(validInput);
|
|
||||||
|
|
||||||
// Clear selected domain when user types
|
|
||||||
if (selectedProvidedDomain) {
|
if (selectedProvidedDomain) {
|
||||||
setSelectedProvidedDomain(null);
|
setSelectedProvidedDomain(null);
|
||||||
onDomainChange?.({
|
onDomainChange?.({
|
||||||
@@ -369,6 +333,43 @@ export default function DomainPicker2({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBaseDomainSelect = (option: DomainOption) => {
|
||||||
|
let sub = subdomainInput;
|
||||||
|
|
||||||
|
if (sub && sub.trim() !== "") {
|
||||||
|
sub = finalizeSubdomain(sub, option) || "";
|
||||||
|
setSubdomainInput(sub);
|
||||||
|
} else {
|
||||||
|
sub = "";
|
||||||
|
setSubdomainInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (option.type === "provided-search") {
|
||||||
|
setUserInput("");
|
||||||
|
setAvailableOptions([]);
|
||||||
|
setSelectedProvidedDomain(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedBaseDomain(option);
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (option.domainType === "cname") {
|
||||||
|
sub = "";
|
||||||
|
setSubdomainInput("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
||||||
|
|
||||||
|
onDomainChange?.({
|
||||||
|
domainId: option.domainId || "",
|
||||||
|
domainNamespaceId: option.domainNamespaceId,
|
||||||
|
type: option.type === "provided-search" ? "provided" : "organization",
|
||||||
|
subdomain: sub || undefined,
|
||||||
|
fullDomain,
|
||||||
|
baseDomain: option.domain
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleProvidedDomainSelect = (option: AvailableOption) => {
|
const handleProvidedDomainSelect = (option: AvailableOption) => {
|
||||||
setSelectedProvidedDomain(option);
|
setSelectedProvidedDomain(option);
|
||||||
|
|
||||||
@@ -380,15 +381,19 @@ export default function DomainPicker2({
|
|||||||
domainId: option.domainId,
|
domainId: option.domainId,
|
||||||
domainNamespaceId: option.domainNamespaceId,
|
domainNamespaceId: option.domainNamespaceId,
|
||||||
type: "provided",
|
type: "provided",
|
||||||
subdomain: subdomain,
|
subdomain,
|
||||||
fullDomain: option.fullDomain,
|
fullDomain: option.fullDomain,
|
||||||
baseDomain: baseDomain
|
baseDomain
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubdomainValid = selectedBaseDomain
|
const isSubdomainValid = selectedBaseDomain && subdomainInput
|
||||||
? validateSubdomain(subdomainInput, selectedBaseDomain)
|
? validateByDomainType(subdomainInput, {
|
||||||
|
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
|
||||||
|
domainType: selectedBaseDomain.domainType
|
||||||
|
})
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
const showSubdomainInput =
|
const showSubdomainInput =
|
||||||
selectedBaseDomain &&
|
selectedBaseDomain &&
|
||||||
selectedBaseDomain.type === "organization" &&
|
selectedBaseDomain.type === "organization" &&
|
||||||
@@ -396,7 +401,7 @@ export default function DomainPicker2({
|
|||||||
const showProvidedDomainSearch =
|
const showProvidedDomainSearch =
|
||||||
selectedBaseDomain?.type === "provided-search";
|
selectedBaseDomain?.type === "provided-search";
|
||||||
|
|
||||||
const sortedAvailableOptions = availableOptions.sort((a, b) => {
|
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
|
||||||
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
||||||
return sortOrder === "asc" ? comparison : -comparison;
|
return sortOrder === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
@@ -408,6 +413,7 @@ export default function DomainPicker2({
|
|||||||
const hasMoreProvided =
|
const hasMoreProvided =
|
||||||
sortedAvailableOptions.length > providedDomainsShown;
|
sortedAvailableOptions.length > providedDomainsShown;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
@@ -426,16 +432,16 @@ export default function DomainPicker2({
|
|||||||
showProvidedDomainSearch
|
showProvidedDomainSearch
|
||||||
? ""
|
? ""
|
||||||
: showSubdomainInput
|
: showSubdomainInput
|
||||||
? ""
|
? ""
|
||||||
: t("domainPickerNotAvailableForCname")
|
: t("domainPickerNotAvailableForCname")
|
||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
!showSubdomainInput && !showProvidedDomainSearch
|
!showSubdomainInput && !showProvidedDomainSearch
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
!isSubdomainValid &&
|
!isSubdomainValid &&
|
||||||
subdomainInput &&
|
subdomainInput &&
|
||||||
"border-red-500"
|
"border-red-500 focus:border-red-500"
|
||||||
)}
|
)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (showProvidedDomainSearch) {
|
if (showProvidedDomainSearch) {
|
||||||
@@ -445,6 +451,11 @@ export default function DomainPicker2({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{showSubdomainInput && !subdomainInput && (
|
{showSubdomainInput && !subdomainInput && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||||
@@ -470,7 +481,7 @@ export default function DomainPicker2({
|
|||||||
{selectedBaseDomain ? (
|
{selectedBaseDomain ? (
|
||||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
{selectedBaseDomain.type ===
|
{selectedBaseDomain.type ===
|
||||||
"organization" ? null : (
|
"organization" ? null : (
|
||||||
<Zap className="h-4 w-4 flex-shrink-0" />
|
<Zap className="h-4 w-4 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
@@ -564,67 +575,67 @@ export default function DomainPicker2({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
{(build === "saas" ||
|
{(build === "saas" ||
|
||||||
build === "enterprise") && (
|
build === "enterprise") && (
|
||||||
<CommandSeparator className="my-2" />
|
<CommandSeparator className="my-2" />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(build === "saas" ||
|
{(build === "saas" ||
|
||||||
build === "enterprise") && (
|
build === "enterprise") && (
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={
|
heading={
|
||||||
build === "enterprise"
|
build === "enterprise"
|
||||||
? t(
|
? t(
|
||||||
"domainPickerProvidedDomains"
|
"domainPickerProvidedDomains"
|
||||||
)
|
)
|
||||||
: t("domainPickerFreeDomains")
|
: t("domainPickerFreeDomains")
|
||||||
}
|
}
|
||||||
className="py-2"
|
className="py-2"
|
||||||
>
|
>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key="provided-search"
|
key="provided-search"
|
||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleBaseDomainSelect({
|
handleBaseDomainSelect({
|
||||||
id: "provided-search",
|
id: "provided-search",
|
||||||
domain:
|
domain:
|
||||||
build ===
|
build ===
|
||||||
"enterprise"
|
"enterprise"
|
||||||
|
? "Provided Domain"
|
||||||
|
: "Free Provided Domain",
|
||||||
|
type: "provided-search"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mx-2 rounded-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
||||||
|
<Zap className="h-4 w-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{build === "enterprise"
|
||||||
? "Provided Domain"
|
? "Provided Domain"
|
||||||
: "Free Provided Domain",
|
: "Free Provided Domain"}
|
||||||
type: "provided-search"
|
</span>
|
||||||
})
|
<span className="text-xs text-muted-foreground">
|
||||||
}
|
{t(
|
||||||
className="mx-2 rounded-md"
|
"domainPickerSearchForAvailableDomains"
|
||||||
>
|
)}
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
</span>
|
||||||
<Zap className="h-4 w-4 text-primary" />
|
</div>
|
||||||
</div>
|
<Check
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
className={cn(
|
||||||
<span className="font-medium truncate">
|
"h-4 w-4 text-primary",
|
||||||
{build === "enterprise"
|
selectedBaseDomain?.id ===
|
||||||
? "Provided Domain"
|
"provided-search"
|
||||||
: "Free Provided Domain"}
|
? "opacity-100"
|
||||||
</span>
|
: "opacity-0"
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
"domainPickerSearchForAvailableDomains"
|
|
||||||
)}
|
)}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</CommandItem>
|
||||||
<Check
|
</CommandList>
|
||||||
className={cn(
|
</CommandGroup>
|
||||||
"h-4 w-4 text-primary",
|
)}
|
||||||
selectedBaseDomain?.id ===
|
|
||||||
"provided-search"
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -680,7 +691,7 @@ export default function DomainPicker2({
|
|||||||
htmlFor={option.domainNamespaceId}
|
htmlFor={option.domainNamespaceId}
|
||||||
data-state={
|
data-state={
|
||||||
selectedProvidedDomain?.domainNamespaceId ===
|
selectedProvidedDomain?.domainNamespaceId ===
|
||||||
option.domainNamespaceId
|
option.domainNamespaceId
|
||||||
? "checked"
|
? "checked"
|
||||||
: "unchecked"
|
: "unchecked"
|
||||||
}
|
}
|
||||||
@@ -760,4 +771,4 @@ function debounce<T extends (...args: any[]) => any>(
|
|||||||
func(...args);
|
func(...args);
|
||||||
}, wait);
|
}, wait);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
import SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { Menu, Server } from "lucide-react";
|
import { ExternalLink, Menu, Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
|
|||||||
<SupporterStatus />
|
<SupporterStatus />
|
||||||
{env?.app?.version && (
|
{env?.app?.version && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
v{env.app.version}
|
<Link
|
||||||
|
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
v{env.app.version}
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
import SupporterStatus from "@app/components/SupporterStatus";
|
||||||
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
||||||
|
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -151,7 +152,7 @@ export function LayoutSidebar({
|
|||||||
{!isUnlocked()
|
{!isUnlocked()
|
||||||
? t("communityEdition")
|
? t("communityEdition")
|
||||||
: t("commercialEdition")}
|
: t("commercialEdition")}
|
||||||
<ExternalLink size={12} />
|
<FaGithub size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground ">
|
<div className="text-xs text-muted-foreground ">
|
||||||
@@ -165,9 +166,28 @@ export function LayoutSidebar({
|
|||||||
<BookOpenText size={12} />
|
<BookOpenText size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
<Link
|
||||||
|
href="https://discord.gg/HCJR8Xhme4"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
<FaDiscord size={12} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
{env?.app?.version && (
|
{env?.app?.version && (
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
v{env.app.version}
|
<Link
|
||||||
|
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
v{env.app.version}
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t('actionUpdateOrg')]: "updateOrg",
|
[t('actionUpdateOrg')]: "updateOrg",
|
||||||
[t('actionGetOrgUser')]: "getOrgUser",
|
[t('actionGetOrgUser')]: "getOrgUser",
|
||||||
[t('actionInviteUser')]: "inviteUser",
|
[t('actionInviteUser')]: "inviteUser",
|
||||||
|
[t('actionListInvitations')]: "listInvitations",
|
||||||
[t('actionRemoveUser')]: "removeUser",
|
[t('actionRemoveUser')]: "removeUser",
|
||||||
[t('actionListUsers')]: "listUsers",
|
[t('actionListUsers')]: "listUsers",
|
||||||
[t('actionListOrgDomains')]: "listOrgDomains"
|
[t('actionListOrgDomains')]: "listOrgDomains"
|
||||||
|
|||||||
15
src/lib/parseHostTarget.ts
Normal file
15
src/lib/parseHostTarget.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function parseHostTarget(input: string) {
|
||||||
|
try {
|
||||||
|
const normalized = input.match(/^https?:\/\//) ? input : `http://${input}`;
|
||||||
|
const url = new URL(normalized);
|
||||||
|
|
||||||
|
const protocol = url.protocol.replace(":", ""); // http | https
|
||||||
|
const host = url.hostname;
|
||||||
|
const port = url.port ? parseInt(url.port, 10) : protocol === "https" ? 443 : 80;
|
||||||
|
|
||||||
|
return { protocol, host, port };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
src/lib/subdomain-utils.ts
Normal file
63
src/lib/subdomain-utils.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
export type DomainType = "organization" | "provided" | "provided-search";
|
||||||
|
|
||||||
|
export const SINGLE_LABEL_RE = /^[\p{L}\p{N}-]+$/u; // provided-search (no dots)
|
||||||
|
export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wildcard
|
||||||
|
export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
|
||||||
|
|
||||||
|
|
||||||
|
export function sanitizeInputRaw(input: string): string {
|
||||||
|
if (!input) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFC") // normalize Unicode
|
||||||
|
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
|
||||||
|
}
|
||||||
|
|
||||||
|
export function finalizeSubdomainSanitize(input: string): string {
|
||||||
|
if (!input) return "";
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/[^\p{L}\p{N}.-]/gu, "") // allow Unicode
|
||||||
|
.replace(/\.{2,}/g, ".") // collapse multiple dots
|
||||||
|
.replace(/^-+|-+$/g, "") // strip leading/trailing hyphens
|
||||||
|
.replace(/^\.+|\.+$/g, "") // strip leading/trailing dots
|
||||||
|
.replace(/(\.-)|(-\.)/g, "."); // fix illegal dot-hyphen combos
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function validateByDomainType(subdomain: string, domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard" } ): boolean {
|
||||||
|
if (!domainType) return false;
|
||||||
|
|
||||||
|
if (domainType.type === "provided-search") {
|
||||||
|
return SINGLE_LABEL_RE.test(subdomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domainType.type === "organization") {
|
||||||
|
if (domainType.domainType === "cname") {
|
||||||
|
return subdomain === "";
|
||||||
|
} else if (domainType.domainType === "ns" || domainType.domainType === "wildcard") {
|
||||||
|
if (subdomain === "") return true;
|
||||||
|
if (!MULTI_LABEL_RE.test(subdomain)) return false;
|
||||||
|
const labels = subdomain.split(".");
|
||||||
|
return labels.every(l => l.length >= 1 && l.length <= 63 && SINGLE_LABEL_RE.test(l));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const isValidSubdomainStructure = (input: string): boolean => {
|
||||||
|
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
|
||||||
|
|
||||||
|
if (!input) return false;
|
||||||
|
if (input.includes("..")) return false;
|
||||||
|
|
||||||
|
return input.split(".").every(label => regex.test(label));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user