Spaces:
Paused
Paused
File size: 6,319 Bytes
262a183 0216deb 7bffa9f 2a2fa33 01e6a7a ad533eb c18cf88 956a293 47b9f31 2a2fa33 0216deb 9e8330a c18cf88 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb 9e8330a 0216deb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | 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"] |