From 739eeb63aaff4f7a4321798ffecfcd2aa6c072eb Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Fri, 30 May 2025 15:04:12 +0000 Subject: [PATCH] update --- .gitignore | 1 + ts/classes.dcrouter.ts | 290 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 266 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 68640ea..4e4038b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ dist_*/ # custom **/.claude/settings.local.json data/ +readme.plan.md diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index cfa0e33..7f16806 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -53,24 +53,41 @@ export interface IDcRouterOptions { caPath?: string; }; - /** - * DNS domain for automatic DNS server setup with DoH - * When set, DNS server will: - * - Always bind to UDP port 53 on the VM's IP address - * - Use socket-handler approach for DNS-over-HTTPS - * - Automatically handle NS delegation validation + /** + * The nameserver domains (e.g., ['gatewaymain.lossless.directory', 'gatewaymain2.lossless.directory']) + * These must have A records pointing to your server's IP + * These are what go in the NS records for ALL domains in dnsScopes */ - dnsDomain?: string; + dnsNsDomains?: string[]; /** - * DNS records to register when using dnsDomain - * These are in addition to auto-generated records from email domains with internal-dns mode + * Domains this DNS server is authoritative for (e.g., ['bleu.de', 'mail.social.io']) + * NS records will be auto-generated for these domains + * Any DNS record outside these scopes will trigger a warning + * Email domains with `internal-dns` mode must be included here + */ + dnsScopes?: string[]; + + /** + * IPs of proxies that forward traffic to your server (optional) + * When defined AND useIngressProxy is true, A records with server IP are replaced with proxy IPs + * If not defined or empty, all A records use the real server IP + * Helps hide real server IP for security/privacy + */ + proxyIps?: string[]; + + /** + * DNS records to register + * Must be within the defined dnsScopes (or receive warning) + * Only need A, CNAME, TXT, MX records (NS and SOA are auto-generated) + * Can use `useIngressProxy: false` to expose real server IP (defaults to true) */ dnsRecords?: Array<{ name: string; type: 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'NS' | 'SOA'; value: string; ttl?: number; + useIngressProxy?: boolean; // Whether to replace server IP with proxy IP (default: true) }>; /** DNS challenge configuration for ACME (optional) */ @@ -139,8 +156,9 @@ export class DcRouter { } } - // Set up DNS server if configured by dnsDomain - if (this.options.dnsDomain) { + // Set up DNS server if configured with nameservers and scopes + if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0 && + this.options.dnsScopes && this.options.dnsScopes.length > 0) { await this.setupDnsWithSocketHandler(); } @@ -177,10 +195,10 @@ export class DcRouter { routes = [...routes, ...emailRoutes]; // Enable email routing through SmartProxy } - // If DNS domain is configured, add DNS routes - if (this.options.dnsDomain) { + // If DNS is configured, add DNS routes + if (this.options.dnsNsDomains && this.options.dnsNsDomains.length > 0) { const dnsRoutes = this.generateDnsRoutes(); - console.log(`DNS Routes for domain ${this.options.dnsDomain}:`, dnsRoutes); + console.log(`DNS Routes for nameservers ${this.options.dnsNsDomains.join(', ')}:`, dnsRoutes); routes = [...routes, ...dnsRoutes]; } @@ -395,7 +413,7 @@ export class DcRouter { * Generate SmartProxy routes for DNS configuration */ private generateDnsRoutes(): plugins.smartproxy.IRouteConfig[] { - if (!this.options.dnsDomain) { + if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) { return []; } @@ -404,12 +422,15 @@ export class DcRouter { // Create routes for DNS-over-HTTPS paths const dohPaths = ['/dns-query', '/resolve']; + // Use the first nameserver domain for DoH routes + const primaryNameserver = this.options.dnsNsDomains[0]; + for (const path of dohPaths) { const dohRoute: plugins.smartproxy.IRouteConfig = { name: `dns-over-https-${path.replace('/', '')}`, match: { ports: [443], // HTTPS port for DoH - domains: [this.options.dnsDomain], + domains: [primaryNameserver], path: path }, action: { @@ -676,7 +697,8 @@ export class DcRouter { const recordsByDomain = new Map(); for (const record of records) { - const pattern = record.name.includes('*') ? record.name : `*.${record.name}`; + // Use exact domain name for registration - no automatic wildcard prefix + const pattern = record.name; if (!recordsByDomain.has(pattern)) { recordsByDomain.set(pattern, []); } @@ -724,6 +746,18 @@ export class DcRouter { return value; case 'NS': return value; + case 'SOA': + // SOA format: primary-ns admin-email serial refresh retry expire minimum + const parts = value.split(' '); + return { + mname: parts[0], + rname: parts[1], + serial: parseInt(parts[2]), + refresh: parseInt(parts[3]), + retry: parseInt(parts[4]), + expire: parseInt(parts[5]), + minimum: parseInt(parts[6]) + }; default: return value; } @@ -733,18 +767,23 @@ export class DcRouter { * Set up DNS server with socket handler for DoH */ private async setupDnsWithSocketHandler(): Promise { - if (!this.options.dnsDomain) { - throw new Error('dnsDomain is required for DNS socket handler setup'); + if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) { + throw new Error('dnsNsDomains is required for DNS server setup'); } - logger.log('info', `Setting up DNS server with socket handler for domain: ${this.options.dnsDomain}`); + if (!this.options.dnsScopes || this.options.dnsScopes.length === 0) { + throw new Error('dnsScopes is required for DNS server setup'); + } + + const primaryNameserver = this.options.dnsNsDomains[0]; + logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`); // Get VM IP address for UDP binding const networkInterfaces = plugins.os.networkInterfaces(); let vmIpAddress = '0.0.0.0'; // Default to all interfaces // Try to find the VM's internal IP address - for (const [name, interfaces] of Object.entries(networkInterfaces)) { + for (const [_name, interfaces] of Object.entries(networkInterfaces)) { if (interfaces) { for (const iface of interfaces) { if (!iface.internal && iface.family === 'IPv4') { @@ -761,7 +800,7 @@ export class DcRouter { udpBindInterface: vmIpAddress, httpsPort: 443, // Required but won't bind due to manual mode manualHttpsMode: true, // Enable manual HTTPS socket handling - dnssecZone: this.options.dnsDomain, + dnssecZone: primaryNameserver, // For now, use self-signed cert until we integrate with Let's Encrypt httpsKey: '', httpsCert: '' @@ -771,10 +810,28 @@ export class DcRouter { await this.dnsServer.start(); logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`); - // Register DNS records if provided + // Validate DNS configuration + await this.validateDnsConfiguration(); + + // Generate and register authoritative records + const authoritativeRecords = await this.generateAuthoritativeRecords(); + + // Generate email DNS records + const emailDnsRecords = await this.generateEmailDnsRecords(); + + // Combine all records: authoritative, email, and user-defined + const allRecords = [...authoritativeRecords, ...emailDnsRecords]; if (this.options.dnsRecords && this.options.dnsRecords.length > 0) { - this.registerDnsRecords(this.options.dnsRecords); - logger.log('info', `Registered ${this.options.dnsRecords.length} DNS records`); + allRecords.push(...this.options.dnsRecords); + } + + // Apply proxy IP replacement if configured + this.applyProxyIpReplacement(allRecords); + + // Register all DNS records + if (allRecords.length > 0) { + this.registerDnsRecords(allRecords); + logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${this.options.dnsRecords?.length || 0} user-defined)`); } } @@ -802,6 +859,189 @@ export class DcRouter { }; } + /** + * Validate DNS configuration + */ + private async validateDnsConfiguration(): Promise { + if (!this.options.dnsNsDomains || !this.options.dnsScopes) { + return; + } + + logger.log('info', 'Validating DNS configuration...'); + + // Check if email domains with internal-dns are in dnsScopes + if (this.options.emailConfig?.domains) { + for (const domainConfig of this.options.emailConfig.domains) { + if (domainConfig.dnsMode === 'internal-dns' && + !this.options.dnsScopes.includes(domainConfig.domain)) { + logger.log('warn', `Email domain '${domainConfig.domain}' with internal-dns mode is not in dnsScopes. It should be added to dnsScopes.`); + } + } + } + + // Validate user-provided DNS records are within scopes + if (this.options.dnsRecords) { + for (const record of this.options.dnsRecords) { + const recordDomain = this.extractDomain(record.name); + const isInScope = this.options.dnsScopes.some(scope => + recordDomain === scope || recordDomain.endsWith(`.${scope}`) + ); + + if (!isInScope) { + logger.log('warn', `DNS record for '${record.name}' is outside defined scopes [${this.options.dnsScopes.join(', ')}]`); + } + } + } + } + + /** + * Generate email DNS records for domains with internal-dns mode + */ + private async generateEmailDnsRecords(): Promise> { + const records: Array<{name: string; type: string; value: string; ttl?: number}> = []; + + if (!this.options.emailConfig?.domains) { + return records; + } + + // Filter domains with internal-dns mode + const internalDnsDomains = this.options.emailConfig.domains.filter( + domain => domain.dnsMode === 'internal-dns' + ); + + for (const domainConfig of internalDnsDomains) { + const domain = domainConfig.domain; + const ttl = domainConfig.dns?.internal?.ttl || 3600; + const mxPriority = domainConfig.dns?.internal?.mxPriority || 10; + + // MX record - points to the domain itself for email handling + records.push({ + name: domain, + type: 'MX', + value: `${mxPriority} ${domain}`, + ttl + }); + + // SPF record - using sensible defaults + const spfRecord = 'v=spf1 a mx ~all'; + records.push({ + name: domain, + type: 'TXT', + value: spfRecord, + ttl + }); + + // DMARC record - using sensible defaults + const dmarcPolicy = 'none'; // Start with 'none' policy for monitoring + const dmarcEmail = `dmarc@${domain}`; + records.push({ + name: `_dmarc.${domain}`, + type: 'TXT', + value: `v=DMARC1; p=${dmarcPolicy}; rua=mailto:${dmarcEmail}`, + ttl + }); + + // Note: DKIM records will be generated later when DKIM keys are available + // They require the DKIMCreator which is part of the email server + } + + logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`); + return records; + } + + /** + * Generate authoritative DNS records (NS and SOA) for all domains in dnsScopes + */ + private async generateAuthoritativeRecords(): Promise> { + const records: Array<{name: string; type: string; value: string; ttl?: number}> = []; + + if (!this.options.dnsNsDomains || !this.options.dnsScopes) { + return records; + } + + const primaryNameserver = this.options.dnsNsDomains[0]; + + // Generate NS and SOA records for each domain in scopes + for (const domain of this.options.dnsScopes) { + // Add NS records for all nameservers + for (const nsDomain of this.options.dnsNsDomains) { + records.push({ + name: domain, + type: 'NS', + value: nsDomain, + ttl: 3600 + }); + } + + // Add SOA record with first nameserver as primary + const soaValue = `${primaryNameserver} hostmaster.${domain} ${Date.now()} 7200 3600 1209600 3600`; + records.push({ + name: domain, + type: 'SOA', + value: soaValue, + ttl: 3600 + }); + } + + logger.log('info', `Generated ${records.length} authoritative records for ${this.options.dnsScopes.length} domains`); + return records; + } + + /** + * Extract the base domain from a DNS record name + */ + private extractDomain(recordName: string): string { + // Handle wildcards + if (recordName.startsWith('*.')) { + recordName = recordName.substring(2); + } + return recordName; + } + + /** + * Apply proxy IP replacement logic to DNS records + */ + private applyProxyIpReplacement(records: Array<{name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean}>): void { + if (!this.options.proxyIps || this.options.proxyIps.length === 0) { + return; // No proxy IPs configured, skip replacement + } + + // Get server's public IP (for now, we'll use a placeholder - in production this would be detected) + // This would normally be detected from network interfaces or external service + const serverIp = this.detectServerPublicIp(); + if (!serverIp) { + logger.log('warn', 'Could not detect server public IP, skipping proxy IP replacement'); + return; + } + + logger.log('info', `Applying proxy IP replacement. Server IP: ${serverIp}, Proxy IPs: ${this.options.proxyIps.join(', ')}`); + + let proxyIndex = 0; + for (const record of records) { + if (record.type === 'A' && + record.value === serverIp && + record.useIngressProxy !== false) { + // Round-robin through proxy IPs + const proxyIp = this.options.proxyIps[proxyIndex % this.options.proxyIps.length]; + logger.log('info', `Replacing A record for ${record.name}: ${record.value} → ${proxyIp}`); + record.value = proxyIp; + proxyIndex++; + } + } + } + + /** + * Detect the server's public IP address + */ + private detectServerPublicIp(): string | null { + // In a real implementation, this would: + // 1. Check network interfaces for public IPs + // 2. Or make a request to an external service to get public IP + // For now, return null to skip proxy replacement + // TODO: Implement proper public IP detection + return null; + } + /** * Create mail socket handler for email traffic */