764f3609d0
拆分 TLS 证书同步脚本的 target owner 与服务用户。 目标证书默认保持 root:genarrative,服务用户只做可读性复核。 更新证书同步测试,覆盖显式 target-user 的本机 apply 场景。
481 lines
14 KiB
JavaScript
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')}`;
|
|
}
|