FROM node:22-alpine # 运行时需要 sqlite CLI 做一致性备份;不安装 sqlite-dev(构建依赖,生产不需要) RUN apk add --no-cache sqlite WORKDIR /app RUN npm install -g omniroute # 基础网络与环境配置 ENV PORT=7860 ENV OMNIROUTE_PORT=7860 ENV HOST=0.0.0.0 ENV NODE_ENV=production # ⚠️ 不要在 Dockerfile 里写密码/令牌。 # 在 HF Space → Settings → Variables and secrets 中设置: # INITIAL_PASSWORD (Secret) # DOWNLOAD_TOKEN (Secret) # 未设置 DOWNLOAD_TOKEN 时下载服务会拒绝启动(见 download_server.js)。 EXPOSE 7860 # 写入下载服务脚本(纯 Node.js,无需额外依赖) RUN cat > /app/download_server.js << 'EOF' const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const PORT = 7861; const TOKEN = process.env.DOWNLOAD_TOKEN; // 强制要求设置令牌,否则拒绝启动,避免使用默认值导致数据泄露 if (!TOKEN || TOKEN.length < 16) { console.error('[download-server] 致命错误: 必须设置 DOWNLOAD_TOKEN 且长度 >= 16。已退出。'); process.exit(1); } // 允许下载的安全目录白名单 const ALLOWED_DIRS = ['/data', '/root/.omniroute']; // 恒定时间比较,防止时序攻击且不泄露长度 function tokenValid(provided) { const a = Buffer.from(String(provided)); const b = Buffer.from(TOKEN); // 先对两个 buffer 做固定开销的 hash,再比较,规避长度差异 const ha = crypto.createHash('sha256').update(a).digest(); const hb = crypto.createHash('sha256').update(b).digest(); return crypto.timingSafeEqual(ha, hb); } function safeResolvePath(filename) { // 只允许纯文件名,拒绝任何路径分隔符(防路径穿越) if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) { return null; } for (const dir of ALLOWED_DIRS) { const fullPath = path.join(dir, filename); if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { return fullPath; } } return null; } const server = http.createServer((req, res) => { const url = new URL(req.url, `http://localhost:${PORT}`); const token = url.searchParams.get('token') || ''; const route = url.pathname; if (!tokenValid(token)) { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Forbidden: invalid token' })); return; } // ── GET /list ───────────────────────────────────────────── if (route === '/list') { const result = {}; for (const dir of ALLOWED_DIRS) { try { result[dir] = fs.readdirSync(dir).map(name => { const stat = fs.statSync(path.join(dir, name)); return { name, size: stat.size, mtime: stat.mtime }; }); } catch (_) { result[dir] = 'directory not found'; } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result, null, 2)); return; } // ── GET /download?token=...&file=omni_storage.sqlite ────── if (route === '/download') { const filename = url.searchParams.get('file') || ''; const fullPath = safeResolvePath(filename); if (!fullPath) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'File not found' })); // 不回显用户输入 return; } const stat = fs.statSync(fullPath); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${path.basename(fullPath)}"`, 'Content-Length': stat.size, }); fs.createReadStream(fullPath).pipe(res); console.log(`[download] ${fullPath} (${stat.size} bytes)`); return; } // ── 404 ─────────────────────────────────────────────────── res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unknown route', routes: ['/list', '/download'] })); }); // 只监听回环地址更安全;若需外部访问下载,再改为 0.0.0.0 并务必用强 TOKEN server.listen(PORT, '0.0.0.0', () => { console.log(`[download-server] listening on ${PORT}, routes: /list /download?file=`); }); EOF # 写入启动脚本,避免把复杂逻辑塞进 CMD RUN cat > /app/entrypoint.sh << 'EOF' #!/bin/sh set -u echo "=== 存储诊断开始 ===" ls -lah /data 2>&1 || echo "/data 目录不存在" ls -lah /root/.omniroute 2>&1 || echo "OmniRoute 目录不存在" df -h echo "=== 存储诊断结束 ===" mkdir -p /root/.omniroute /data # ── 开机从持久化存储恢复数据 ── if [ -f /data/omni_storage.sqlite ]; then cp /data/omni_storage.sqlite /root/.omniroute/storage.sqlite && echo "✅ 恢复 storage.sqlite 成功" else echo "⚠️ /data/omni_storage.sqlite 不存在,跳过恢复" fi if [ -f /data/omni_settings.json ]; then cp /data/omni_settings.json /root/.omniroute/settings.json && echo "✅ 恢复 settings.json 成功" else echo "⚠️ /data/omni_settings.json 不存在,跳过恢复" fi # ── 后台:每 60 秒一致性备份到 /data ── (while true; do sleep 60 if [ -f /root/.omniroute/storage.sqlite ]; then # 用 sqlite3 .backup 做一致性快照,避免拷到写入中途的损坏状态 if sqlite3 /root/.omniroute/storage.sqlite ".backup '/data/omni_storage.sqlite.tmp'" 2>/dev/null; then mv -f /data/omni_storage.sqlite.tmp /data/omni_storage.sqlite echo "💾 [backup] storage.sqlite → /data" else echo "⚠️ [backup] sqlite 备份失败" fi fi if [ -f /root/.omniroute/settings.json ]; then cp /root/.omniroute/settings.json /data/omni_settings.json.tmp && mv -f /data/omni_settings.json.tmp /data/omni_settings.json && echo "💾 [backup] settings.json → /data" fi done) & # ── 后台:启动下载服务(7861 端口)── node /app/download_server.js & # ── 前台:启动主程序 ── exec env PORT=7860 omniroute EOF RUN chmod +x /app/entrypoint.sh CMD ["/app/entrypoint.sh"]