This commit is contained in:
Philipp Kunz 2025-05-30 15:04:12 +00:00
parent eb26a62a87
commit 739eeb63aa
2 changed files with 266 additions and 25 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ dist_*/
# custom
**/.claude/settings.local.json
data/
readme.plan.md

View File

@ -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<string, typeof records>();
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<void> {
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<void> {
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<Array<{name: string; type: string; value: string; ttl?: number}>> {
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<Array<{name: string; type: string; value: string; ttl?: number}>> {
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
*/