Files
Genarrative/scripts/deploy/pingora-tls-cert-sync.mjs
kdletters 764f3609d0 修正 Pingora 证书副本归属
拆分 TLS 证书同步脚本的 target owner 与服务用户。

目标证书默认保持 root:genarrative,服务用户只做可读性复核。

更新证书同步测试,覆盖显式 target-user 的本机 apply 场景。
2026-06-17 22:44:10 +08:00

481 lines
14 KiB
JavaScript

#!/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 <path> --source-key-file <path> --target-dir <path> [options]
Options:
--source-cert-file <path> 已存在的证书链文件,通常是 Let's Encrypt live/fullchain.pem。
--source-key-file <path> 已存在的私钥文件,通常是 Let's Encrypt live/privkey.pem。
--target-dir <path> Pingora 私有 TLS 目录,例如 /etc/genarrative/pingora-tls/<host>。
--service-user <user|uid> 目标服务用户,默认 genarrative。
--service-group <group|gid>
目标文件组,默认 genarrative。
--target-user <user|uid> 目标文件 owner,默认 root。
--directory-mode <mode> 目标目录权限,默认 0750。
--file-mode <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')}`;
}