feat(fsys): C++ 重写 static 模块(libhttp/libmsg 实现 + 安全审计 + 测试页)

This commit is contained in:
qing committed 2026-08-08 06:58:09 +00:00
1 parent 575c2a27a4
commit 78bf74b067
11 files changed
+2227

No files matched your search

+1
View File
@@ -0,0 +1 @@
build/
+88
View File
@@ -0,0 +1,88 @@
# fsys Dockerfile — C++20 静态文件托管服务
# 多阶段构建:第一阶段编译 libhttp + libmsg + fsys,第二阶段精简运行时
# ============================================================================
# Stage 1: 构建阶段
# ============================================================================
FROM ubuntu:24.04 AS builder
LABEL maintainer="qingsong"
LABEL service="fsys"
LABEL description="Static File Hosting Service (C++20) - 静态文件托管服务"
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libboost-json-dev \
libssl-dev \
libcurl4-openssl-dev \
make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY libhttp/ /build/libhttp/
COPY libmsg/ /build/libmsg/
COPY fsys/ /build/fsys/
# 编译 libmsg
RUN cd /build/libmsg && make clean && make
# 编译 libhttp
RUN cd /build/libhttp && make clean && make \
CPP="g++" \
INCS="-I inc" \
SERVER_LIBS="-lssl -lcrypto -lpthread" \
CLIENT_LIBS="-lssl -lcrypto -lcurl -lpthread"
# 编译 fsys
RUN cd /build/fsys && make clean && make \
CPP="g++" \
INCS="-I ../libhttp/inc -I ../libmsg/inc" \
LIBS="-L ../libhttp/build -L ../libmsg/build -lus_http -lus_httpclient -lus_msg -lboost_json -lssl -lcrypto -lcurl -lpthread" \
RPATH="-Wl,-rpath,/app/lib"
# ============================================================================
# Stage 2: 运行时阶段
# ============================================================================
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
libboost-json1.83.0 \
libssl3 \
libcurl4 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1000 appuser
WORKDIR /app
# 复制构建产物
COPY --from=builder /build/fsys/build/fsys /app/fsys
COPY --from=builder /build/libhttp/build/libus_http.so /app/lib/libus_http.so
COPY --from=builder /build/libhttp/build/libus_httpclient.so /app/lib/libus_httpclient.so
COPY --from=builder /build/libmsg/build/libus_msg.so /app/lib/libus_msg.so
# 复制静态文件(public 目录)
COPY static/public/ /app/public/
ENV LD_LIBRARY_PATH=/app/lib
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 9200
# 健康检查:GET /health 期望 200
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -sf http://localhost:9200/health -o /dev/null || exit 1
ENV LISTEN_ADDR=0.0.0.0 \
LISTEN_PORT=9200 \
STATIC_ROOT=/app/public
CMD ["/app/fsys"]
+26
View File
@@ -0,0 +1,26 @@
CPP = g++
CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe
INCS = -I ../libhttp/inc -I ../libmsg/inc -I /opt/homebrew/include
LIBS = -L ../libhttp/build -L ../libmsg/build -L /opt/homebrew/lib \
-lus_http -lus_httpclient -lus_msg -lboost_json \
-lssl -lcrypto -lcurl -lpthread
RPATH = -Wl,-rpath,'$$ORIGIN/../../libhttp/build' \
-Wl,-rpath,'$$ORIGIN/../../libmsg/build' \
-Wl,-rpath,/opt/homebrew/lib
BUILD = build
TARGET = $(BUILD)/fsys
SRC = src/fsys.cpp
all: $(TARGET)
$(BUILD):
mkdir -p $(BUILD)
$(TARGET): $(SRC) | $(BUILD)
$(CPP) $(CPPFLAGS) $(INCS) -o $@ $< $(LIBS) $(RPATH)
clean:
rm -f $(TARGET)
.PHONY: all clean
+113
View File
@@ -0,0 +1,113 @@
# fsys — 静态文件托管服务 (C++20)
基于 libhttp + libmsg 的 C++20 静态文件托管与前端页面服务,监听 `127.0.0.1:9200`。
Python/Flask `static` 模块的生产级 C++ 重写,接口 100% 兼容,前端零改动。
## 架构
```
waf :443 (TLS 终止 + 安全规则)
│
▼
gateway :8443 (反向代理 + 认证)
│
▼
fsys :9200 (libhttp 异步多线程 + CORS)
│
├── public/ 静态文件根目录
├── dnfs2 :8006 笔记代理(反向代理)
├── db2 :8005 用户查询 + 日志写入
└── confs :8010 配置拉取
```
## 路由
### 核心路由
| 路径 | 说明 |
|------|------|
| `GET /` | 首页(优先 index.html,回退 __view.html) |
| `GET /*` | 静态文件 / 目录浏览 |
| `GET /api/filelist?path=` | JSON 文件列表,过滤 `__` 前缀系统文件 |
| `GET /video/*` | 视频/音频流媒体(HTTP Range 206 分段) |
| `GET /health` | 健康检查 |
### 内部工具路由
| 路径 | 说明 |
|------|------|
| `POST /api/hash` | SHA-256 密码哈希(限速 20/min/IP) |
| `POST /api/check_user` | 检查用户是否已注册(限速 10/min/IP) |
| `GET /api/favicon?url=` | favicon SSRF 防护代理(512KB 上限,1 天缓存) |
### dnfs2 代理路由
| 路径 | 说明 |
|------|------|
| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs2:8006) |
| `POST /add_note` | 新增笔记(代理至 dnfs2:8006) |
| `POST /delete_note` | 删除笔记(代理至 dnfs2:8006) |
| `POST /update_note` | 更新笔记(代理至 dnfs2:8006) |
支持 HttpOnly Cookie 模式:`q4_token` Cookie 自动转为 `Authorization: Bearer` 头。
## 配置
与 `static` 模块完全兼容,使用相同的 `static.ini`(由 confs 服务分发)。
| Section | Key | 说明 |
|---------|-----|------|
| `[server]` | `host` | 监听地址(默认 127.0.0.1) |
| `[server]` | `port` | 监听端口(默认 9200) |
| `[directory]` | `root` | 静态文件根目录(相对路径基于工作目录,_run.sh 以模块目录启动) |
| `[ssl]` | `enabled` | TLS 开关(默认 false) |
| `[ssl]` | `cert_file` | 证书路径 |
| `[ssl]` | `key_file` | 私钥路径 |
| `[cors]` | `allowed_origins` | CORS 白名单,逗号分隔 |
### 环境变量
| 变量 | 说明 |
|------|------|
| `LISTEN_ADDR` | 覆盖监听地址 |
| `LISTEN_PORT` | 覆盖监听端口 |
| `STATIC_ROOT` | 覆盖静态文件根目录 |
| `DB_HOST` / `DB_PORT` | 覆盖 db 服务地址 |
| `DNFS_URL` | 覆盖 dnfs2 上游地址 |
| `CONFS_HOST` / `CONFS_PORT` | 覆盖 confs 引导地址 |
### 命令行参数
| 参数 | 说明 |
|------|------|
| `--addr` / `-a` | 监听地址 |
| `--port` / `-p` | 监听端口 |
| `--threads` / `-t` | 工作线程数 |
## 相比 Python static 的改进
| 改进 | 说明 |
|------|------|
| 请求体大小限制 | `setMaxBodySize(10MB)` 防 OOM |
| SSL 原生支持 | libhttp 构造器直接支持,无需回退开发服务器 |
| 异步 IO | Boost.Beast 异步多线程替代 Flask 同步 |
| 消除 Python 依赖 | 不再需要 venv/pip/Flask/waitress |
| 命令行参数 | 支持 `--addr/-a --port/-p --threads/-t` |
| 数值范围校验 | 所有端口/线程数有严格边界检查 |
| 统一日志配置 | 复用主配置 confs 拉取的 db host/port |
## 运行
```bash
bash _set.sh # 编译
bash _run.sh # 启动
```
## 依赖
libhttp, libmsg, boost::json, OpenSSL, libcurl
## 许可证
MIT License
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
exec ./build/fsys "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
make
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>fsys 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f1419; color: #e6e6e6; padding: 32px 16px; }
.wrap { max-width: 860px; margin: 0 auto; }
h1 { font-size: 22px; margin-bottom: 4px; }
.sub { color: #8b98a5; font-size: 13px; margin-bottom: 24px; }
.card { background: #1a2027; border: 1px solid #2a3441; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
.card h2 { font-size: 14px; color: #9fb3c8; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
td { padding: 7px 8px; border-bottom: 1px solid #232d38; vertical-align: top; }
td.name { width: 34%; color: #d7e2ec; }
td.st { width: 90px; font-weight: 600; }
.ok { color: #4ade80; } .fail { color: #f87171; } .pending { color: #8b98a5; }
td.detail { color: #8b98a5; font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
button { background: #2563eb; color: #fff; border: 0; border-radius: 8px; padding: 10px 22px;
font-size: 14px; cursor: pointer; margin-bottom: 18px; }
button:hover { background: #1d4ed8; }
.links a { color: #60a5fa; text-decoration: none; margin-right: 14px; font-size: 13px; }
.links a:hover { text-decoration: underline; }
#summary { font-size: 14px; margin-bottom: 14px; }
</style>
</head>
<body>
<div class="wrap">
<h1>fsys 测试页</h1>
<div class="sub">C++ 静态文件托管服务 · 路由/安全功能自检</div>
<button onclick="runAll()">▶ 运行全部测试</button>
<div id="summary"></div>
<div class="card">
<h2>API 自检</h2>
<table id="tbl">
<tr data-t="health"><td class="name">GET /health</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="filelist_root"><td class="name">GET /api/filelist(根目录)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="filelist_sub"><td class="name">GET /api/filelist?path=test%2F(编码子目录)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="filelist_traverse"><td class="name">GET /api/filelist?path=..%2F..(遍历应 403)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="static_file"><td class="name">GET /test/hello.txt(静态文件)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="download"><td class="name">GET /test/hello.txt?download=true(附件头)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="utf8_path"><td class="name">GET 中文文件名(URL 解码)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="hash"><td class="name">POST /api/hash(sha256("test123"))</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="hash_bad"><td class="name">POST /api/hash 非法输入(应 400)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="check_user"><td class="name">POST /api/check_user(结构校验)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="favicon_private"><td class="name">GET /api/favicon 内网地址(SSRF 应 403)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="favicon_public"><td class="name">GET /api/favicon 公网地址(需外网)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="headers"><td class="name">安全响应头(CSP/X-Frame-Options…)</td><td class="st pending">—</td><td class="detail"></td></tr>
<tr data-t="notfound"><td class="name">GET /不存在的路径(应 404)</td><td class="st pending">—</td><td class="detail"></td></tr>
</table>
</div>
<div class="card links">
<h2>手动验证链接</h2>
<a href="/api/filelist?path=" target="_blank">filelist 根</a>
<a href="/api/filelist?path=test" target="_blank">filelist test/</a>
<a href="/test/hello.txt" target="_blank">hello.txt</a>
<a href="/test/hello.txt?download=true" target="_blank">下载模式</a>
<a href="/health" target="_blank">health</a>
</div>
</div>
<script>
const EXPECT_HASH = 'ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae';
function setRow(key, ok, detail) {
const tr = document.querySelector(`tr[data-t="${key}"]`);
if (!tr) return;
const st = tr.children[1], dt = tr.children[2];
st.textContent = ok ? 'PASS' : 'FAIL';
st.className = 'st ' + (ok ? 'ok' : 'fail');
dt.textContent = detail || '';
}
async function j(url, opt) {
const res = await fetch(url, opt);
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch (e) {}
return { res, text, json };
}
const tests = {
async health() {
const { res, json } = await j('/health');
const ok = res.status === 200 && json && json.status === 'ok';
setRow('health', ok, `HTTP ${res.status} ${JSON.stringify(json)}`);
},
async filelist_root() {
const { res, json } = await j('/api/filelist?path=');
const ok = res.status === 200 && json && json.success === true && Array.isArray(json.items);
setRow('filelist_root', ok, `HTTP ${res.status}, ${json && json.items ? json.items.length + ' 项' : '无 items'}`);
},
async filelist_sub() {
const { res, json } = await j('/api/filelist?path=' + encodeURIComponent('test'));
const hasHello = json && Array.isArray(json.items) && json.items.some(i => i.name === 'hello.txt');
setRow('filelist_sub', res.status === 200 && hasHello, `HTTP ${res.status}, hello.txt ${hasHello ? '可见' : '缺失'}`);
},
async filelist_traverse() {
const { res } = await j('/api/filelist?path=' + encodeURIComponent('../../'));
setRow('filelist_traverse', res.status === 403, `HTTP ${res.status}(期望 403)`);
},
async static_file() {
const { res, text } = await j('/test/hello.txt');
const ok = res.status === 200 && text.includes('hello fsys');
setRow('static_file', ok, `HTTP ${res.status}, ${(res.headers.get('content-type') || '').split(';')[0]}`);
},
async download() {
const { res } = await j('/test/hello.txt?download=true');
const cd = res.headers.get('content-disposition') || '';
setRow('download', res.status === 200 && cd.includes('attachment'), `content-disposition: ${cd || '(无)'}`);
},
async utf8_path() {
const { res, text } = await j('/test/' + encodeURIComponent('中文文件.txt'));
const ok = res.status === 200 && text.includes('中文内容');
setRow('utf8_path', ok, `HTTP ${res.status}(URL 解码生效=${ok})`);
},
async hash() {
const { res, json } = await j('/api/hash', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'test123' })
});
const ok = res.status === 200 && json && json.hash === EXPECT_HASH;
setRow('hash', ok, `HTTP ${res.status}, hash ${ok ? '匹配' : '不匹配'}`);
},
async hash_bad() {
const { res } = await j('/api/hash', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 12345 })
});
setRow('hash_bad', res.status === 400, `HTTP ${res.status}(期望 400)`);
},
async check_user() {
const { res, json } = await j('/api/check_user', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'fsys-test@example.com' })
});
// db 可能不可用:只要返回结构合法(success 字段存在)即 PASS
const ok = [200, 500].includes(res.status) && json && typeof json.success === 'boolean';
setRow('check_user', ok, `HTTP ${res.status} ${JSON.stringify(json)}`);
},
async favicon_private() {
const { res } = await j('/api/favicon?url=' + encodeURIComponent('http://127.0.0.1:8005/'));
setRow('favicon_private', res.status === 403, `HTTP ${res.status}(期望 403 forbidden host)`);
},
async favicon_public() {
try {
const { res } = await j('/api/favicon?url=' + encodeURIComponent('https://www.baidu.com'));
const ct = res.headers.get('content-type') || '';
setRow('favicon_public', res.status === 200 && ct.startsWith('image/'), `HTTP ${res.status} ${ct}`);
} catch (e) {
setRow('favicon_public', false, '网络不可达: ' + e.message);
}
},
async headers() {
const res = await fetch('/health');
const need = ['content-security-policy', 'x-frame-options', 'x-content-type-options',
'referrer-policy', 'permissions-policy'];
const missing = need.filter(h => !res.headers.get(h));
setRow('headers', missing.length === 0, missing.length ? '缺失: ' + missing.join(', ') : '5 项安全头齐全');
},
async notfound() {
const { res } = await j('/no/such/file-' + Date.now());
setRow('notfound', res.status === 404, `HTTP ${res.status}(期望 404)`);
}
};
async function runAll() {
const summary = document.getElementById('summary');
summary.textContent = '测试运行中…';
document.querySelectorAll('#tbl tr').forEach(tr => {
tr.children[1].textContent = '…'; tr.children[1].className = 'st pending';
tr.children[2].textContent = '';
});
let pass = 0, fail = 0;
for (const [name, fn] of Object.entries(tests)) {
try { await fn(); }
catch (e) { setRow(name, false, '异常: ' + e.message); }
const st = document.querySelector(`tr[data-t="${name}"] .st`);
if (st && st.classList.contains('ok')) pass++; else fail++;
}
summary.innerHTML = `完成:<span class="ok">${pass} 通过</span> / <span class="fail">${fail} 失败</span>(共 ${pass + fail})`;
}
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
hello fsys — 这是 fsys 静态文件服务的测试文件。
+1
View File
@@ -0,0 +1 @@
这是中文内容:用于验证 URL 解码(UTF-8 编码文件名)是否生效。
+1589
View File
File diff suppressed because it is too large. Load diff