import { Init, Provide } from '@midwayjs/core'; import { BaseService, CoolCommException } from '@cool-midway/core'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import * as dns from 'dns'; import * as tls from 'tls'; import { TeamDomainEntity } from '../entity/domain'; import { DnsVerifyResult, SslCertMockInfo, SslCheckResult, ValidateDomainResult, } from '../../../comm/types/omni'; /** * 租户独立域名与 SSL 证书治理服务 */ @Provide() export class TeamDomainService extends BaseService { @InjectEntityModel(TeamDomainEntity) teamDomainEntity: Repository; @Init() async init() { await super.init(); this.setEntity(this.teamDomainEntity); } /** * 正则严查企业二级域名格式 * 拦截端口、特殊字符、IP 与内部保留域名 */ validateDomainFormat(domain: string): ValidateDomainResult { if (!domain || typeof domain !== 'string' || !domain.trim()) { return { valid: false, reason: '域名不能为空' }; } const trimmed = domain.trim(); // 拦截带有协议前缀或路径 if ( trimmed.includes('://') || trimmed.startsWith('//') || trimmed.includes('/') ) { return { valid: false, reason: '域名不能包含协议前缀 (http:// 或 https://) 或路径', }; } // 拦截 IPv4 与 IPv6 地址 const isIpv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(trimmed); const isIpv6 = trimmed === '::1' || /^[0-9a-fA-F:]+$/.test(trimmed) && trimmed.includes(':'); if (isIpv4 || isIpv6) { return { valid: false, reason: '不能使用 IP 地址作为自定义域名' }; } // 拦截端口号 if (trimmed.includes(':')) { return { valid: false, reason: '域名不能包含端口号' }; } // 拦截内部保留域名及特定后缀 const lower = trimmed.toLowerCase(); const reservedSuffixes = [ '.local', '.internal', '.lan', '.test', '.example', '.invalid', '.localhost', ]; if ( lower === 'localhost' || reservedSuffixes.some(suffix => lower.endsWith(suffix)) ) { return { valid: false, reason: '不能使用内部或保留域名' }; } // 拦截非法特殊字符(仅允许字母、数字、点号与连字符) if (/[^a-zA-Z0-9.-]/.test(trimmed)) { return { valid: false, reason: '域名包含非法字符,仅支持英文字母、数字和连字符', }; } // 长度检查 if (trimmed.length > 253) { return { valid: false, reason: '域名长度不能超过 253 个字符' }; } // 分段检查 const labels = trimmed.split('.'); if (labels.length < 2 || labels.some(l => l.length === 0)) { return { valid: false, reason: '域名必须是有效的二级或多级域名' }; } for (const label of labels) { if (label.length < 1 || label.length > 63) { return { valid: false, reason: '单个域名分段长度必须在 1-63 字符之间' }; } if (label.startsWith('-') || label.endsWith('-')) { return { valid: false, reason: '域名分段不能以连字符 (-) 开头或结尾', }; } } // 顶级域 TLD 必须全为字母且至少 2 位 const tld = labels[labels.length - 1]; if (!/^[a-zA-Z]{2,}$/.test(tld)) { return { valid: false, reason: '顶级域名必须由至少 2 位英文字母组成', }; } return { valid: true }; } /** * CNAME DNS 解析核验(支持模拟记录与真实解析) */ async verifyDnsRecord( domain: string, targetCname: string, options?: { mockRecords?: string[] } ): Promise { const normalizedTarget = targetCname .trim() .toLowerCase() .replace(/\.$/, ''); let records: string[] = []; if (options?.mockRecords !== undefined) { records = options.mockRecords; } else { try { records = await dns.promises.resolveCname(domain.trim()); } catch { return { verified: false, records: [], targetCname: targetCname.trim(), message: 'DNS CNAME 记录未找到或解析失败', }; } } const normalizedRecords = records.map(r => r.trim().toLowerCase().replace(/\.$/, '') ); const verified = normalizedRecords.includes(normalizedTarget); return { verified, records, targetCname: targetCname.trim(), message: verified ? 'CNAME 解析配置正确' : 'CNAME 记录与目标地址不匹配', }; } /** * SSL 证书状态与过期时间计算(支持 TLS 真实握手与 Mock 注入) */ async checkSslCertificate( domain: string, port = 443, options?: { mockCert?: SslCertMockInfo } ): Promise { if (options?.mockCert) { const mock = options.mockCert; const issuer = mock.issuer || 'Mock CA'; const validFrom = mock.validFrom ? new Date(mock.validFrom).toISOString() : new Date().toISOString(); const validTo = mock.validTo ? new Date(mock.validTo).toISOString() : new Date().toISOString(); const validToTime = new Date(validTo).getTime(); const daysRemaining = Math.floor( (validToTime - Date.now()) / (1000 * 60 * 60 * 24) ); const expired = daysRemaining <= 0; const authorized = mock.authorized !== false; const valid = !expired && authorized; return { valid, status: valid ? 'active' : 'failed', issuer, validFrom, validTo, daysRemaining, expired, errorMessage: expired ? 'SSL 证书已过期' : !authorized ? '证书不受信任' : undefined, }; } return new Promise(resolve => { const host = domain.trim(); const socket = tls.connect( { host, port, servername: host, timeout: 5000, rejectUnauthorized: false, }, () => { try { const cert = socket.getPeerCertificate(); const authorized = socket.authorized; socket.end(); if (!cert || Object.keys(cert).length === 0) { return resolve({ valid: false, status: 'failed', errorMessage: '未获取到有效 SSL 证书', }); } const validTo = cert.valid_to; const validFrom = cert.valid_from; const rawIssuer = cert.issuer; let issuer = 'Unknown'; if (rawIssuer && typeof rawIssuer === 'object') { const o = Array.isArray(rawIssuer.O) ? rawIssuer.O[0] : rawIssuer.O; const cn = Array.isArray(rawIssuer.CN) ? rawIssuer.CN[0] : rawIssuer.CN; issuer = o || cn || 'Unknown'; } else if (rawIssuer) { issuer = String(rawIssuer); } const validToTime = new Date(validTo).getTime(); const daysRemaining = Math.floor( (validToTime - Date.now()) / (1000 * 60 * 60 * 24) ); const expired = daysRemaining <= 0; const valid = !expired && authorized; resolve({ valid, status: valid ? 'active' : 'failed', issuer, validFrom, validTo, daysRemaining, expired, errorMessage: expired ? 'SSL 证书已过期' : !authorized ? socket.authorizationError?.message || '证书不受信任' : undefined, }); } catch (err: unknown) { socket.destroy(); resolve({ valid: false, status: 'failed', errorMessage: err instanceof Error ? err.message : '解析证书异常', }); } } ); socket.on('error', err => { resolve({ valid: false, status: 'failed', errorMessage: err.message, }); }); socket.on('timeout', () => { socket.destroy(); resolve({ valid: false, status: 'failed', errorMessage: 'TLS 连接超时', }); }); }); } /** * 绑定独立域名持久化 */ async bindDomain( tenantId: number, domain: string, options?: { cnameTarget?: string } ): Promise { if (!tenantId || Number(tenantId) <= 0) { throw new CoolCommException('租户 ID 不能为空'); } const formatCheck = this.validateDomainFormat(domain); if (!formatCheck.valid) { throw new CoolCommException(`域名格式非法: ${formatCheck.reason}`); } const normalizedDomain = domain.trim().toLowerCase(); // 冲突检查:是否已被其他租户占用 const occupied = await this.teamDomainEntity.findOne({ where: { customDomain: normalizedDomain }, }); if (occupied && occupied.tenantId !== tenantId) { throw new CoolCommException('该域名已被其他企业绑定'); } let target = occupied; if (!target) { target = await this.teamDomainEntity.findOne({ where: { tenantId, customDomain: normalizedDomain }, }); } if (!target) { target = new TeamDomainEntity(); target.tenantId = tenantId; target.customDomain = normalizedDomain; target.sslStatus = 'pending'; } if (options?.cnameTarget) { target.cnameTarget = options.cnameTarget; } return await this.teamDomainEntity.save(target); } }