cangku/backend/src/services/resilientSecretStore.js

208 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const database = require('../config/database');
const SECRET_TABLE = 'system_secrets';
const SECRET_STORE_VERSION = 1;
let schemaReady = false;
let schemaPromise = null;
let backupWriteQueue = Promise.resolve();
function normalizeSecretKey(value) {
const key = String(value || '').trim();
if (!/^[a-z0-9][a-z0-9_.-]{2,119}$/i.test(key)) {
throw new Error('持久密钥名称格式无效');
}
return key;
}
function normalizeSecretValue(value) {
return String(value || '').trim().slice(0, 10000);
}
function firstSecret(values = []) {
for (const value of values) {
const normalized = normalizeSecretValue(value);
if (normalized) return normalized;
}
return '';
}
function getSecretBackupPath() {
const configured = String(process.env.SHUJUKU3_SECRET_STORE_PATH || '').trim();
return configured || path.join(os.homedir(), '.config', 'shujuku3', 'secrets.json');
}
function getLegacySecretBackupPath() {
const configured = String(process.env.SHUJUKU3_LEGACY_SECRET_STORE_PATH || '').trim();
return configured || path.join(os.homedir(), '.config', 'shujuku3', 'secrets.json');
}
function getSecretBackupCandidatePaths() {
return [...new Set([getSecretBackupPath(), getLegacySecretBackupPath()])];
}
function readSecretBackup(secretKey) {
const key = normalizeSecretKey(secretKey);
for (const filePath of getSecretBackupCandidatePaths()) {
if (!fs.existsSync(filePath)) continue;
try {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const value = normalizeSecretValue(parsed?.secrets?.[key]);
if (value) return value;
} catch (_error) {
// Continue to the legacy backup when the primary file is incomplete.
}
}
return '';
}
function writeSecretBackupNow(secretKey, secretValue) {
const key = normalizeSecretKey(secretKey);
const value = normalizeSecretValue(secretValue);
if (!value) throw new Error('持久密钥不能为空');
const filePath = getSecretBackupPath();
const directory = path.dirname(filePath);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
try { fs.chmodSync(directory, 0o700); } catch (_error) {}
let current = {};
if (fs.existsSync(filePath)) {
try { current = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}; } catch (_error) {}
}
const payload = {
version: SECRET_STORE_VERSION,
updatedAt: new Date().toISOString(),
secrets: {
...(current.secrets && typeof current.secrets === 'object' ? current.secrets : {}),
[key]: value,
},
};
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
fs.renameSync(temporaryPath, filePath);
try { fs.chmodSync(filePath, 0o600); } catch (_error) {}
}
function writeSecretBackup(secretKey, secretValue) {
const operation = backupWriteQueue.then(() => writeSecretBackupNow(secretKey, secretValue));
backupWriteQueue = operation.catch(() => {});
return operation;
}
async function ensureSecretSchema() {
if (schemaReady) return;
if (schemaPromise) return schemaPromise;
schemaPromise = database.local.query(
`CREATE TABLE IF NOT EXISTS ${SECRET_TABLE} (
secret_key VARCHAR(120) PRIMARY KEY COMMENT '密钥用途',
secret_value TEXT NOT NULL COMMENT '仅服务端读取的密钥值',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统持久密钥'`
).then(() => {
schemaReady = true;
}).finally(() => {
schemaPromise = null;
});
return schemaPromise;
}
async function readDatabaseSecret(secretKey) {
const key = normalizeSecretKey(secretKey);
await ensureSecretSchema();
const rows = await database.local.query(
`SELECT secret_value AS secretValue FROM ${SECRET_TABLE} WHERE secret_key = ? LIMIT 1`,
[key]
);
return normalizeSecretValue(rows[0]?.secretValue);
}
async function writeDatabaseSecret(secretKey, secretValue) {
const key = normalizeSecretKey(secretKey);
const value = normalizeSecretValue(secretValue);
if (!value) throw new Error('持久密钥不能为空');
await ensureSecretSchema();
await database.local.query(
`INSERT INTO ${SECRET_TABLE} (secret_key, secret_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE secret_value = VALUES(secret_value), updated_at = CURRENT_TIMESTAMP`,
[key, value]
);
return value;
}
async function backupSecretQuietly(secretKey, secretValue) {
try {
await writeSecretBackup(secretKey, secretValue);
} catch (error) {
console.warn(`持久密钥文件备份失败(${secretKey}):`, error.message);
}
}
async function restoreDatabaseSecretQuietly(secretKey, secretValue) {
try {
await writeDatabaseSecret(secretKey, secretValue);
} catch (error) {
console.warn(`持久密钥数据库恢复失败(${secretKey}):`, error.message);
}
}
async function getResilientSecret(secretKey, options = {}) {
const key = normalizeSecretKey(secretKey);
try {
const databaseValue = await readDatabaseSecret(key);
if (databaseValue) {
await backupSecretQuietly(key, databaseValue);
return { value: databaseValue, source: 'database' };
}
} catch (error) {
console.warn(`读取持久密钥数据库失败(${key}):`, error.message);
}
const legacyValue = firstSecret(options.legacyValues);
if (legacyValue) {
await restoreDatabaseSecretQuietly(key, legacyValue);
await backupSecretQuietly(key, legacyValue);
return { value: legacyValue, source: 'legacy-database' };
}
const backupValue = readSecretBackup(key);
if (backupValue) {
await restoreDatabaseSecretQuietly(key, backupValue);
await backupSecretQuietly(key, backupValue);
return { value: backupValue, source: 'persistent-backup' };
}
const fallbackValue = firstSecret(options.fallbackValues);
if (fallbackValue) {
await restoreDatabaseSecretQuietly(key, fallbackValue);
await backupSecretQuietly(key, fallbackValue);
return { value: fallbackValue, source: 'environment' };
}
return { value: '', source: 'unconfigured' };
}
async function saveResilientSecret(secretKey, secretValue) {
const key = normalizeSecretKey(secretKey);
const value = await writeDatabaseSecret(key, secretValue);
await backupSecretQuietly(key, value);
return { value, source: 'database' };
}
module.exports = {
SECRET_TABLE,
ensureSecretSchema,
getResilientSecret,
getSecretBackupCandidatePaths,
getSecretBackupPath,
readSecretBackup,
saveResilientSecret,
writeSecretBackup,
};