import { execFileSync } from 'node:child_process';
import {
writeFileSync,
mkdirSync,
unlinkSync,
existsSync,
readFileSync,
symlinkSync,
rmSync,
} from 'node:fs';
import { join, isAbsolute } from 'node:path';
import { tmpdir } from 'node:os';
const CERT_ROOT = '/app/certs';
function isIp(host: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(host) || /^[0-9a-fA-F:]+$/.test(host);
}
function storeCert(name: string, cert: string, key: string, origin: string) {
const dir = join(CERT_ROOT, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'fullchain.pem'), cert, { mode: 0o644 });
writeFileSync(join(dir, 'privkey.pem'), key, { mode: 0o600 });
writeFileSync(join(dir, 'origin'), origin);
}
function activateCert(name: string) {
const activeLink = join(CERT_ROOT, 'active');
try { unlinkSync(activeLink); } catch {}
symlinkSync(join(CERT_ROOT, name), activeLink);
}
export function generateSelfSigned(host: string) {
const san = isIp(host) ? `IP:${host}` : `DNS:${host}`;
const name = `self-${host.replace(/[^a-zA-Z0-9.\-]/g, '_')}`;
const tmp = tmpdir();
const certPath = join(tmp, `${name}-cert.pem`);
const keyPath = join(tmp, `${name}-key.pem`);
execFileSync('openssl', [
'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
'-keyout', keyPath,
'-out', certPath,
'-days', '3650',
'-subj', `/CN=${host}`,
'-addext', `subjectAltName=${san}`,
], { stdio: 'pipe' });
const cert = readFileSync(certPath, 'utf8');
const key = readFileSync(keyPath, 'utf8');
storeCert(name, cert, key, 'self-signed');
activateCert(name);
}
export function importCert(certPem: string, keyPem: string) {
execFileSync('openssl', ['x509', '-noout'], {
input: certPem,
stdio: ['pipe', 'pipe', 'pipe'],
});
execFileSync('openssl', ['pkey', '-noout'], {
input: keyPem,
stdio: ['pipe', 'pipe', 'pipe'],
});
const subjectOutput = execFileSync(
'openssl',
['x509', '-noout', '-subject', '-nameopt', 'multiline'],
{ input: certPem, stdio: ['pipe', 'pipe', 'pipe'] }
).toString();
const cnMatch = subjectOutput.match(/commonName\s*=\s*(.+)/);
const cn = cnMatch ? cnMatch[1]!.trim().replace(/[^a-zA-Z0-9.\-]/g, '_') : 'cert';
const name = `imported-${cn}`;
storeCert(name, certPem, keyPem, 'imported');
activateCert(name);
}
function assertReadablePem(path: string, kind: 'certificate' | 'key') {
if (!isAbsolute(path)) {
throw new Error(`${kind} path must be absolute: ${path}`);
}
if (!existsSync(path)) {
throw new Error(`${kind} file not found: ${path}`);
}
try {
readFileSync(path, 'utf8');
} catch (e) {
throw new Error(
`Failed to read ${kind} file at ${path}: ${(e as Error).message}`
);
}
}
export function importCertFromPath(certPath: string, keyPath: string) {
assertReadablePem(certPath, 'certificate');
assertReadablePem(keyPath, 'key');
execFileSync('openssl', ['x509', '-noout', '-in', certPath], {
stdio: ['pipe', 'pipe', 'pipe'],
});
execFileSync('openssl', ['pkey', '-noout', '-in', keyPath], {
stdio: ['pipe', 'pipe', 'pipe'],
});
const name = 'path-imported';
const dir = join(CERT_ROOT, name);
try { rmSync(dir, { recursive: true, force: true }); } catch {}
mkdirSync(dir, { recursive: true });
symlinkSync(certPath, join(dir, 'fullchain.pem'));
symlinkSync(keyPath, join(dir, 'privkey.pem'));
writeFileSync(
join(dir, 'origin'),
`imported-path\ncert=${certPath}\nkey=${keyPath}\n`,
{ mode: 0o644 }
);
activateCert(name);
}
export function scheduleNodeRestart() {
setTimeout(() => {
process.exit(0);
}, 500);
}