/** * Self-signed certificate generation for HTTPS support */ import { X509Certificate } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { homedir, networkInterfaces } from 'node:os' import { join } from 'node:path' import { generate } from 'selfsigned' /** * Get all LAN IPv4 addresses */ export function getLanIPs(): string[] { const ips: string[] = [] const nets = networkInterfaces() for (const name of Object.keys(nets)) { for (const net of nets[name] || []) { // Skip internal (loopback) and non-IPv4 addresses if (!net.internal && net.family === 'IPv4') { ips.push(net.address) } } } return ips } /** * Extract IP addresses from certificate's Subject Alternative Name (SAN) * SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost" */ function extractSanIPs(x509: X509Certificate): string[] { const san = x509.subjectAltName if (!san) return [] const ips: string[] = [] // Parse "IP Address:x.x.x.x" entries from SAN string const parts = san.split(', ') for (const part of parts) { const match = part.match(/^IP Address:(.+)$/) if (match && match[1]) { ips.push(match[1]) } } return ips } const CERT_DIR = join(homedir(), '.acp-proxy') const KEY_PATH = join(CERT_DIR, 'key.pem') const CERT_PATH = join(CERT_DIR, 'cert.pem') // Certificate validity in days const CERT_VALIDITY_DAYS = 365 export interface TlsOptions { key: string cert: string } /** * Get or generate self-signed certificate * Certificates are cached in ~/.acp-proxy/ */ export async function getOrCreateCertificate(): Promise { // Ensure directory exists if (!existsSync(CERT_DIR)) { mkdirSync(CERT_DIR, { recursive: true }) } // Check if certificates already exist and are still valid if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) { const certPem = readFileSync(CERT_PATH, 'utf-8') const keyPem = readFileSync(KEY_PATH, 'utf-8') try { const x509 = new X509Certificate(certPem) const validTo = new Date(x509.validTo) const now = new Date() // Check if cert is expired or will expire within 7 days const daysUntilExpiry = Math.floor( (validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ) if (daysUntilExpiry <= 7) { // Certificate expired or expiring soon console.log( `⚠️ Certificate ${daysUntilExpiry <= 0 ? 'expired' : `expires in ${daysUntilExpiry} days`}, regenerating...`, ) } else { // Check if current LAN IPs are in the certificate's SAN const currentLanIPs = getLanIPs() const certSanIPs = extractSanIPs(x509) // Check if all current LAN IPs are covered by the certificate const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip)) if (missingIPs.length === 0) { console.log(`🔐 Using existing certificate from ${CERT_DIR}`) console.log(` Valid for ${daysUntilExpiry} more days`) return { key: keyPem, cert: certPem } } // LAN IP changed, regenerate console.log( `⚠️ LAN IP changed (missing: ${missingIPs.join(', ')}), regenerating certificate...`, ) } } catch { // Failed to parse certificate, regenerate console.log(`⚠️ Invalid certificate, regenerating...`) } } // Generate new self-signed certificate console.log(`🔐 Generating self-signed certificate...`) const attrs = [{ name: 'commonName', value: 'ACP Proxy Server' }] // Calculate expiry date const notAfterDate = new Date() notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS) // Build altNames: localhost + loopback + all LAN IPs const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [ { type: 2, value: 'localhost' }, { type: 7, ip: '127.0.0.1' }, { type: 7, ip: '::1' }, ] // Add all current LAN IPs const lanIPs = getLanIPs() for (const ip of lanIPs) { altNames.push({ type: 7, ip }) } if (lanIPs.length > 0) { console.log(` Including LAN IPs: ${lanIPs.join(', ')}`) } const pems = await generate(attrs, { keySize: 2048, notAfterDate, algorithm: 'sha256', extensions: [ { name: 'basicConstraints', cA: true, }, { name: 'keyUsage', keyCertSign: true, digitalSignature: true, keyEncipherment: true, }, { name: 'extKeyUsage', serverAuth: true, }, { name: 'subjectAltName', altNames, }, ], }) // Save certificates writeFileSync(KEY_PATH, pems.private) writeFileSync(CERT_PATH, pems.cert) console.log(`✅ Certificate saved to ${CERT_DIR}`) console.log(` Valid for ${CERT_VALIDITY_DAYS} days`) console.log( ` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`, ) return { key: pems.private, cert: pems.cert, } }