补齐 Pingora TLS 证书同步
新增 Pingora TLS 证书同步脚本,默认 dry-run,apply 时原子复制证书到私有目录并复核服务用户可读。 将证书同步脚本纳入 API release 构建、部署复制和 current release 自审清单。 补充发布包、deploy、自审和脚本自身门禁,覆盖缺失脚本、symlink 目标与权限边界。 更新 Pingora env 示例、运维文档和项目踩坑记录,明确不要放宽 Let’s Encrypt 原路径权限。
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
#!/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_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。
|
||||
--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,
|
||||
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 '--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');
|
||||
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.serviceUser, 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.serviceUser,
|
||||
group: config.serviceGroup,
|
||||
uid: owner.uid,
|
||||
gid: owner.gid,
|
||||
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')}`;
|
||||
}
|
||||
@@ -620,6 +620,7 @@ PINGORA_RELEASE_READINESS_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-rel
|
||||
PINGORA_ENABLE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-direct-enable.sh"
|
||||
PINGORA_ROLLBACK_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-direct-rollback.sh"
|
||||
PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
|
||||
PINGORA_TLS_CERT_SYNC_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs"
|
||||
PINGORA_PREFLIGHT_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-direct-preflight.mjs"
|
||||
PINGORA_LIVE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-direct-live.mjs"
|
||||
PINGORA_CANARY_LIVE_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/check-pingora-canary-live.mjs"
|
||||
@@ -707,6 +708,12 @@ if [[ ! -f "${PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE}" ]]; then
|
||||
fi
|
||||
cp "${PINGORA_HEALTH_PATROL_ENV_SWITCH_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
|
||||
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-health-patrol-env-switch.mjs"
|
||||
if [[ ! -f "${PINGORA_TLS_CERT_SYNC_SCRIPT_SOURCE}" ]]; then
|
||||
echo "[production-api-deploy] 发布产物缺少 Pingora TLS 证书同步脚本: ${SOURCE_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "${PINGORA_TLS_CERT_SYNC_SCRIPT_SOURCE}" "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs"
|
||||
chmod 0755 "${RELEASE_CONTENT_DIR}/scripts/deploy/pingora-tls-cert-sync.mjs"
|
||||
if [[ ! -f "${PINGORA_PREFLIGHT_SCRIPT_SOURCE}" ]]; then
|
||||
echo "[production-api-deploy] 发布产物缺少 Pingora 直连预检脚本: ${SOURCE_DIR}/scripts/check-pingora-direct-preflight.mjs" >&2
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user