Spaces:
Paused
Paused
| 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=<name>`); | |
| }); | |
| 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"] |