diff --git a/next.config.js b/next.config.js index db8e508..9b71e89 100644 --- a/next.config.js +++ b/next.config.js @@ -9,6 +9,10 @@ const nextConfig = { reactStrictMode: false, swcMinify: true, + experimental: { + instrumentationHook: true, + }, + // Uncoment to add domain whitelist images: { unoptimized: true, diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..5382a8a --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,689 @@ +/* eslint-disable */ +/** + * Next.js Instrumentation Hook + * 在应用启动时执行关键检查,失败时立即退出 + */ + +import * as crypto from 'crypto'; + +// 认证相关接口定义 +export interface APIResponse { + success: boolean; + message: string; + data?: any; + timestamp: number; + signature: string; + server_fingerprint: string; +} + +export interface ServerInfo { + encrypted_public_key: string; + fingerprint: string; + encryption_method: string; + note: string; +} + +// API密钥 - 用于解密公钥 +const API_SECRET = 'moontv-is-the-best'; + +// 验证服务器地址 +const AUTH_SERVER = process.env.AUTH_SERVER || 'https://moontv-auth.ihtw.moe'; + +// 全局变量存储公钥和指纹 +let serverPublicKey: crypto.KeyObject | null = null; +let expectedFingerprint = ''; + +// 验证相关的全局变量 +let verificationTimer: NodeJS.Timeout | null = null; +let networkFailureCount = 0; +const MAX_NETWORK_FAILURES = 3; +let currentMachineCode = ''; + +/** + * 使用AES-GCM解密数据 + */ +function decryptWithAES(encryptedData: string, key: string): string { + try { + // 将密钥转换为32字节(SHA256哈希) + const keyHash = crypto.createHash('sha256').update(key).digest(); + + // Base64解码密文 + const encryptedBytes = Buffer.from(encryptedData, 'base64'); + + // 提取nonce(前12字节)和密文 + const nonceSize = 12; + const nonce = encryptedBytes.slice(0, nonceSize); + const ciphertext = encryptedBytes.slice(nonceSize, -16); // 除去最后16字节的认证标签 + const tag = encryptedBytes.slice(-16); // 最后16字节是认证标签 + + // 创建AES-GCM解密器 + const decipher = crypto.createDecipheriv('aes-256-gcm', keyHash, nonce); + decipher.setAuthTag(tag); + + const decrypted = decipher.update(ciphertext); + const final = decipher.final(); + + // 合并 Buffer 并转换为字符串 + const result = Buffer.concat([decrypted, final]); + return result.toString('utf8'); + } catch (error) { + throw new Error(`AES解密失败: ${error instanceof Error ? error.message : '未知错误'}`); + } +} + +/** + * 从验证服务器获取公钥 + */ +async function fetchServerPublicKey(): Promise<{ publicKey: string, fingerprint: string }> { + try { + // 设置10秒超时 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${AUTH_SERVER}/api/public_key`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'MoonTV/1.0.0' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const apiResp: APIResponse = await response.json(); + + if (!apiResp.success) { + throw new Error(`API错误: ${apiResp.message}`); + } + + const serverInfo = apiResp.data as ServerInfo; + const encryptedPublicKey = serverInfo.encrypted_public_key; + const serverFingerprint = serverInfo.fingerprint; + const decryptedPublicKeyPem = decryptWithAES(encryptedPublicKey, API_SECRET); + + console.log('✅ 公钥解密成功'); + + return { publicKey: decryptedPublicKeyPem, fingerprint: serverFingerprint }; + + } catch (error) { + throw new Error(`获取服务器公钥失败: ${error instanceof Error ? error.message : '未知错误'}`); + } +} + +/** + * 验证API响应的签名 + */ +async function verifyResponse(apiResp: APIResponse, requestTimestamp: string): Promise { + if (!serverPublicKey) { + throw new Error('未获取服务器公钥'); + } + + // 验证服务器指纹 + if (expectedFingerprint && apiResp.server_fingerprint !== expectedFingerprint) { + throw new Error('服务器指纹不匹配,可能是伪造的服务器'); + } + + try { + // 现在服务端只对时间戳字符串进行签名,而不是整个响应对象 + // 使用我们发送请求时的时间戳,而不是响应中的时间戳 + const timestampToVerify = requestTimestamp; + const verified = await verifyTimestampSignature(timestampToVerify, apiResp.signature); + + if (!verified) { + throw new Error('时间戳签名验证失败'); + } + + } catch (error) { + throw new Error(`签名验证失败: ${error instanceof Error ? error.message : '未知错误'}`); + } +} + +/** + * 验证时间戳的RSA签名(服务端现在只对时间戳字符串进行签名) + */ +async function verifyTimestampSignature(timestamp: string, signature: string): Promise { + try { + if (!serverPublicKey) { + console.error('❌ 服务器公钥未初始化'); + return false; + } + + // 将时间戳转换为字符串(与Go服务端保持一致) + const timestampString = String(timestamp); + + // 将十六进制签名转换为Buffer + const signatureBuffer = Buffer.from(signature, 'hex'); + + // 使用正确的方法:验证原始时间戳字符串 + // Go服务端实际上是对原始时间戳字符串进行签名的 + const verifier = crypto.createVerify('RSA-SHA256'); + verifier.update(timestampString, 'utf8'); + + const result = verifier.verify(serverPublicKey, signatureBuffer); + + return result; + } catch (error) { + console.error('❌ 时间戳签名验证出错:', error); + return false; + } +} + +/** + * 模拟Go的json.Marshal行为进行JSON序列化 + * Go对map[string]interface{}会按键的字母顺序排序 + */ +function serializeAsGoJsonMarshal(obj: any): string { + if (obj === null) return 'null'; + if (obj === undefined) return 'undefined'; + + if (typeof obj === 'string') { + return JSON.stringify(obj); + } + + if (typeof obj === 'number' || typeof obj === 'boolean') { + return String(obj); + } + + // 处理BigInt类型 + if (typeof obj === 'bigint') { + return String(obj); + } + + if (Array.isArray(obj)) { + const items = obj.map(item => serializeAsGoJsonMarshal(item)); + return '[' + items.join(',') + ']'; + } + + if (typeof obj === 'object') { + // 按键的字母顺序排序(Go的map[string]interface{}行为) + const sortedKeys = Object.keys(obj).sort(); + const pairs: string[] = []; + + for (const key of sortedKeys) { + if (obj[key] !== undefined) { + const serializedKey = JSON.stringify(key); + const serializedValue = serializeAsGoJsonMarshal(obj[key]); + pairs.push(`${serializedKey}:${serializedValue}`); + } + } + + return '{' + pairs.join(',') + '}'; + } + + // 处理其他类型,包括可能的BigInt + try { + return JSON.stringify(obj); + } catch (error) { + // 如果JSON.stringify失败(比如因为BigInt),尝试转换为字符串 + if (error instanceof TypeError && error.message.includes('BigInt')) { + return String(obj); + } + throw error; + } +} + +/** + * 注册设备到认证服务器 + */ +async function registerDevice(authCode: string, deviceCode: string) { + try { + // 用户数量设置为0 + const userCount = 0; + + // 生成请求时间戳 + const requestTimestamp = Date.now().toString(); + + // 设置10秒超时 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${AUTH_SERVER}/api/register_device`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'MoonTV/1.0.0' + }, + body: JSON.stringify({ + auth_code: authCode, + device_code: deviceCode, + user_count: userCount, + timestamp: requestTimestamp + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const responseBody = await response.text(); + const apiResp: APIResponse = JSON.parse(responseBody); + + // 验证响应签名(使用我们发送的时间戳) + await verifyResponse(apiResp, requestTimestamp); + + if (!apiResp.success) { + throw new Error(`设备注册失败: ${apiResp.message}`); + } + + console.log(`✅ 设备注册成功`); + } catch (error) { + throw new Error(`设备注册失败: ${error instanceof Error ? error.message : '未知错误'}`); + } +} + +/** + * 验证设备状态 + */ +async function verifyDevice(): Promise { + try { + console.log('🔄 开始设备验证...'); + + // 用户数量设置为0 + const userCount = 0; + + // 生成请求时间戳 + const requestTimestamp = Date.now().toString(); + + // 设置10秒超时 + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(`${AUTH_SERVER}/api/verify_device`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'MoonTV/1.0.0' + }, + body: JSON.stringify({ + device_code: currentMachineCode, + auth_code: process.env.AUTH_TOKEN || '', + user_count: userCount, + timestamp: requestTimestamp + }), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const responseBody = await response.text(); + const apiResp: APIResponse = JSON.parse(responseBody); + + // 验证响应签名(使用我们发送的时间戳) + await verifyResponse(apiResp, requestTimestamp); + + if (!apiResp.success) { + console.error('❌ 设备验证失败,服务器即将退出'); + console.error(`验证失败原因: ${apiResp.message}`); + process.exit(1); + } + + // 重置网络失败计数 + networkFailureCount = 0; + console.log(`✅ 设备验证通过,用户数量: ${userCount}`); + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '未知错误'; + + // 判断是否为网络问题 + const isNetworkError = errorMessage.includes('fetch') || + errorMessage.includes('timeout') || + errorMessage.includes('ECONNREFUSED') || + errorMessage.includes('ETIMEDOUT') || + errorMessage.includes('aborted'); + + if (isNetworkError) { + networkFailureCount++; + console.warn(`⚠️ 网络验证失败 (${networkFailureCount}/${MAX_NETWORK_FAILURES}): ${errorMessage}`); + + if (networkFailureCount >= MAX_NETWORK_FAILURES) { + console.error('❌ 网络验证失败次数超过限制,服务器即将退出'); + process.exit(1); + } + + // 5分钟后重试 + console.log('🔄 将在 5 分钟后重试验证...'); + setTimeout(() => { + verifyDevice().catch(err => { + console.error('验证重试失败:', err); + }); + }, 5 * 60 * 1000); // 5分钟 + + } else { + // 非网络错误,直接退出 + console.error('❌ 设备验证失败,服务器即将退出'); + console.error(`验证失败原因: ${errorMessage}`); + process.exit(1); + } + } +} + +/** + * 启动定时验证 + */ +function startPeriodicVerification(): void { + console.log('⏰ 启动定时设备验证 (每小时一次)'); + + // 清除现有的定时器(如果有) + if (verificationTimer) { + clearInterval(verificationTimer); + } + + // 设置每小时验证一次 + verificationTimer = setInterval(() => { + verifyDevice().catch(err => { + console.error('定时验证失败:', err); + }); + }, 60 * 60 * 1000); // 1小时 +} + +/** + * 停止定时验证 + */ +function stopPeriodicVerification(): void { + if (verificationTimer) { + clearInterval(verificationTimer); + verificationTimer = null; + console.log('⏹️ 定时验证已停止'); + } +} + +/** + * 环境变量检查 + */ +function checkEnvironment(): void { + // 检查 USERNAME + const username = process.env.USERNAME; + if (!username || username.trim() === '') { + console.error('❌ USERNAME 环境变量不得为空'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + // 检查 PASSWORD + const password = process.env.PASSWORD; + if (!password || password.trim() === '') { + console.error('❌ PASSWORD 环境变量不得为空'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + // 检查弱密码 + const weakPasswords = [ + 'admin_password', + 'password', + '123456', + 'admin', + 'root', + 'password123', + '12345678', + 'qwerty', + 'abc123', + 'admin123', + 'test123', + 'password1', + '000000', + '111111', + '11111111112233', + '112233', + '123123', + '123321', + '654321', + '666666', + '888888', + 'abcdef', + 'abcabc', + 'a1b2c3', + 'aaa111', + '123qwe', + 'qweasd' + ]; + + if (weakPasswords.includes(password.toLowerCase())) { + console.error(`❌ PASSWORD 不能使用常见弱密码: ${password}`); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + if (password.length < 8) { + console.error('❌ PASSWORD 长度不能少于8位'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + // 检查密码不能与用户名相同 + if (password.toLowerCase() === username.toLowerCase()) { + console.error('❌ PASSWORD 不能与 USERNAME 相同'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + // 检查 AUTH_TOKEN + const authToken = process.env.AUTH_TOKEN; + if (!authToken || authToken.trim() === '') { + console.error('❌ AUTH_TOKEN 不得为空'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + + // 检查 AUTH_SERVER(可选,但如果设置了需要验证格式) + const authServer = process.env.AUTH_SERVER; + if (authServer && authServer.trim() !== '') { + if (!authServer.startsWith('https://') && !authServer.startsWith('http://')) { + console.error('❌ AUTH_SERVER 必须以 http:// 或 https:// 开头'); + console.error('🚨 环境变量检查失败,服务器即将退出'); + process.exit(1); + } + } +} + +/** + * 认证检查 + */ +async function checkAuthentication(): Promise { + // 获取环境变量 + const authToken = process.env.AUTH_TOKEN; + const username = process.env.USERNAME; + const password = process.env.PASSWORD; + + if (!authToken || !username || !password) { + console.error('❌ 认证检查失败:缺少必需的环境变量'); + console.error('🚨 认证检查失败,服务器即将退出'); + process.exit(1); + } + + try { + // 第一步:生成机器码 + const combinedString = authToken + username + password; + const encoder = new TextEncoder(); + const data = encoder.encode(combinedString); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + const machineCode = hashHex.substring(0, 16); + currentMachineCode = machineCode; // 保存到全局变量 + + // 第二步:从验证服务器获取公钥 + const { publicKey, fingerprint } = await fetchServerPublicKey(); + + // 设置全局变量供签名验证使用 + // 将PEM格式的公钥字符串转换为KeyObject + try { + serverPublicKey = crypto.createPublicKey({ + key: publicKey, + format: 'pem', + type: 'spki' + }); + } catch (keyError) { + console.error('❌ 公钥KeyObject创建失败:', keyError); + throw new Error(`公钥格式错误: ${keyError instanceof Error ? keyError.message : '未知错误'}`); + } + expectedFingerprint = fingerprint; + + console.log('🔑 公钥获取成功,准备进行设备注册'); + + // 第三步:注册设备 + // 使用机器码作为认证码和设备码 + const deviceCode = machineCode; + await registerDevice(authToken, deviceCode); + + console.log('🎉 设备认证流程完成'); + + // 启动定时验证 + startPeriodicVerification(); + } catch (error) { + console.error('❌ 认证流程失败:', error instanceof Error ? error.message : '未知错误'); + console.error('🚨 认证检查失败,服务器即将退出'); + process.exit(1); + } +} + +/** + * 数据库配置检查 + */ +function checkDatabaseConfig(): void { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + + // 检查存储类型配置 + const allowedStorageTypes = ['localstorage', 'kvrocks', 'upstash', 'redis']; + if (!allowedStorageTypes.includes(storageType)) { + console.error(`❌ NEXT_PUBLIC_STORAGE_TYPE 必须是 ${allowedStorageTypes.join(', ')} 之一,当前值: ${storageType}`); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + + // 根据存储类型检查相应的环境变量 + switch (storageType) { + case 'kvrocks': + const kvrocksUrl = process.env.KVROCKS_URL; + if (!kvrocksUrl || kvrocksUrl.trim() === '') { + console.error('❌ KVROCKS_URL 环境变量不得为空'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + if (!kvrocksUrl.startsWith('redis://')) { + console.error('❌ KVROCKS_URL 必须以 redis:// 开头'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + break; + + case 'upstash': + const upstashUrl = process.env.UPSTASH_URL; + const upstashToken = process.env.UPSTASH_TOKEN; + + if (!upstashUrl || upstashUrl.trim() === '') { + console.error('❌ UPSTASH_URL 环境变量不得为空'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + if (!upstashUrl.startsWith('https://')) { + console.error('❌ UPSTASH_URL 必须以 https:// 开头'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + + if (!upstashToken || upstashToken.trim() === '') { + console.error('❌ UPSTASH_TOKEN 环境变量不得为空'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + break; + + case 'redis': + const redisUrl = process.env.REDIS_URL; + if (!redisUrl || redisUrl.trim() === '') { + console.error('❌ REDIS_URL 环境变量不得为空'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + if (!redisUrl.startsWith('redis://')) { + console.error('❌ REDIS_URL 必须以 redis:// 开头'); + console.error('🚨 数据库配置检查失败,服务器即将退出'); + process.exit(1); + } + break; + } +} + +/** + * 执行启动检查并在失败时退出 + */ +async function runCriticalStartupChecks(): Promise { + console.log('🔧 执行关键启动检查...'); + + // 1. 环境变量检查 + console.log('📝 检查环境变量...'); + checkEnvironment(); + console.log('✅ 环境变量检查通过'); + + // 2. 认证检查 + console.log('🔐 检查认证信息...'); + await checkAuthentication(); + console.log('✅ 认证检查通过'); + + // 3. 数据库配置检查 + console.log('🗄️ 检查数据库配置...'); + checkDatabaseConfig(); + console.log('✅ 数据库配置检查通过'); + + console.log('🎉 所有关键检查通过,服务器正常启动'); +} + +/** + * Next.js Instrumentation Hook + * 这个函数会在应用启动时自动被 Next.js 调用 + */ +export async function register() { + // 只在服务器端运行 + if (typeof window === 'undefined') { + console.log('🚀 MoonTV 启动检查开始...'); + + // 注册进程退出事件处理 + process.on('SIGINT', () => { + console.log('\n🛑 收到 SIGINT 信号,正在优雅关闭...'); + stopPeriodicVerification(); + process.exit(0); + }); + + process.on('SIGTERM', () => { + console.log('\n🛑 收到 SIGTERM 信号,正在优雅关闭...'); + stopPeriodicVerification(); + process.exit(0); + }); + + try { + await runCriticalStartupChecks(); + } catch (error) { + console.error('💥 启动检查过程中发生未预期错误:', error); + console.error('🚨 服务器即将退出'); + stopPeriodicVerification(); + process.exit(1); + } + } +} + +// 导出检查函数供其他模块使用(如果需要) +export { + checkAuthentication, + checkDatabaseConfig, + checkEnvironment, + decryptWithAES, + fetchServerPublicKey, + startPeriodicVerification, + stopPeriodicVerification, + verifyResponse, + verifyTimestampSignature, + serializeAsGoJsonMarshal +}; \ No newline at end of file