v0.3.6: 恢复丢失的11个模块 + 接线基础设施
恢复内容: - quark驱动拆解为7个子模块 (quark-api/auth/share/storage/cleanup/rename/ad-cleanup) - 工具模块: utils/crypto, utils/logger, utils/proxy-agent - 配置校验: config/startup-validator - 接线: main.ts(checkStartup), credential.service.ts(加密Cookie), admin.routes.ts(代理测试) - quark.driver.ts 从1533行巨兽瘦身到130行壳子
This commit is contained in:
98
source_clean/src/utils/crypto.ts
Normal file
98
source_clean/src/utils/crypto.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption for protecting cloud drive cookies stored in DB.
|
||||
*
|
||||
* Encryption key is derived from COOKIE_ENCRYPTION_KEY env var via SHA-256.
|
||||
* If unset, uses a built-in default key (stable across container restarts).
|
||||
* Production MUST set COOKIE_ENCRYPTION_KEY!
|
||||
*/
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96-bit nonce for GCM
|
||||
const TAG_LENGTH = 16; // 128-bit auth tag
|
||||
const KEY_LENGTH = 32; // 256-bit key
|
||||
|
||||
let ENCRYPTION_KEY: Buffer | null = null;
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (ENCRYPTION_KEY) return ENCRYPTION_KEY;
|
||||
|
||||
const envKey = process.env.COOKIE_ENCRYPTION_KEY;
|
||||
if (envKey && envKey.length >= 32) {
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)');
|
||||
} else if (envKey) {
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)');
|
||||
} else {
|
||||
// Default stable key (not ephemeral) — data survives container restart
|
||||
ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest();
|
||||
console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)');
|
||||
}
|
||||
return ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag).
|
||||
*/
|
||||
export function encrypt(plaintext: string): string {
|
||||
if (!plaintext) return '';
|
||||
const key = getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv (12) + tag (16) + ciphertext
|
||||
const combined = Buffer.concat([iv, tag, encrypted]);
|
||||
return combined.toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt base64-encoded ciphertext. Returns original plaintext.
|
||||
* Returns empty string if decryption fails (corrupted data or wrong key).
|
||||
*/
|
||||
export function decrypt(encoded: string): string {
|
||||
if (!encoded) return '';
|
||||
try {
|
||||
const key = getKey();
|
||||
const combined = Buffer.from(encoded, 'base64');
|
||||
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
|
||||
console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)');
|
||||
return encoded;
|
||||
}
|
||||
const iv = combined.subarray(0, IV_LENGTH);
|
||||
const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
||||
const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH);
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) {
|
||||
console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is');
|
||||
return encoded;
|
||||
}
|
||||
console.error('[Crypto] Decryption error:', err.message);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string appears to be encrypted (base64 with IV+tag prefix).
|
||||
* Used for migration: re-encrypt legacy plaintext cookies.
|
||||
*/
|
||||
export function isEncrypted(value: string): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const combined = Buffer.from(value, 'base64');
|
||||
return combined.length > IV_LENGTH + TAG_LENGTH;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
77
source_clean/src/utils/logger.ts
Normal file
77
source_clean/src/utils/logger.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Structured logger with log levels.
|
||||
* Level controlled by LOG_LEVEL env var (debug|info|warn|error).
|
||||
* Default: 'info' in production, 'debug' otherwise.
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
module?: string;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
let currentLevel: LogLevel =
|
||||
(process.env.LOG_LEVEL as LogLevel) ||
|
||||
(process.env.NODE_ENV === 'production' ? 'info' : 'debug');
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
function formatLog(entry: LogEntry): string {
|
||||
const parts: string[] = [
|
||||
`[${entry.timestamp}]`,
|
||||
`[${entry.level.toUpperCase()}]`,
|
||||
];
|
||||
if (entry.module) parts.push(`[${entry.module}]`);
|
||||
parts.push(entry.message);
|
||||
if (entry.duration !== undefined) parts.push(`(${entry.duration}ms)`);
|
||||
if (entry.error) parts.push(`\n ${entry.error}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function log(level: LogLevel, message: string, module?: string, extra?: Partial<LogEntry>): void {
|
||||
if (!shouldLog(level)) return;
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
module,
|
||||
...extra,
|
||||
};
|
||||
const formatted = formatLog(entry);
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(formatted);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(formatted);
|
||||
break;
|
||||
default:
|
||||
console.log(formatted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (msg: string, module?: string) => log('debug', msg, module),
|
||||
info: (msg: string, module?: string) => log('info', msg, module),
|
||||
warn: (msg: string, module?: string) => log('warn', msg, module),
|
||||
error: (msg: string, module?: string, err?: Error) =>
|
||||
log('error', msg, module, err ? { error: err.stack || err.message } : undefined),
|
||||
/** Log with duration (for performance tracking) */
|
||||
perf: (msg: string, durationMs: number, module?: string) =>
|
||||
log('info', msg, module, { duration: durationMs }),
|
||||
};
|
||||
145
source_clean/src/utils/proxy-agent.ts
Normal file
145
source_clean/src/utils/proxy-agent.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Unified proxy agent — supports HTTP/HTTPS/SOCKS5/SOCKS5h protocols.
|
||||
*
|
||||
* Node 20+ native fetch() uses undici Dispatcher, but socks-proxy-agent
|
||||
* doesn't implement this interface.
|
||||
* Solution: use http.Agent interface + http/https.request().
|
||||
*/
|
||||
|
||||
let HttpsProxyAgent: any;
|
||||
let SocksProxyAgent: any;
|
||||
|
||||
try {
|
||||
HttpsProxyAgent = require('https-proxy-agent').HttpsProxyAgent;
|
||||
} catch {
|
||||
try {
|
||||
HttpsProxyAgent = require('https-proxy-agent');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
SocksProxyAgent = require('socks-proxy-agent').SocksProxyAgent;
|
||||
} catch {
|
||||
try {
|
||||
SocksProxyAgent = require('socks-proxy-agent');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Create an http.Agent for the given proxy URL (works with https.request) */
|
||||
function createProxyAgent(proxyUrl?: string): any {
|
||||
if (!proxyUrl || typeof proxyUrl !== 'string') return null;
|
||||
const trimmed = proxyUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
try {
|
||||
if (lower.startsWith('socks5://') || lower.startsWith('socks5h://')) {
|
||||
if (!SocksProxyAgent) {
|
||||
console.warn('[Proxy] socks-proxy-agent not installed');
|
||||
return null;
|
||||
}
|
||||
return new SocksProxyAgent(trimmed);
|
||||
}
|
||||
if (lower.startsWith('http://') || lower.startsWith('https://')) {
|
||||
if (!HttpsProxyAgent) {
|
||||
console.warn('[Proxy] No HTTP proxy agent available');
|
||||
return null;
|
||||
}
|
||||
return new HttpsProxyAgent(trimmed);
|
||||
}
|
||||
// Unknown scheme — try as HTTP proxy
|
||||
if (HttpsProxyAgent) return new HttpsProxyAgent(trimmed);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.error(`[Proxy] Failed to create proxy agent: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with proxy support.
|
||||
* Uses native fetch() when no proxy, or http/https.request() with agent when proxy is set.
|
||||
*/
|
||||
export async function proxiedFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
proxyUrl?: string
|
||||
): Promise<Response> {
|
||||
if (!proxyUrl) return fetch(url, init);
|
||||
|
||||
const agent = createProxyAgent(proxyUrl);
|
||||
if (!agent) return fetch(url, init);
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
const mod = parsedUrl.protocol === 'https:' ? require('https') : require('http');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (init?.headers) {
|
||||
const h = init.headers;
|
||||
if (h instanceof Headers) {
|
||||
h.forEach((v, k) => { headers[k] = v; });
|
||||
} else if (typeof h === 'object') {
|
||||
Object.assign(headers, h);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
||||
path: parsedUrl.pathname + parsedUrl.search,
|
||||
method: init?.method || 'GET',
|
||||
headers,
|
||||
agent,
|
||||
};
|
||||
|
||||
const req = mod.request(options, (res: any) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
resolve(new Response(body, {
|
||||
status: res.statusCode || 502,
|
||||
statusText: res.statusMessage || '',
|
||||
headers: new Headers(res.headers || {}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (init?.signal) {
|
||||
init.signal.addEventListener('abort', () => req.destroy());
|
||||
}
|
||||
if (init?.body) {
|
||||
req.write(
|
||||
typeof init.body === 'string' ? init.body :
|
||||
init.body instanceof Buffer ? init.body :
|
||||
init.body instanceof ArrayBuffer ? Buffer.from(init.body) :
|
||||
Buffer.from(String(init.body))
|
||||
);
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function testProxyConnection(
|
||||
proxyUrl: string,
|
||||
testUrl?: string
|
||||
): Promise<{ ok: boolean; latency: number; info: string }> {
|
||||
const target = testUrl || 'https://www.baidu.com';
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await proxiedFetch(target, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
}, proxyUrl);
|
||||
const latency = Date.now() - start;
|
||||
return { ok: true, latency, info: `连接成功 (${res.status})` };
|
||||
} catch (err: any) {
|
||||
return { ok: false, latency: Date.now() - start, info: `代理连接失败: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy compat — no longer returns dispatcher, kept for type compatibility
|
||||
export function createProxyDispatcher(proxyUrl?: string): { agent: any } | null {
|
||||
const agent = createProxyAgent(proxyUrl);
|
||||
return agent ? { agent } : null;
|
||||
}
|
||||
Reference in New Issue
Block a user