#!/usr/bin/env node import { randomBytes } from 'node:crypto'; import { accessSync, chmodSync, chownSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, statSync, } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import path from 'node:path'; import { userInfo } from 'node:os'; const DEFAULT_SERVICE_USER = 'genarrative'; const DEFAULT_SERVICE_GROUP = 'genarrative'; const DEFAULT_TARGET_USER = 'root'; const DEFAULT_DIRECTORY_MODE = '0750'; const DEFAULT_FILE_MODE = '0640'; const config = parseArgs(process.argv.slice(2)); const result = run(); if (config.json) { console.log(`${JSON.stringify(result, null, 2)}\n`); } else { const mode = config.apply ? 'apply' : 'dry-run'; console.log(`[pingora-tls-cert-sync] ${mode} OK`); console.log(`[pingora-tls-cert-sync] target=${result.target.directory}`); for (const item of result.files) { console.log( `[pingora-tls-cert-sync] ${item.kind}: ${item.source.path} -> ${item.target.path}`, ); } } function usage() { console.log(`Usage: node scripts/deploy/pingora-tls-cert-sync.mjs --source-cert-file --source-key-file --target-dir [options] Options: --source-cert-file 已存在的证书链文件,通常是 Let's Encrypt live/fullchain.pem。 --source-key-file 已存在的私钥文件,通常是 Let's Encrypt live/privkey.pem。 --target-dir Pingora 私有 TLS 目录,例如 /etc/genarrative/pingora-tls/。 --service-user 目标服务用户,默认 genarrative。 --service-group 目标文件组,默认 genarrative。 --target-user 目标文件 owner,默认 root。 --directory-mode 目标目录权限,默认 0750。 --file-mode 证书和私钥权限,默认 0640。 --apply 执行复制;默认只做 dry-run 校验。 --json 输出 JSON。 该脚本只把现有证书复制到 Pingora 私有目录,不修改 Let's Encrypt / Certbot / Nginx 原始文件。 源路径可以是 Certbot live symlink,但解析后的目标必须是普通文件;目标目录和目标文件不允许是 symlink。 `); } function parseArgs(argv) { const result = { sourceCertFile: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_SOURCE_CERT_FILE || '', sourceKeyFile: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_SOURCE_KEY_FILE || '', targetDir: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_TARGET_DIR || '', serviceUser: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_SERVICE_USER || DEFAULT_SERVICE_USER, serviceGroup: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_SERVICE_GROUP || DEFAULT_SERVICE_GROUP, targetUser: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_TARGET_USER || DEFAULT_TARGET_USER, directoryMode: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_DIRECTORY_MODE || DEFAULT_DIRECTORY_MODE, fileMode: process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_FILE_MODE || DEFAULT_FILE_MODE, apply: readBoolEnv( process.env.GENARRATIVE_PINGORA_TLS_CERT_SYNC_APPLY, false, 'GENARRATIVE_PINGORA_TLS_CERT_SYNC_APPLY', ), json: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; switch (arg) { case '-h': case '--help': usage(); process.exit(0); break; case '--source-cert-file': result.sourceCertFile = requireValue(argv, ++index, arg); break; case '--source-key-file': result.sourceKeyFile = requireValue(argv, ++index, arg); break; case '--target-dir': result.targetDir = requireValue(argv, ++index, arg); break; case '--service-user': result.serviceUser = requireValue(argv, ++index, arg); break; case '--service-group': result.serviceGroup = requireValue(argv, ++index, arg); break; case '--target-user': result.targetUser = requireValue(argv, ++index, arg); break; case '--directory-mode': result.directoryMode = requireValue(argv, ++index, arg); break; case '--file-mode': result.fileMode = requireValue(argv, ++index, arg); break; case '--apply': result.apply = true; break; case '--json': result.json = true; break; default: throw new Error(`未知参数: ${arg}`); } } validateSafeAbsoluteFilePath( result.sourceCertFile, '--source-cert-file', ); validateSafeAbsoluteFilePath(result.sourceKeyFile, '--source-key-file'); validateSafeAbsoluteDirectoryPath(result.targetDir, '--target-dir'); validateIdentity(result.serviceUser, '--service-user'); validateIdentity(result.serviceGroup, '--service-group'); validateIdentity(result.targetUser, '--target-user'); result.directoryModeNumber = parseMode( result.directoryMode, '--directory-mode', ); result.fileModeNumber = parseMode(result.fileMode, '--file-mode'); if ((result.fileModeNumber & 0o007) !== 0) { throw new Error('--file-mode 不能允许 other 读取证书或私钥。'); } if ((result.directoryModeNumber & 0o007) !== 0) { throw new Error('--directory-mode 不能允许 other 进入 TLS 私有目录。'); } return result; } function requireValue(argv, index, flag) { const value = argv[index]; if (!value || value.startsWith('--')) { throw new Error(`${flag} 缺少参数值`); } return value; } function readBoolEnv(raw, fallback, label) { if (raw === undefined || raw === null || String(raw).trim() === '') { return fallback; } validateNoControlCharacters(raw, label); const normalized = String(raw).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) { return true; } if (['0', 'false', 'no', 'off'].includes(normalized)) { return false; } throw new Error(`${label} 必须是布尔值 true/false 或 1/0。`); } function validateSafeAbsoluteFilePath(value, label) { validateNoControlCharacters(value, label); if (!path.isAbsolute(value)) { throw new Error(`${label} 必须是绝对路径。`); } if (isFilesystemRootPath(value)) { throw new Error(`${label} 不能是文件系统根目录。`); } } function validateSafeAbsoluteDirectoryPath(value, label) { validateSafeAbsoluteFilePath(value, label); if (path.basename(path.resolve(value)) === '..') { throw new Error(`${label} 不能以 .. 结尾。`); } } function validateNoControlCharacters(value, label) { if (/[\0\r\n]/u.test(String(value))) { throw new Error(`${label} 不能包含换行或 NUL 字符。`); } } function validateIdentity(value, label) { validateNoControlCharacters(value, label); if (!/^[A-Za-z0-9_.#-]+$/u.test(String(value))) { throw new Error(`${label} 只能包含字母、数字、点、下划线、短横线或 #。`); } } function parseMode(value, label) { validateNoControlCharacters(value, label); const text = String(value).trim(); if (!/^0?[0-7]{3}$/u.test(text)) { throw new Error(`${label} 必须是三位八进制权限,例如 0640。`); } return Number.parseInt(text, 8); } function isFilesystemRootPath(value) { const resolved = path.resolve(String(value)); return resolved === path.parse(resolved).root; } function run() { const certSource = inspectSourceFile( config.sourceCertFile, '--source-cert-file', ); const keySource = inspectSourceFile(config.sourceKeyFile, '--source-key-file'); const certTarget = path.join(config.targetDir, 'fullchain.pem'); const keyTarget = path.join(config.targetDir, 'privkey.pem'); const owner = config.apply ? resolveOwner(config.targetUser, config.serviceGroup) : { uid: null, gid: null }; assertNoSymlinkAncestors(config.targetDir, '--target-dir'); assertTargetPath(certTarget, '目标 fullchain.pem'); assertTargetPath(keyTarget, '目标 privkey.pem'); if (existsSync(config.targetDir)) { assertTargetDirectory(config.targetDir); } const planned = { mode: config.apply ? 'apply' : 'dry-run', target: { directory: config.targetDir, user: config.targetUser, group: config.serviceGroup, uid: owner.uid, gid: owner.gid, serviceUser: config.serviceUser, directoryMode: formatMode(config.directoryModeNumber), fileMode: formatMode(config.fileModeNumber), }, files: [ { kind: 'cert', source: certSource, target: { path: certTarget }, }, { kind: 'key', source: keySource, target: { path: keyTarget }, }, ], serviceUserReadable: false, }; if (!config.apply) { return planned; } mkdirSync(config.targetDir, { recursive: true }); assertTargetDirectory(config.targetDir); chmodSync(config.targetDir, config.directoryModeNumber); applyOwner(config.targetDir, owner); copyAtomic(certSource.resolvedPath, certTarget, owner); copyAtomic(keySource.resolvedPath, keyTarget, owner); planned.serviceUserReadable = assertServiceUserReadable( config.serviceUser, [certTarget, keyTarget], ); return planned; } function inspectSourceFile(filePath, label) { const linkStat = lstatSync(filePath); const sourceIsSymlink = linkStat.isSymbolicLink(); const resolvedPath = realpathSync(filePath); const fileStat = statSync(resolvedPath); if (!fileStat.isFile()) { throw new Error(`${label} 解析后必须是普通文件: ${resolvedPath}`); } return { path: filePath, resolvedPath, sourceIsSymlink, sizeBytes: fileStat.size, }; } function assertTargetDirectory(directory) { const linkStat = lstatSync(directory); if (linkStat.isSymbolicLink()) { throw new Error(`--target-dir 不能是符号链接: ${directory}`); } if (!linkStat.isDirectory()) { throw new Error(`--target-dir 已存在但不是目录: ${directory}`); } } function assertNoSymlinkAncestors(targetDir, label) { const resolved = path.resolve(targetDir); const parsed = path.parse(resolved); let current = parsed.root; for (const part of resolved.slice(parsed.root.length).split(path.sep)) { if (!part) { continue; } current = path.join(current, part); if (!existsSync(current)) { break; } const linkStat = lstatSync(current); if (linkStat.isSymbolicLink()) { throw new Error(`${label} 已存在路径不能包含符号链接: ${current}`); } if (!linkStat.isDirectory()) { throw new Error(`${label} 已存在父路径不是目录: ${current}`); } } } function assertTargetPath(filePath, label) { if (!existsSync(filePath)) { return; } const linkStat = lstatSync(filePath); if (linkStat.isSymbolicLink()) { throw new Error(`${label} 不能是符号链接: ${filePath}`); } if (!linkStat.isFile()) { throw new Error(`${label} 已存在但不是普通文件: ${filePath}`); } } function resolveOwner(user, group) { const current = currentIdentity(); const uid = resolveUserId(user, current); const gid = resolveGroupId(group, current, user); if (!isCurrentUserRoot()) { if (uid !== current.uid || gid !== current.gid) { throw new Error( `当前用户不是 root,不能把证书授权给 ${user}:${group};请用 sudo -n 执行 --apply。`, ); } } return { uid, gid }; } function resolveUserId(user, current) { if (/^#?\d+$/u.test(user)) { return Number.parseInt(user.replace(/^#/u, ''), 10); } if (user === current.username) { return current.uid; } return Number.parseInt( execFileSync('id', ['-u', user], { encoding: 'utf8' }).trim(), 10, ); } function resolveGroupId(group, current, user) { if (/^#?\d+$/u.test(group)) { return Number.parseInt(group.replace(/^#/u, ''), 10); } if (group === current.groupname || group === String(current.gid)) { return current.gid; } const getent = spawnSync('getent', ['group', group], { encoding: 'utf8' }); if (getent.status === 0 && getent.stdout.trim()) { const parts = getent.stdout.trim().split(':'); return Number.parseInt(parts[2], 10); } if (group === user) { return Number.parseInt( execFileSync('id', ['-g', user], { encoding: 'utf8' }).trim(), 10, ); } throw new Error(`无法解析服务组: ${group}`); } function currentIdentity() { const uid = typeof process.getuid === 'function' ? process.getuid() : -1; const gid = typeof process.getgid === 'function' ? process.getgid() : -1; let username = ''; try { username = userInfo().username; } catch { username = process.env.USER || process.env.LOGNAME || ''; } return { uid, gid, username, groupname: '', }; } function isCurrentUserRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; } function applyOwner(filePath, owner) { const current = currentIdentity(); if (!isCurrentUserRoot() && owner.uid === current.uid && owner.gid === current.gid) { return; } chownSync(filePath, owner.uid, owner.gid); } function copyAtomic(sourcePath, targetPath, owner) { const tempPath = path.join( path.dirname(targetPath), `.${path.basename(targetPath)}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`, ); try { copyFileSync(sourcePath, tempPath, constants.COPYFILE_EXCL); chmodSync(tempPath, config.fileModeNumber); applyOwner(tempPath, owner); renameSync(tempPath, targetPath); chmodSync(targetPath, config.fileModeNumber); applyOwner(targetPath, owner); } catch (error) { rmSync(tempPath, { force: true }); throw error; } } function assertServiceUserReadable(serviceUser, files) { if (/^#?\d+$/u.test(serviceUser)) { for (const file of files) { accessSync(file, constants.R_OK); } return true; } const current = currentIdentity(); if (serviceUser === current.username && !isCurrentUserRoot()) { for (const file of files) { accessSync(file, constants.R_OK); } return true; } for (const file of files) { const check = spawnSync('sudo', ['-n', '-u', serviceUser, 'test', '-r', file], { encoding: 'utf8', }); if (check.status !== 0) { const detail = (check.stderr || check.stdout || '').trim(); throw new Error( `服务用户 ${serviceUser} 不可读: ${file}${detail ? ` (${detail})` : ''}`, ); } } return true; } function formatMode(mode) { return `0${mode.toString(8).padStart(3, '0')}`; }