forked from lunanting/cangku
208 lines
6.7 KiB
JavaScript
208 lines
6.7 KiB
JavaScript
'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,
|
||
};
|