Merge pull request #1391 from fosrl/dev

1.9.3
This commit is contained in:
Owen Schwartz
2025-08-31 20:58:35 -07:00
committed by GitHub
45 changed files with 1311 additions and 550 deletions

View File

@@ -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:

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -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: "
} }

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -1457,5 +1457,7 @@
"autoLoginRedirecting": "로그인으로 리디렉션 중...", "autoLoginRedirecting": "로그인으로 리디렉션 중...",
"autoLoginError": "자동 로그인 오류", "autoLoginError": "자동 로그인 오류",
"autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.",
"autoLoginErrorGeneratingUrl": "인증 URL 생성 실패." "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.",
"internationaldomaindetected": "국제 도메인 감지됨",
"willbestoredas": "다음과 같이 저장됩니다."
} }

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -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:"
} }

View File

@@ -1457,5 +1457,7 @@
"autoLoginRedirecting": "Перенаправление к входу...", "autoLoginRedirecting": "Перенаправление к входу...",
"autoLoginError": "Ошибка автоматического входа", "autoLoginError": "Ошибка автоматического входа",
"autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.",
"autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации." "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.",
"internationaldomaindetected": "Обнаружен международный домен",
"willbestoredas": "Будет сохранен как:"
} }

View File

@@ -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:"
} }

View File

@@ -1457,5 +1457,7 @@
"autoLoginRedirecting": "重定向到登录...", "autoLoginRedirecting": "重定向到登录...",
"autoLoginError": "自动登录错误", "autoLoginError": "自动登录错误",
"autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。",
"autoLoginErrorGeneratingUrl": "生成身份验证URL失败。" "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。",
"internationaldomaindetected": "检测到国际域名",
"willbestoredas": "将存储为:"
} }

View File

@@ -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()

View File

@@ -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;

View File

@@ -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());

View 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);
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -82,6 +82,7 @@ authenticated.delete(
"/org/:orgId", "/org/:orgId",
verifyOrgAccess, verifyOrgAccess,
verifyUserIsOrgOwner, verifyUserIsOrgOwner,
verifyUserHasAction(ActionsEnum.deleteOrg),
org.deleteOrg org.deleteOrg
); );

View File

@@ -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,

View File

@@ -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)

View File

@@ -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);

View File

@@ -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();
} }

View File

@@ -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),
) )
); );

View File

@@ -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, {

View File

@@ -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>
); );
} }

View File

@@ -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);
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`,
});
} }
}} }}
> >

View File

@@ -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>
) )
)} )}

View File

@@ -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>
) )
)} )}

View File

@@ -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,

View File

@@ -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 || "")}/`
})) }))
); );
} }

View File

@@ -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>

View File

@@ -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";

View File

@@ -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);
}; };
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View 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;
}
}

View 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));
};