mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-28 22:00:51 +00:00
@@ -13,6 +13,8 @@ managed:
|
||||
app:
|
||||
dashboard_url: "https://{{.DashboardDomain}}"
|
||||
log_level: "info"
|
||||
telemetry:
|
||||
anonymous_usage: true
|
||||
|
||||
domains:
|
||||
domain1:
|
||||
|
||||
@@ -1457,5 +1457,7 @@
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"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...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"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...",
|
||||
"autoLoginError": "Fehler bei der automatischen Anmeldung",
|
||||
"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",
|
||||
"actionListSiteResources": "List Site Resources",
|
||||
"actionUpdateSiteResource": "Update Site Resource",
|
||||
"actionListInvitations": "List Invitations",
|
||||
"noneSelected": "None selected",
|
||||
"orgNotFound2": "No organizations found.",
|
||||
"searchProgress": "Search...",
|
||||
@@ -1457,5 +1458,43 @@
|
||||
"autoLoginRedirecting": "Redirecting to login...",
|
||||
"autoLoginError": "Auto Login Error",
|
||||
"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...",
|
||||
"autoLoginError": "Error de inicio de sesión automático",
|
||||
"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...",
|
||||
"autoLoginError": "Erreur de connexion automatique",
|
||||
"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...",
|
||||
"autoLoginError": "Errore di Accesso Automatico",
|
||||
"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": "로그인으로 리디렉션 중...",
|
||||
"autoLoginError": "자동 로그인 오류",
|
||||
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
|
||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패."
|
||||
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
|
||||
"internationaldomaindetected": "국제 도메인 감지됨",
|
||||
"willbestoredas": "다음과 같이 저장됩니다."
|
||||
}
|
||||
|
||||
@@ -1457,5 +1457,7 @@
|
||||
"autoLoginRedirecting": "Redirecting naar inloggen...",
|
||||
"autoLoginError": "Auto Login Fout",
|
||||
"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...",
|
||||
"autoLoginError": "Błąd automatycznego logowania",
|
||||
"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...",
|
||||
"autoLoginError": "Erro de Login Automático",
|
||||
"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": "Перенаправление к входу...",
|
||||
"autoLoginError": "Ошибка автоматического входа",
|
||||
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
|
||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации."
|
||||
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
|
||||
"internationaldomaindetected": "Обнаружен международный домен",
|
||||
"willbestoredas": "Будет сохранен как:"
|
||||
}
|
||||
|
||||
@@ -1457,5 +1457,7 @@
|
||||
"autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...",
|
||||
"autoLoginError": "Otomatik Giriş Hatası",
|
||||
"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": "重定向到登录...",
|
||||
"autoLoginError": "自动登录错误",
|
||||
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
|
||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。"
|
||||
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
|
||||
"internationaldomaindetected": "检测到国际域名",
|
||||
"willbestoredas": "将存储为:"
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export const configSchema = z
|
||||
.default("/var/dynamic/router_config.yml"),
|
||||
static_domains: z.array(z.string()).optional().default([]),
|
||||
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)
|
||||
})
|
||||
.optional()
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function getValidCertificatesForDomainsHybrid(domains: Set<string>)
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -68,6 +69,7 @@ export async function getValidCertificatesForDomains(domains: Set<string>): Prom
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
export const subdomainSchema = z
|
||||
.string()
|
||||
.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"
|
||||
)
|
||||
.min(1, "Subdomain must be at least 1 character long")
|
||||
@@ -12,7 +12,8 @@ export const subdomainSchema = z
|
||||
export const tlsNameSchema = z
|
||||
.string()
|
||||
.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"
|
||||
)
|
||||
.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;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean | null;
|
||||
}
|
||||
>();
|
||||
|
||||
@@ -115,6 +116,7 @@ export class TraefikConfigManager {
|
||||
exists: boolean;
|
||||
lastModified: Date | null;
|
||||
expiresAt: Date | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
>
|
||||
> {
|
||||
@@ -136,13 +138,16 @@ export class TraefikConfigManager {
|
||||
const certPath = path.join(domainDir, "cert.pem");
|
||||
const keyPath = path.join(domainDir, "key.pem");
|
||||
const lastUpdatePath = path.join(domainDir, ".last_update");
|
||||
const wildcardPath = path.join(domainDir, ".wildcard");
|
||||
|
||||
const certExists = await this.fileExists(certPath);
|
||||
const keyExists = await this.fileExists(keyPath);
|
||||
const lastUpdateExists = await this.fileExists(lastUpdatePath);
|
||||
const wildcardExists = await this.fileExists(wildcardPath);
|
||||
|
||||
let lastModified: Date | null = null;
|
||||
const expiresAt: Date | null = null;
|
||||
let wildcard = false;
|
||||
|
||||
if (lastUpdateExists) {
|
||||
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, {
|
||||
exists: certExists && keyExists,
|
||||
lastModified,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
wildcard
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -192,19 +213,36 @@ export class TraefikConfigManager {
|
||||
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 (
|
||||
this.lastKnownDomains.size !== currentDomains.size ||
|
||||
!Array.from(this.lastKnownDomains).every((domain) =>
|
||||
currentDomains.has(domain)
|
||||
domainsNeedingCerts.size !== lastDomainsNeedingCerts.size ||
|
||||
!Array.from(domainsNeedingCerts).every((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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (!localState || !localState.exists) {
|
||||
logger.info(
|
||||
@@ -273,6 +311,7 @@ export class TraefikConfigManager {
|
||||
let validCertificates: Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -280,23 +319,50 @@ export class TraefikConfigManager {
|
||||
}> = [];
|
||||
|
||||
if (this.shouldFetchCertificates(domains)) {
|
||||
// Get valid certificates for active domains
|
||||
// Filter out domains that are already covered by wildcard certificates
|
||||
const domainsToFetch = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
if (!isDomainCoveredByWildcard(domain, this.lastLocalCertificateState)) {
|
||||
domainsToFetch.add(domain);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Domain ${domain} is covered by existing wildcard certificate, skipping fetch`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (domainsToFetch.size > 0) {
|
||||
// Get valid certificates for domains not covered by wildcards
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(domains);
|
||||
await getValidCertificatesForDomainsHybrid(
|
||||
domainsToFetch
|
||||
);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(domains);
|
||||
await getValidCertificatesForDomains(
|
||||
domainsToFetch
|
||||
);
|
||||
}
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
logger.info(
|
||||
`Fetched ${validCertificates.length} certificates from remote`
|
||||
`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 {
|
||||
const timeSinceLastFetch = this.lastCertificateFetch
|
||||
? Math.round(
|
||||
@@ -544,7 +610,11 @@ export class TraefikConfigManager {
|
||||
// Clear existing certificates and rebuild from local state
|
||||
dynamicConfig.tls.certificates = [];
|
||||
|
||||
// Keep track of certificates we've already added to avoid duplicates
|
||||
const addedCertPaths = new Set<string>();
|
||||
|
||||
for (const domain of domains) {
|
||||
// First, try to find an exact match certificate
|
||||
const localState = this.lastLocalCertificateState.get(domain);
|
||||
if (localState && localState.exists) {
|
||||
const domainDir = path.join(
|
||||
@@ -554,11 +624,47 @@ export class TraefikConfigManager {
|
||||
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);
|
||||
}
|
||||
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<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -651,15 +758,24 @@ export class TraefikConfigManager {
|
||||
"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(
|
||||
`Certificate updated for domain: ${cert.domain}`
|
||||
`Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}`
|
||||
);
|
||||
|
||||
// Update local state tracking
|
||||
this.lastLocalCertificateState.set(cert.domain, {
|
||||
exists: true,
|
||||
lastModified: new Date(),
|
||||
expiresAt: cert.expiresAt
|
||||
expiresAt: cert.expiresAt,
|
||||
wildcard: cert.wildcard
|
||||
});
|
||||
}
|
||||
|
||||
@@ -810,14 +926,8 @@ export class TraefikConfigManager {
|
||||
this.lastLocalCertificateState.delete(dirName);
|
||||
|
||||
// Remove from dynamic config
|
||||
const certFilePath = path.join(
|
||||
domainDir,
|
||||
"cert.pem"
|
||||
);
|
||||
const keyFilePath = path.join(
|
||||
domainDir,
|
||||
"key.pem"
|
||||
);
|
||||
const certFilePath = path.join(domainDir, "cert.pem");
|
||||
const keyFilePath = path.join(domainDir, "key.pem");
|
||||
const before = dynamicConfig.tls.certificates.length;
|
||||
dynamicConfig.tls.certificates =
|
||||
dynamicConfig.tls.certificates.filter(
|
||||
@@ -894,14 +1004,58 @@ export class TraefikConfigManager {
|
||||
monitorInterval: number;
|
||||
lastCertificateFetch: Date | null;
|
||||
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 {
|
||||
isRunning: this.isRunning,
|
||||
activeDomains: Array.from(this.activeDomains),
|
||||
monitorInterval:
|
||||
config.getRawConfig().traefik.monitor_interval || 5000,
|
||||
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]);
|
||||
|
||||
if (allRoleIds.length === 0) {
|
||||
|
||||
@@ -82,6 +82,7 @@ authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
);
|
||||
|
||||
|
||||
@@ -221,6 +221,13 @@ authenticated.get(
|
||||
domain.listDomains
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/invitations",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listInvitations),
|
||||
user.listInvitations
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -49,19 +49,7 @@ export async function deleteOrg(
|
||||
}
|
||||
|
||||
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
|
||||
.select()
|
||||
.from(orgs)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, resources } from "@server/db";
|
||||
import { apiKeys, roleResources, roles } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -74,13 +74,18 @@ export async function setResourceRoles(
|
||||
|
||||
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(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Organization not found"
|
||||
"Resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -92,7 +97,7 @@ export async function setResourceRoles(
|
||||
.where(
|
||||
and(
|
||||
eq(roles.name, "Admin"),
|
||||
eq(roles.orgId, orgId)
|
||||
eq(roles.orgId, resource.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function createSite(
|
||||
type,
|
||||
dockerSocketEnabled: false,
|
||||
online: true,
|
||||
subnet: "0.0.0.0/0"
|
||||
subnet: "0.0.0.0/32"
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from "express";
|
||||
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 HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
@@ -149,7 +149,10 @@ export async function getTraefikConfig(
|
||||
eq(sites.exitNodeId, 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(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID")
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.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);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
@@ -93,7 +98,7 @@ export async function addUserRole(
|
||||
const roleExists = await db
|
||||
.select()
|
||||
.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);
|
||||
|
||||
if (roleExists.length === 0) {
|
||||
@@ -108,7 +113,7 @@ export async function addUserRole(
|
||||
const newUserRole = await db
|
||||
.update(userOrgs)
|
||||
.set({ roleId })
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)))
|
||||
.returning();
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
FormMessage,
|
||||
FormDescription
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useToast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -33,7 +34,7 @@ import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { AxiosResponse } from "axios";
|
||||
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 {
|
||||
InfoSection,
|
||||
@@ -43,9 +44,58 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
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({
|
||||
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"])
|
||||
});
|
||||
|
||||
@@ -109,8 +159,14 @@ export default function CreateDomainForm({
|
||||
}
|
||||
}
|
||||
|
||||
const domainType = form.watch("type");
|
||||
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 = [];
|
||||
if (build == "enterprise" || build == "saas") {
|
||||
@@ -182,10 +238,23 @@ export default function CreateDomainForm({
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
placeholder="example.com, café.com, 日本.com"
|
||||
{...field}
|
||||
/>
|
||||
</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 />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -234,9 +303,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{baseDomain}
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(baseDomain) !== baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
@@ -307,11 +383,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
cnameRecord.baseDomain
|
||||
}
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(cnameRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({cnameRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -374,11 +455,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
aRecord.baseDomain
|
||||
}
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(aRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({aRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -440,11 +526,16 @@ export default function CreateDomainForm({
|
||||
"createDomainName"
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono">
|
||||
{
|
||||
txtRecord.baseDomain
|
||||
}
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-mono block">
|
||||
{fromPunycode(txtRecord.baseDomain)}
|
||||
</span>
|
||||
{fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({txtRecord.baseDomain})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||
import { redirect } from "next/navigation";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -22,7 +23,13 @@ export default async function DomainsPage(props: Props) {
|
||||
const res = await internal.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/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) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
interface DomainOption {
|
||||
baseDomain: string;
|
||||
@@ -91,7 +92,7 @@ export default function CustomDomainInput({
|
||||
key={option.domainId}
|
||||
value={option.domainId}
|
||||
>
|
||||
.{option.baseDomain}
|
||||
.{toUnicode(option.baseDomain)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -12,15 +12,19 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
const { resource, authInfo } = useResourceContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
|
||||
@@ -53,6 +53,9 @@ import {
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { Globe } from "lucide-react";
|
||||
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() {
|
||||
const [formKey, setFormKey] = useState(0);
|
||||
@@ -79,12 +82,13 @@ export default function GeneralForm() {
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
`${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`
|
||||
);
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null>(null);
|
||||
|
||||
const GeneralFormSchema = z
|
||||
@@ -153,7 +157,11 @@ export default function GeneralForm() {
|
||||
});
|
||||
|
||||
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);
|
||||
setFormKey((key) => key + 1);
|
||||
}
|
||||
@@ -178,7 +186,7 @@ export default function GeneralForm() {
|
||||
{
|
||||
enabled: data.enabled,
|
||||
name: data.name,
|
||||
subdomain: data.subdomain,
|
||||
subdomain: data.subdomain ? toASCII(data.subdomain) : undefined,
|
||||
domainId: data.domainId,
|
||||
proxyPort: data.proxyPort,
|
||||
// ...(!resource.http && {
|
||||
@@ -441,7 +449,8 @@ export default function GeneralForm() {
|
||||
const selected = {
|
||||
domainId: res.domainId,
|
||||
subdomain: res.subdomain,
|
||||
fullDomain: res.fullDomain
|
||||
fullDomain: res.fullDomain,
|
||||
baseDomain: res.baseDomain
|
||||
};
|
||||
setSelectedDomain(selected);
|
||||
}}
|
||||
@@ -454,18 +463,23 @@ export default function GeneralForm() {
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedDomain) {
|
||||
setResourceFullDomain(
|
||||
selectedDomain.fullDomain
|
||||
);
|
||||
form.setValue(
|
||||
"domainId",
|
||||
selectedDomain.domainId
|
||||
);
|
||||
form.setValue(
|
||||
"subdomain",
|
||||
selectedDomain.subdomain
|
||||
);
|
||||
const sanitizedSubdomain = selectedDomain.subdomain
|
||||
? finalizeSubdomainSanitize(selectedDomain.subdomain)
|
||||
: "";
|
||||
|
||||
const sanitizedFullDomain = sanitizedSubdomain
|
||||
? `${sanitizedSubdomain}.${selectedDomain.baseDomain}`
|
||||
: selectedDomain.baseDomain;
|
||||
|
||||
setResourceFullDomain(sanitizedFullDomain);
|
||||
form.setValue("domainId", selectedDomain.domainId);
|
||||
form.setValue("subdomain", sanitizedSubdomain);
|
||||
|
||||
setEditDomainOpen(false);
|
||||
|
||||
toast({
|
||||
description: `Final domain: ${sanitizedFullDomain}`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -94,6 +94,7 @@ import {
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { parseHostTarget } from "@app/lib/parseHostTarget";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
@@ -647,13 +648,25 @@ export default function ReverseProxyTargets(props: {
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
className="min-w-[150px]"
|
||||
onBlur={(e) =>
|
||||
onBlur={(e) => {
|
||||
const parsed = parseHostTarget(e.target.value);
|
||||
if (parsed) {
|
||||
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
|
||||
})
|
||||
});
|
||||
}
|
||||
}}
|
||||
|
||||
/>
|
||||
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -942,11 +955,22 @@ export default function ReverseProxyTargets(props: {
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>
|
||||
{t("targetAddr")}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -88,6 +88,9 @@ import { ArrayElement } from "@server/types/ArrayElement";
|
||||
import { isTargetValid } from "@server/lib/validators";
|
||||
import { ListTargetsResponse } from "@server/routers/target";
|
||||
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({
|
||||
name: z.string().min(1).max(255),
|
||||
@@ -326,7 +329,7 @@ export default function Page() {
|
||||
if (isHttp) {
|
||||
const httpData = httpForm.getValues();
|
||||
Object.assign(payload, {
|
||||
subdomain: httpData.subdomain,
|
||||
subdomain: httpData.subdomain ? toASCII(httpData.subdomain) : undefined,
|
||||
domainId: httpData.domainId,
|
||||
protocol: "tcp"
|
||||
});
|
||||
@@ -468,7 +471,11 @@ export default function Page() {
|
||||
});
|
||||
|
||||
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);
|
||||
// if (domains.length) {
|
||||
// httpForm.setValue("domainId", domains[0].domainId);
|
||||
@@ -622,12 +629,23 @@ export default function Page() {
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
className="min-w-[150px]"
|
||||
onBlur={(e) =>
|
||||
onBlur={(e) => {
|
||||
const parsed = parseHostTarget(e.target.value);
|
||||
|
||||
if (parsed) {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value
|
||||
})
|
||||
method: parsed.protocol,
|
||||
ip: parsed.host,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
});
|
||||
} else {
|
||||
updateTarget(row.original.targetId, {
|
||||
...row.original,
|
||||
ip: e.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1176,21 +1194,25 @@ export default function Page() {
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={
|
||||
addTargetForm.control
|
||||
}
|
||||
control={addTargetForm.control}
|
||||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"targetAddr"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormLabel>{t("targetAddr")}</FormLabel>
|
||||
<FormControl>
|
||||
<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>
|
||||
<FormMessage />
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -75,7 +76,9 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
id: resource.resourceId,
|
||||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
|
||||
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "./AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from 'punycode';
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -159,7 +160,7 @@ export default function CreateShareLinkForm({
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
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 {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -18,30 +20,27 @@ import {
|
||||
ExternalLink
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ManagedPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
export default async function ManagedPage() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Managed Self-Hosted"
|
||||
description="More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles"
|
||||
title={t("managedSelfHosted.title")}
|
||||
description={t("managedSelfHosted.description")}
|
||||
/>
|
||||
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionBody>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
<strong>Managed Self-Hosted Pangolin</strong> is a
|
||||
deployment option designed for people who want
|
||||
simplicity and extra reliability while still keeping
|
||||
their data private and self-hosted.
|
||||
<strong>{t("managedSelfHosted.introTitle")}</strong>{" "}
|
||||
{t("managedSelfHosted.introDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
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:
|
||||
{t("managedSelfHosted.introDetail")}
|
||||
</p>
|
||||
|
||||
<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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Simpler operations
|
||||
{t(
|
||||
"managedSelfHosted.benefitSimplerOperations.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
{t(
|
||||
"managedSelfHosted.benefitSimplerOperations.description"
|
||||
)}
|
||||
</p>
|
||||
</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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Automatic updates
|
||||
{t(
|
||||
"managedSelfHosted.benefitAutomaticUpdates.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The cloud dashboard evolves quickly,
|
||||
so you get new features and bug
|
||||
fixes without having to manually
|
||||
pull new containers every time.
|
||||
{t(
|
||||
"managedSelfHosted.benefitAutomaticUpdates.description"
|
||||
)}
|
||||
</p>
|
||||
</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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Less maintenance
|
||||
{t(
|
||||
"managedSelfHosted.benefitLessMaintenance.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No database migrations, backups, or
|
||||
extra infrastructure to manage. We
|
||||
handle that in the cloud.
|
||||
{t(
|
||||
"managedSelfHosted.benefitLessMaintenance.description"
|
||||
)}
|
||||
</p>
|
||||
</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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Cloud failover
|
||||
{t(
|
||||
"managedSelfHosted.benefitCloudFailover.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If your node goes down, your tunnels
|
||||
can temporarily fail over to our
|
||||
cloud points of presence until you
|
||||
bring it back online.
|
||||
{t(
|
||||
"managedSelfHosted.benefitCloudFailover.description"
|
||||
)}
|
||||
</p>
|
||||
</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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
High availability (PoPs)
|
||||
{t(
|
||||
"managedSelfHosted.benefitHighAvailability.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also attach multiple nodes
|
||||
to your account for redundancy and
|
||||
better performance.
|
||||
{t(
|
||||
"managedSelfHosted.benefitHighAvailability.description"
|
||||
)}
|
||||
</p>
|
||||
</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" />
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Future enhancements
|
||||
{t(
|
||||
"managedSelfHosted.benefitFutureEnhancements.title"
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We're planning to add more
|
||||
analytics, alerting, and management
|
||||
tools to make your deployment even
|
||||
more robust.
|
||||
{t(
|
||||
"managedSelfHosted.benefitFutureEnhancements.description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,15 +148,14 @@ export default async function ManagedPage() {
|
||||
variant="neutral"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
Read the docs to learn more about the Managed
|
||||
Self-Hosted option in our{" "}
|
||||
{t("managedSelfHosted.docsAlert.text")}{" "}
|
||||
<Link
|
||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
||||
href="https://docs.digpangolin.com/manage/managed"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary flex items-center gap-1"
|
||||
>
|
||||
documentation
|
||||
{t("managedSelfHosted.docsAlert.documentation")}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Link>
|
||||
.
|
||||
@@ -157,13 +163,13 @@ export default async function ManagedPage() {
|
||||
</SettingsSectionBody>
|
||||
<SettingsSectionFooter>
|
||||
<Link
|
||||
href="https://docs.digpangolin.com/self-host/advanced/convert-to-managed"
|
||||
href="https://docs.digpangolin.com/self-host/convert-managed"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-primary flex items-center gap-1"
|
||||
>
|
||||
<Button>
|
||||
Convert This Node to Managed Self-Hosted
|
||||
{t("managedSelfHosted.convertButton")}
|
||||
</Button>
|
||||
</Link>
|
||||
</SettingsSectionFooter>
|
||||
|
||||
@@ -48,6 +48,7 @@ export default async function InvitePage(props: {
|
||||
)
|
||||
.catch((e) => {
|
||||
error = formatAxiosError(e);
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
@@ -55,13 +56,13 @@ export default async function InvitePage(props: {
|
||||
}
|
||||
|
||||
function cardType() {
|
||||
if (error.includes(t('inviteErrorWrongUser'))) {
|
||||
if (error.includes("Invite is not for this user")) {
|
||||
return "wrong_user";
|
||||
} else if (
|
||||
error.includes(t('inviteErrorUserNotExists'))
|
||||
error.includes("User does not exist. Please create an account first.")
|
||||
) {
|
||||
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";
|
||||
} else {
|
||||
return "rejected";
|
||||
|
||||
@@ -37,6 +37,13 @@ import { cn } from "@/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
sanitizeInputRaw,
|
||||
finalizeSubdomainSanitize,
|
||||
validateByDomainType,
|
||||
isValidSubdomainStructure
|
||||
} from "@/lib/subdomain-utils";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type OrganizationDomain = {
|
||||
domainId: string;
|
||||
@@ -120,6 +127,7 @@ export default function DomainPicker2({
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
type: domain.type as "ns" | "cname" | "wildcard"
|
||||
}));
|
||||
setOrganizationDomains(domains);
|
||||
@@ -255,108 +263,64 @@ export default function DomainPicker2({
|
||||
|
||||
const dropdownOptions = generateDropdownOptions();
|
||||
|
||||
const validateSubdomain = (
|
||||
subdomain: string,
|
||||
baseDomain: DomainOption
|
||||
): boolean => {
|
||||
if (!baseDomain) return false;
|
||||
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||
const sanitized = finalizeSubdomainSanitize(sub);
|
||||
|
||||
if (baseDomain.type === "provided-search") {
|
||||
return /^[a-zA-Z0-9-]+$/.test(subdomain);
|
||||
if (!sanitized) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid subdomain",
|
||||
description: `The input "${sub}" was removed because it's not valid.`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
if (baseDomain.type === "organization") {
|
||||
if (baseDomain.domainType === "cname") {
|
||||
return subdomain === "";
|
||||
} else if (baseDomain.domainType === "ns") {
|
||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
||||
} else if (baseDomain.domainType === "wildcard") {
|
||||
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(subdomain);
|
||||
}
|
||||
const ok = validateByDomainType(sanitized, {
|
||||
type: base.type === "provided-search" ? "provided-search" : "organization",
|
||||
domainType: base.domainType
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid subdomain",
|
||||
description: `"${sub}" could not be made valid for ${base.domain}.`,
|
||||
});
|
||||
return "";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle base domain selection
|
||||
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 (sub !== sanitized) {
|
||||
toast({
|
||||
title: "Subdomain sanitized",
|
||||
description: `"${sub}" was corrected to "${sanitized}"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (option.type === "organization") {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const handleSubdomainChange = (value: string) => {
|
||||
const validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
||||
setSubdomainInput(validInput);
|
||||
|
||||
const raw = sanitizeInputRaw(value);
|
||||
setSubdomainInput(raw);
|
||||
setSelectedProvidedDomain(null);
|
||||
|
||||
if (selectedBaseDomain && selectedBaseDomain.type === "organization") {
|
||||
const isValid = validateSubdomain(validInput, selectedBaseDomain);
|
||||
if (isValid) {
|
||||
const fullDomain = validInput
|
||||
? `${validInput}.${selectedBaseDomain.domain}`
|
||||
if (selectedBaseDomain?.type === "organization") {
|
||||
const fullDomain = raw
|
||||
? `${raw}.${selectedBaseDomain.domain}`
|
||||
: selectedBaseDomain.domain;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: selectedBaseDomain.domainId!,
|
||||
type: "organization",
|
||||
subdomain: validInput || undefined,
|
||||
fullDomain: fullDomain,
|
||||
subdomain: raw || undefined,
|
||||
fullDomain,
|
||||
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 validInput = value.replace(/[^a-zA-Z0-9.-]/g, "");
|
||||
setUserInput(validInput);
|
||||
|
||||
// Clear selected domain when user types
|
||||
setUserInput(value);
|
||||
if (selectedProvidedDomain) {
|
||||
setSelectedProvidedDomain(null);
|
||||
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) => {
|
||||
setSelectedProvidedDomain(option);
|
||||
|
||||
@@ -380,15 +381,19 @@ export default function DomainPicker2({
|
||||
domainId: option.domainId,
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: "provided",
|
||||
subdomain: subdomain,
|
||||
subdomain,
|
||||
fullDomain: option.fullDomain,
|
||||
baseDomain: baseDomain
|
||||
baseDomain
|
||||
});
|
||||
};
|
||||
|
||||
const isSubdomainValid = selectedBaseDomain
|
||||
? validateSubdomain(subdomainInput, selectedBaseDomain)
|
||||
const isSubdomainValid = selectedBaseDomain && subdomainInput
|
||||
? validateByDomainType(subdomainInput, {
|
||||
type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization",
|
||||
domainType: selectedBaseDomain.domainType
|
||||
})
|
||||
: true;
|
||||
|
||||
const showSubdomainInput =
|
||||
selectedBaseDomain &&
|
||||
selectedBaseDomain.type === "organization" &&
|
||||
@@ -396,7 +401,7 @@ export default function DomainPicker2({
|
||||
const showProvidedDomainSearch =
|
||||
selectedBaseDomain?.type === "provided-search";
|
||||
|
||||
const sortedAvailableOptions = availableOptions.sort((a, b) => {
|
||||
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
|
||||
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
});
|
||||
@@ -408,6 +413,7 @@ export default function DomainPicker2({
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -435,7 +441,7 @@ export default function DomainPicker2({
|
||||
className={cn(
|
||||
!isSubdomainValid &&
|
||||
subdomainInput &&
|
||||
"border-red-500"
|
||||
"border-red-500 focus:border-red-500"
|
||||
)}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
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 { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -117,7 +117,15 @@ export function LayoutMobileMenu({
|
||||
<SupporterStatus />
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { ExternalLink, Server, BookOpenText, Zap } from "lucide-react";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
@@ -151,7 +152,7 @@ export function LayoutSidebar({
|
||||
{!isUnlocked()
|
||||
? t("communityEdition")
|
||||
: t("commercialEdition")}
|
||||
<ExternalLink size={12} />
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground ">
|
||||
@@ -165,9 +166,28 @@ export function LayoutSidebar({
|
||||
<BookOpenText size={12} />
|
||||
</Link>
|
||||
</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 && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<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>
|
||||
|
||||
@@ -24,6 +24,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t('actionUpdateOrg')]: "updateOrg",
|
||||
[t('actionGetOrgUser')]: "getOrgUser",
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionListInvitations')]: "listInvitations",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[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