Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f214c862d3 | ||
|
|
a8a190571c | ||
|
|
aa3a99825c | ||
|
|
dcfdbcb9f2 | ||
|
|
62007e7848 |
No files matched your search
@@ -0,0 +1,384 @@
|
|||||||
|
# bot4cj — 机器人硬件脚本解析器
|
||||||
|
|
||||||
|
基于**仓颉(Cangjie)** 编程语言和 Linux I2C 接口的机器人动作脚本解析器。用一种矩阵式脚本来描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。
|
||||||
|
|
||||||
|
## 架构分层
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ .mt 矩阵脚本(硬件定义 + 动作序列) │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ MatrixParser(P.cj) — FSM 逐字符解析 → MatrixObject 链表 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ RobotStruct(r.cj) — 从矩阵对象构建硬件变量(createHardVars) │
|
||||||
|
│ MatrixVars_Hardware 基类 + 闭包回调: │
|
||||||
|
│ · build() → rise 回调(打开硬件、获取资源) │
|
||||||
|
│ · play() → things 回调(执行动作,参数/返回值均为 JsonValue) │
|
||||||
|
│ · free() → free 回调(释放资源) │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ 硬件实现:hd/*.cj │
|
||||||
|
│ PCA9685(I2C 0x40, 50Hz PWM, 角度→脉宽 0.5~2.5ms) │
|
||||||
|
│ Servo(舵机:max/min/offset 校准 + 角度翻转) │
|
||||||
|
│ MPU6050(陀螺+加速度,±500°/s ±8g,后台 10ms 采样线程+卡尔曼) │
|
||||||
|
│ Speaker(正弦波发声,5ms 淡入淡出) │
|
||||||
|
│ fb.cj(外部硬件扩展口) │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ 系统库:cpp/ + cuda/ │
|
||||||
|
│ libi2c4cj.so — I2C glibc 封装 + 20 槽锁数组 │
|
||||||
|
│ libmt4cj.so — FP32 矩阵运算(CPU / CUDA 二选一) │
|
||||||
|
│ libfft4cj.so — 基-2 Cooley-Tukey FFT │
|
||||||
|
│ libcnn4cj.so — Conv/Pool/GA-Pool/Flatten + Adam/Momentum │
|
||||||
|
│ libsnd4cj.so — WaveAPI / ALSA 音频播放 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ 数学/NN 库:math/*.cj │
|
||||||
|
│ Matrix1(二维矩阵)+ Tensor + 6 种激活函数 + Xavier 初始化 │
|
||||||
|
│ Nn1 全连接层(前向/反向/权重更新) │
|
||||||
|
│ Af(Sigmoid/Tanh/ReLU/Leaky_ReLU/Swish/Softmax) │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层 | 技术 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 应用层 | **仓颉 Cangjie** | 主程序、解析器、机器人结构、数学库、HTTP 服务 |
|
||||||
|
| 系统层 | C / C++ | I2C 驱动、矩阵运算、FFT、CNN、音频 |
|
||||||
|
| 加速层 | CUDA(可选) | `MODE=cpu/cuda` 切换 |
|
||||||
|
| 脚本层 | `.mt` 矩阵脚本 | 硬件定义 + 动作序列 |
|
||||||
|
| 依赖包 | ctype4cj / pthread4cj / spi4cj / lua4cj | 仓颉官方扩展包 |
|
||||||
|
| 构建 | Makefile(C/C++ 共享库)+ cjpm.toml(仓颉包管理) | — |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
bot4cj/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.cj # 入口:CLI 参数解析(-i/-o/--test)+ stdin 管道执行
|
||||||
|
│ ├── P.cj # 矩阵脚本解析器(FSM 逐字符解析)
|
||||||
|
│ ├── r.cj # RobotStruct + MatrixVars_Hardware 基类
|
||||||
|
│ ├── testCNN.cj # CNN 测试入口
|
||||||
|
│ ├── testCNN1.cj # 几何 CNN 测试
|
||||||
|
│ ├── hd/
|
||||||
|
│ │ ├── I2C.cj # I2C 封装 + 20 槽锁数组
|
||||||
|
│ │ ├── PCA9685.cj # PCA9685 舵机控制板(已标记由 Lua 替代)
|
||||||
|
│ │ ├── Servo.cj # 舵机:校准 + 角度翻转
|
||||||
|
│ │ ├── MPU6050.cj # MPU6050 陀螺+加速度+卡尔曼滤波
|
||||||
|
│ │ ├── snd.cj # 扬声器
|
||||||
|
│ │ ├── fb.cj # 外部硬件扩展
|
||||||
|
│ │ └── extern/
|
||||||
|
│ │ └── macro_ExternHardware.cj
|
||||||
|
│ ├── math/
|
||||||
|
│ │ ├── Matrix.cj # 二维矩阵(+运算符重载)
|
||||||
|
│ │ ├── Tensor.cj # 张量
|
||||||
|
│ │ ├── Af.cj # 6 种激活函数
|
||||||
|
│ │ ├── Ini.cj # Xavier/Kaiming 初始化
|
||||||
|
│ │ ├── nn.cj # 全连接层 Nn1
|
||||||
|
│ │ ├── nns.cj # 旧版神经网络(XNOR 门测试)
|
||||||
|
│ │ ├── cnn.cj # CNN 仓颉绑定
|
||||||
|
│ │ ├── pooling.cj # 池化
|
||||||
|
│ │ ├── flatten.cj # Flatten
|
||||||
|
│ │ ├── fft.cj # FFT 仓颉绑定
|
||||||
|
│ │ ├── kalman.cj # 卡尔曼滤波
|
||||||
|
│ │ └── Adam.cj # Adam 优化器
|
||||||
|
│ ├── cpp/ # C/C++ 系统库源
|
||||||
|
│ │ ├── I2C.c # I2C glibc 封装
|
||||||
|
│ │ ├── Matrix.cpp # CPU 矩阵运算
|
||||||
|
│ │ ├── fft1.c # FFT 单实序列
|
||||||
|
│ │ ├── fft.cpp # FFT 双实序列 + 基-2 Cooley-Tukey
|
||||||
|
│ │ ├── kalman.c # 卡尔曼滤波
|
||||||
|
│ │ ├── snd.cpp # 音频播放(WaveAPI/ALSA)
|
||||||
|
│ │ └── cnn/ # 纯 C++ CNN(Conv/Pool/GA-Pool/Flatten)
|
||||||
|
│ └── cuda/
|
||||||
|
│ ├── matrix.cu # GPU 矩阵运算 kernel
|
||||||
|
│ └── error.cuh # CUDA 错误检查宏
|
||||||
|
├── modules/ # Lua 硬件模块(ADS1115/ADS1256/PCA9685/SPI)
|
||||||
|
├── scripts/ # .mt 动作脚本(std/jump/lie-down/tilt-* 等)
|
||||||
|
├── test/ # C/Lua 测试脚本
|
||||||
|
├── docs/Reports/ # 深度分析报告
|
||||||
|
├── cjpm.toml # 仓颉包配置
|
||||||
|
├── Makefile # C/C++ 共享库构建
|
||||||
|
├── _set.sh # 编译(cjpm update + build)
|
||||||
|
├── _run.sh # 运行(stdin 管道)
|
||||||
|
├── compile.ps1 # Windows 编译脚本
|
||||||
|
├── run.ps1 # Windows 运行脚本
|
||||||
|
├── README.md # 旧版说明
|
||||||
|
└── README.robot.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 编译 C/C++ 共享库(一次性)
|
||||||
|
make MODE=cpu # CPU 模式(默认)
|
||||||
|
# 或
|
||||||
|
make MODE=cuda # GPU 模式(需要 nvcc)
|
||||||
|
|
||||||
|
# 2. 编译仓颉程序
|
||||||
|
bash _set.sh
|
||||||
|
|
||||||
|
# 3. 运行动作脚本(stdin 管道)
|
||||||
|
cat scripts/std.mt | bash _run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> **硬件要求**:树莓派(aarch64)或其他 Linux I2C 设备。x86_64 开发板需自行验证 I2C 参数。Windows 暂不支持(依赖 glibc)。
|
||||||
|
|
||||||
|
## CLI 参数
|
||||||
|
|
||||||
|
```
|
||||||
|
./bot # 从 stdin 读取 .mt 脚本执行
|
||||||
|
./bot --help # 打印帮助
|
||||||
|
./bot --test nn # 运行神经网络与门测试
|
||||||
|
./bot --test cnn # 运行 CNN 测试
|
||||||
|
./bot --test cnn1 # 运行几何 CNN 测试
|
||||||
|
./bot --test matrix_o # 解析 ss/test.mt 并保存为 ss/output.mt
|
||||||
|
./bot --test matrix_2 # 解析 ss/output.mt → 执行 → 再次保存
|
||||||
|
./bot -i model.json -o out.json # 加载/保存神经网络模型(DRL 预留)
|
||||||
|
```
|
||||||
|
|
||||||
|
## .mt 脚本语法
|
||||||
|
|
||||||
|
行列式表格,每列以空格或制表符分隔,每行以分号 `;` 结尾。
|
||||||
|
|
||||||
|
### 块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
@BlockName
|
||||||
|
column1 column2 column3;
|
||||||
|
value1 value2 value3;
|
||||||
|
@
|
||||||
|
```
|
||||||
|
|
||||||
|
每个 `@` 块由 `@Name` 行开始,`@` 行结束。支持的块类型:
|
||||||
|
|
||||||
|
| 块 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| `@Alias` | 列名别名(如 `A` → `angle`、`F` → `frequency`) |
|
||||||
|
| `@I2C` | 定义 I2C 设备(name + path) |
|
||||||
|
| `@PCA9685` | 定义 PCA9685 舵机控制板(name + I2C 引用 + 地址) |
|
||||||
|
| `@MPU6050` | 定义 MPU6050 传感器(name + I2C 引用 + 地址) |
|
||||||
|
| `@Speaker` | 定义扬声器(channels + sample_rate + volume + max_f + min_f) |
|
||||||
|
| `@Servo` | 定义舵机(name + max + min + offset + controller + channel) |
|
||||||
|
| `@Action` | **动作矩阵**:列头为硬件变量的属性,行为每帧的参数 |
|
||||||
|
|
||||||
|
### 占位符
|
||||||
|
|
||||||
|
`-` 表示"沿用上一行同列的值",当前行该列不产生调用。
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```mt
|
||||||
|
@Alias
|
||||||
|
name alias;
|
||||||
|
A angle;
|
||||||
|
F frequency;
|
||||||
|
@
|
||||||
|
|
||||||
|
@I2C
|
||||||
|
name path;
|
||||||
|
i2c-0 /dev/i2c-0;
|
||||||
|
i2c-1 /dev/i2c-1;
|
||||||
|
@
|
||||||
|
|
||||||
|
@PCA9685
|
||||||
|
name I2C address;
|
||||||
|
pca9685-0 i2c-1 0x40;
|
||||||
|
@
|
||||||
|
|
||||||
|
@Speaker
|
||||||
|
name channels sample_rate volumn max_f min_f;
|
||||||
|
spk 2 48000 0.6 5000 -;
|
||||||
|
@
|
||||||
|
|
||||||
|
@Servo
|
||||||
|
name max min offset controller channel;
|
||||||
|
s1 90 - - pca9685-0 0;
|
||||||
|
s2 180 - - - 1;
|
||||||
|
s3 -90 - - - 2;
|
||||||
|
s4 -180 - - - 3;
|
||||||
|
@
|
||||||
|
|
||||||
|
@Action
|
||||||
|
s1.A s2.A s3.A s4.A spk.F spk.delay;
|
||||||
|
45 - 45 - 10 200; # 站立
|
||||||
|
90 - - - - 20; # 左转
|
||||||
|
@
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
stdin.read()
|
||||||
|
↓
|
||||||
|
P.MatrixParser (FSM 逐字符解析)
|
||||||
|
↓
|
||||||
|
LinkedList<MatrixObject> 链表
|
||||||
|
↓
|
||||||
|
RobotStruct.createHardVars() # 按 @I2C/@PCA9685/@Servo/@Speaker/@MPU6050 构建硬件变量
|
||||||
|
↓
|
||||||
|
robot.active() # build():逐个硬件调用 rise 回调(打开 I2C、初始化 PWM 等)
|
||||||
|
↓
|
||||||
|
robot.run() # 逐行遍历 @Action 矩阵,每列调用对应硬件的 play() 回调
|
||||||
|
↓
|
||||||
|
robot.deactive() # free():倒序释放资源
|
||||||
|
```
|
||||||
|
|
||||||
|
`play()` 的输入输出均为 `JsonValue`,实现了硬件与脚本之间的统一通信协议。
|
||||||
|
|
||||||
|
## 已支持的硬件
|
||||||
|
|
||||||
|
### PCA9685(I2C 舵机控制板)
|
||||||
|
- 地址:默认 `0x40`
|
||||||
|
- PWM 频率:50Hz(舵机标准 20ms 周期)
|
||||||
|
- 舵机脉宽映射:0°→0.5ms,180°→2.5ms
|
||||||
|
- 角度映射到 12 位 PWM 值(0-4095)
|
||||||
|
- **注意**:PCA9685.cj 代码已标记"由 Lua 脚本取代",保留作备用
|
||||||
|
|
||||||
|
### Servo(舵机)
|
||||||
|
- 支持 `max` / `min` 角度限制与 `offset` 漂移校准
|
||||||
|
- `max < 0` 时自动翻转角度(反向安装的舵机)
|
||||||
|
- 通过闭包回调与实际硬件(PCA9685/Lua 模块)解耦
|
||||||
|
|
||||||
|
### MPU6050(6 轴传感器)
|
||||||
|
- 陀螺仪:±500°/s 量程
|
||||||
|
- 加速度计:±8g 量程
|
||||||
|
- 后台 10ms 采样线程(pthread_create + pthread_cancel)
|
||||||
|
- 卡尔曼滤波融合数据
|
||||||
|
- **已知问题**:`ADDR_0` 与 `ADDR_1` 常量均为 `0x68`([MPU6050.cj:14-15](src/hd/MPU6050.cj#L14-L15)),第二个地址应改为 `0x69`
|
||||||
|
|
||||||
|
### Speaker(扬声器)
|
||||||
|
- 支持正弦波发声(指定频率 `F` + 延时 `delay`)
|
||||||
|
- 5ms 淡入淡出,避免"咔哒"声
|
||||||
|
- 通道数/采样率/音量可在 `.mt` 中配置
|
||||||
|
- **已知问题**:`play()` 仅 sleep 指定延时,真实发声依赖底层 C++ 实现
|
||||||
|
|
||||||
|
## 机器人型号适配
|
||||||
|
|
||||||
|
### Dashun 蜘蛛机器人(默认)
|
||||||
|
10 舵机 + 2 云台 + 扬声器,建模文件见 [gopsc/Dashun](https://github.com/gopsc/Dashun):
|
||||||
|
|
||||||
|
| 舵机 | 关节 | 舵机 | 关节 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| s1 | 左下水平 (rb0) | s6 | 右上垂直 (lf1) |
|
||||||
|
| s2 | 左下垂直 (rb1) | s7 | 左上水平 (rf0) |
|
||||||
|
| s3 | 右下水平 (lb0) | s8 | 左上垂直 (rf1) |
|
||||||
|
| s4 | 右下垂直 (lb1) | s9 | 云台1 (cam0) |
|
||||||
|
| s5 | 右上水平 (lf0) | s10 | 云台2 (cam1) |
|
||||||
|
|
||||||
|
### 可用脚本
|
||||||
|
|
||||||
|
`scripts/` 目录下预写了多种动作脚本:
|
||||||
|
- `std.mt` — 站立姿态
|
||||||
|
- `jump.mt` — 跳跃
|
||||||
|
- `lie-down.mt` — 趴下
|
||||||
|
- `left-hand.mt` / `rght-hand.mt` — 单手动作
|
||||||
|
- `left-turn.mt` / `tilt-f.mt` / `tilt-b.mt` / `tilt-l.mt` / `tilt-r.mt` — 转向与倾斜
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
### 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 仓颉 1.1.3
|
||||||
|
# 仓颉官方扩展包:ctype4cj / pthread4cj / spi4cj / lua4cj
|
||||||
|
# libc / libpthread
|
||||||
|
# liblua-5.4(可选,Lua 硬件模块支持)
|
||||||
|
sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so
|
||||||
|
sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 构建 C/C++ 共享库
|
||||||
|
make MODE=cpu
|
||||||
|
# 产出:libi2c4cj.so libmt4cj.so libfft4cj.so libsnd4cj.so
|
||||||
|
|
||||||
|
# 2. (可选)构建 CNN
|
||||||
|
make libcnn4cj.so
|
||||||
|
|
||||||
|
# 3. (可选)CUDA 模式需 NVIDIA CUDA Toolkit
|
||||||
|
make MODE=cuda
|
||||||
|
|
||||||
|
# 4. 仓颉主程序
|
||||||
|
bash _set.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
[_set.sh](_set.sh) 执行 `cjpm update` + `cjpm build`,自动解析 cjpm.toml 依赖。
|
||||||
|
|
||||||
|
### 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash _run.sh scripts/std.mt
|
||||||
|
# 或
|
||||||
|
cat scripts/std.mt | bash _run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
[_run.sh](_run.sh) 以 `cjStackSize=64MB` / `cjHeapSize=64MB` 启动。
|
||||||
|
|
||||||
|
## 神经网络支持
|
||||||
|
|
||||||
|
项目内置了全连接层 Nn1([math/nn.cj](src/math/nn.cj))与 CNN([math/cnn.cj](src/math/cnn.cj) + [cpp/cnn/](src/cpp/cnn/)):
|
||||||
|
|
||||||
|
- **Nn1**:多层感知机,支持前向传播、反向传播、权重更新
|
||||||
|
- **CNN**:Conv → ReLU → Pool → GA-Pool → Flatten → FC
|
||||||
|
- **优化器**:Adam / Momentum
|
||||||
|
- **激活函数**:Sigmoid / Tanh / ReLU / Leaky_ReLU / Swish / Softmax
|
||||||
|
- **初始化**:Xavier / Kaiming
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bot --test nn # 与门训练(2 输入 → 1 输出)
|
||||||
|
./bot --test cnn # CNN 前向/反向测试
|
||||||
|
./bot --test cnn1 # 几何分类 CNN 测试
|
||||||
|
```
|
||||||
|
|
||||||
|
### DRL 预留(未完成)
|
||||||
|
|
||||||
|
main.cj 中预留了 DRL 流程(注释状态):加载预训练权重 → 执行动作 → 保存微调模型。通过 `-i` / `-o` 参数指定输入/输出路径。
|
||||||
|
|
||||||
|
## 安全与限制
|
||||||
|
|
||||||
|
### I2C 总线保护
|
||||||
|
- [hd/I2C.cj](src/hd/I2C.cj) 内置 **20 槽锁数组**,多硬件共享 I2C 总线时自动互斥
|
||||||
|
- 每个 I2C fd 独立加锁,避免并发写入冲突
|
||||||
|
|
||||||
|
### 硬件关闭时机
|
||||||
|
- `MatrixVars_Hardware` 析构依赖仓颉 GC,**不保证**在确定性时机调用 `free()`
|
||||||
|
- 机器人使用 `try/finally` 在 [main.cj:80-87](src/main.cj#L80) 中显式调用 `deactive()`,确保硬件资源正确释放
|
||||||
|
|
||||||
|
### 线程安全
|
||||||
|
- MPU6050 采样通过 `pthread_create` 启动后台线程
|
||||||
|
- `pthread_cancel` 依赖系统定义的取消点(sleep / 系统调用),可能在非安全点中断
|
||||||
|
|
||||||
|
### 平台支持
|
||||||
|
- ✅ Linux aarch64(树莓派)/ x86_64
|
||||||
|
- ❌ Windows(依赖 glibc)
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
| ID | 描述 | 影响 |
|
||||||
|
|----|------|------|
|
||||||
|
| D1 | 仓颉 GC 不保证析构器调用,I2C fd 可能泄漏 | 长时间运行后 I2C 资源耗尽 |
|
||||||
|
| D2 | Speaker `play()` 仅 sleep,真实发声未接入 C++ | 扬声器无实际声音 |
|
||||||
|
| D3 | MPU6050 `pthread_cancel` 可能在非安全点中断 | 传感器数据不一致 |
|
||||||
|
| D4 | PCA9685 初始化被标记为"由 Lua 取代",但仍保留主路径 | 代码存在双实现 |
|
||||||
|
| D5 | `_set.sh` 会删除 `a.out` | 误删用户产物 |
|
||||||
|
| D6 | Softmax 雅可比矩阵实现错误(math/Af.cj) | NN 训练收敛异常 |
|
||||||
|
| D7 | Tensor 乘法仅支持 2D,3D+ 会 panic | 限制模型复杂度 |
|
||||||
|
| D8 | Tanh 导数实现错误(使用 `1 - y^2` 而非 `1 - tanh^2(x)`) | NN 训练收敛异常 |
|
||||||
|
| D9 | CNN `testGeometryCNN` 使用随机数据 | 测试无实际意义 |
|
||||||
|
| D10 | MPU6050 `ADDR_0` 与 `ADDR_1` 均为 `0x68` | 无法同时使用两个 MPU6050 |
|
||||||
|
| D11 | `@Action` 与 `@Action_Auto` 并存未合并 | 脚本语法冗余 |
|
||||||
|
|
||||||
|
## 相关资源
|
||||||
|
|
||||||
|
- [Dashun 蜘蛛机器人建模](https://github.com/gopsc/Dashun)
|
||||||
|
- [深度分析报告](docs/Reports/analysis_20260525_00.md)(28 章节,1280 行)
|
||||||
|
- [仓颉编程语言](https://cangjie-lang.org/)
|
||||||
|
- [PCA9685 数据手册](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf)
|
||||||
|
- [MPU6050 数据手册](https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Datasheet1.pdf)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
@@ -23,6 +23,10 @@
|
|||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 自动创建 venv 并安装依赖(推荐)
|
||||||
|
bash _set.sh
|
||||||
|
|
||||||
|
# 或手动安装
|
||||||
pip install Pillow
|
pip install Pillow
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -138,7 +142,9 @@ png_b64 = resp["data"]["image"]
|
|||||||
```
|
```
|
||||||
captcha/
|
captcha/
|
||||||
├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验)
|
├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验)
|
||||||
|
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
|
||||||
├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py)
|
├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py)
|
||||||
|
├── requirements.txt # Python 依赖(pymsg、Pillow)
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
|||||||
[Token]
|
[Token]
|
||||||
# Token 有效期(秒),默认 3600 = 1 小时
|
# Token 有效期(秒),默认 3600 = 1 小时
|
||||||
keep_time = 3600
|
keep_time = 3600
|
||||||
|
# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2
|
||||||
|
refresh_grace_multiplier = 2
|
||||||
|
# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10
|
||||||
|
max_refresh = 10
|
||||||
|
|
||||||
[Admin]
|
[Admin]
|
||||||
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
||||||
|
|||||||
+171
-1
@@ -302,6 +302,10 @@ http::response<http::string_body> handleLogout(
|
|||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params);
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
|
http::response<http::string_body> handleRefreshToken(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
||||||
http::response<http::string_body> handleCaptchaImage(
|
http::response<http::string_body> handleCaptchaImage(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
@@ -325,6 +329,8 @@ static std::string _DEFT_URL;
|
|||||||
static std::vector<std::string> _OPEN_URL;
|
static std::vector<std::string> _OPEN_URL;
|
||||||
static std::string _DB_PATH;
|
static std::string _DB_PATH;
|
||||||
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
|
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
|
||||||
|
static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新
|
||||||
|
static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置
|
||||||
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
||||||
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
||||||
|
|
||||||
@@ -335,6 +341,7 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
|||||||
static RateLimiter _LOGIN_RATE;
|
static RateLimiter _LOGIN_RATE;
|
||||||
static RateLimiter _REGISTER_RATE;
|
static RateLimiter _REGISTER_RATE;
|
||||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||||
|
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
|
||||||
|
|
||||||
/* pymsg 下游调用超时(秒):
|
/* pymsg 下游调用超时(秒):
|
||||||
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
||||||
@@ -499,6 +506,8 @@ int main(int argc, char **argv) {
|
|||||||
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
|
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
|
||||||
_DB_PATH = cf.get<std::string>("Database.path", "");
|
_DB_PATH = cf.get<std::string>("Database.path", "");
|
||||||
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
|
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
|
||||||
|
_TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get<int>("Token.refresh_grace_multiplier", 2);
|
||||||
|
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
||||||
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
||||||
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
||||||
}
|
}
|
||||||
@@ -621,6 +630,11 @@ void registerRoutes(HttpServer &server) {
|
|||||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
|
return wrapperHttp(req, params,
|
||||||
|
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); });
|
||||||
|
});
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
||||||
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
return wrapperHttp(req, params,
|
return wrapperHttp(req, params,
|
||||||
@@ -876,9 +890,12 @@ http::response<http::string_body> handleLogin(
|
|||||||
|
|
||||||
json::value jv_t = {
|
json::value jv_t = {
|
||||||
{"user", username},
|
{"user", username},
|
||||||
|
{"iat", LocalTime::getCurrent()},
|
||||||
{"created_at", LocalTime::getCurrent()},
|
{"created_at", LocalTime::getCurrent()},
|
||||||
{"keep_time", _TOKEN_KEEP_TIME},
|
{"keep_time", _TOKEN_KEEP_TIME},
|
||||||
{"is_admin", is_admin}
|
{"is_admin", is_admin},
|
||||||
|
{"refresh_count", 0},
|
||||||
|
{"max_refresh", _TOKEN_MAX_REFRESH}
|
||||||
};
|
};
|
||||||
std::string data = json::serialize(jv_t);
|
std::string data = json::serialize(jv_t);
|
||||||
std::string auth = Base64::base64_encode(data);
|
std::string auth = Base64::base64_encode(data);
|
||||||
@@ -929,6 +946,159 @@ http::response<http::string_body> handleLogout(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 刷新 Token:POST /refresh_token
|
||||||
|
*
|
||||||
|
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
|
||||||
|
* 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。
|
||||||
|
*
|
||||||
|
* 宽限期:iat + keep_time * refresh_grace_multiplier
|
||||||
|
* 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新
|
||||||
|
* 刷新上限:refresh_count < max_refresh
|
||||||
|
* 限流:5 次 / 5 分钟 / IP
|
||||||
|
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token
|
||||||
|
*
|
||||||
|
* 响应:{ success, data:{ token, refresh_count, refreshes_remaining } }
|
||||||
|
*/
|
||||||
|
http::response<http::string_body> handleRefreshToken(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params)
|
||||||
|
{
|
||||||
|
http::response<http::string_body> res;
|
||||||
|
res.version(req.version());
|
||||||
|
|
||||||
|
/* IP 速率限制:5 次 / 300 秒 */
|
||||||
|
std::string client_ip = "direct";
|
||||||
|
auto it_host = req.base().find("X-Forwarded-For");
|
||||||
|
if (it_host != req.base().end())
|
||||||
|
client_ip = std::string(it_host->value());
|
||||||
|
if (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300))
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.",
|
||||||
|
429, "/refresh_token");
|
||||||
|
|
||||||
|
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||||
|
|
||||||
|
/* 获取 Token:Authorization 头优先,回退 Cookie */
|
||||||
|
std::string raw;
|
||||||
|
auto it_auth = req.base().find("Authorization");
|
||||||
|
if (it_auth != req.base().end()) {
|
||||||
|
raw = std::string(it_auth->value());
|
||||||
|
} else {
|
||||||
|
auto cit = req.base().find("Cookie");
|
||||||
|
if (cit != req.base().end()) {
|
||||||
|
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||||
|
if (!cookie_token.empty())
|
||||||
|
raw = "Bearer " + cookie_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.empty())
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
|
||||||
|
|
||||||
|
/* 解析 Bearer <base64>_<signature> */
|
||||||
|
if (raw.substr(0, 7) != "Bearer ")
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||||
|
raw = raw.substr(7);
|
||||||
|
|
||||||
|
size_t idx = raw.find("_");
|
||||||
|
if (idx == std::string::npos)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||||
|
|
||||||
|
std::string data = raw.substr(0, idx);
|
||||||
|
std::string sign = raw.substr(idx + 1);
|
||||||
|
std::string auth = Base64::base64_decode_to_string(data);
|
||||||
|
|
||||||
|
/* RSA 验签 */
|
||||||
|
if (!_PUBL.Verify(auth, sign))
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
|
||||||
|
|
||||||
|
/* 解析 Token payload */
|
||||||
|
json::value jv_t;
|
||||||
|
try { jv_t = json::parse(auth); }
|
||||||
|
catch (const std::exception&) {
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||||
|
}
|
||||||
|
if (!jv_t.is_object())
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||||
|
|
||||||
|
json::object& obj = jv_t.as_object();
|
||||||
|
|
||||||
|
/* 提取关键字段 */
|
||||||
|
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||||
|
std::string iat_str = json::value_to<std::string>(obj.at("iat"));
|
||||||
|
int keep_time = json::value_to<int>(obj.at("keep_time"));
|
||||||
|
int refresh_count = json::value_to<int>(obj.at("refresh_count"));
|
||||||
|
int max_refresh = json::value_to<int>(obj.at("max_refresh"));
|
||||||
|
|
||||||
|
/* 刷新次数检查 */
|
||||||
|
if (refresh_count >= max_refresh)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.",
|
||||||
|
400, "/refresh_token");
|
||||||
|
|
||||||
|
/* 宽限期检查:iat + keep_time * grace_multiplier > now */
|
||||||
|
auto iat_tp = LocalTime::parse(iat_str);
|
||||||
|
if (!iat_tp)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token");
|
||||||
|
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
|
||||||
|
if (now > grace_expire)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.",
|
||||||
|
401, "/refresh_token");
|
||||||
|
|
||||||
|
/* 保留 is_admin(旧 Token 未携带时默认为 false) */
|
||||||
|
bool is_admin = false;
|
||||||
|
auto it_admin = obj.find("is_admin");
|
||||||
|
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||||
|
is_admin = it_admin->value().as_bool();
|
||||||
|
|
||||||
|
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */
|
||||||
|
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||||
|
json::value jv_new = {
|
||||||
|
{"user", username},
|
||||||
|
{"iat", iat_str},
|
||||||
|
{"created_at", LocalTime::getCurrent()},
|
||||||
|
{"keep_time", keep_time},
|
||||||
|
{"is_admin", is_admin},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"max_refresh", max_refresh}
|
||||||
|
};
|
||||||
|
std::string new_data = json::serialize(jv_new);
|
||||||
|
std::string new_auth = Base64::base64_encode(new_data);
|
||||||
|
std::string new_sign = _PRIV.Sign(new_data);
|
||||||
|
std::string new_token = new_auth + "_" + new_sign;
|
||||||
|
|
||||||
|
/* Set-Cookie */
|
||||||
|
{
|
||||||
|
std::string ck = "q4_token=" + new_token +
|
||||||
|
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
|
||||||
|
std::to_string(keep_time);
|
||||||
|
res.set("Set-Cookie", ck);
|
||||||
|
}
|
||||||
|
|
||||||
|
logs().info("gateway", "Token refreshed", std::nullopt,
|
||||||
|
json::serialize(json::value{
|
||||||
|
{"user", username},
|
||||||
|
{"ip", client_ip},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||||
|
}));
|
||||||
|
|
||||||
|
json::value jv_rr = {
|
||||||
|
{"success", true},
|
||||||
|
{"data", json::object{
|
||||||
|
{"token", new_token},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||||
|
}}
|
||||||
|
};
|
||||||
|
res.set("Connection", "close");
|
||||||
|
res.result(http::status::ok);
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.body() = json::serialize(jv_rr);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码:GET /captcha/image
|
/* 代理 captcha 图形验证码:GET /captcha/image
|
||||||
*
|
*
|
||||||
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
|
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
|
||||||
|
|||||||
+49
-4
@@ -7,18 +7,23 @@
|
|||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
gateway :443 (反向代理)
|
waf :443 (TLS 终止 + 安全规则)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
gateway :8443 (反向代理 + 认证)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
static :9200 (Flask + CORS)
|
static :9200 (Flask + CORS)
|
||||||
│
|
│
|
||||||
└── public/ 托管目录
|
└── public/ 托管目录
|
||||||
├── index.html 社区首页
|
├── login.html 登录(默认首页)
|
||||||
├── login.html 登录
|
|
||||||
├── register.html 注册
|
├── register.html 注册
|
||||||
|
├── index.html 社区首页
|
||||||
|
├── forgot.html 忘记密码
|
||||||
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
||||||
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
||||||
├── allcolor.html 调色板
|
├── allcolor.html 调色板
|
||||||
|
├── LICENSE.html 许可证页面
|
||||||
├── css/ theme.css(统一主题)+ view.css
|
├── css/ theme.css(统一主题)+ view.css
|
||||||
├── js/ dnfs.js / login.js / register.js / validation.js / view.js
|
├── js/ dnfs.js / login.js / register.js / validation.js / view.js
|
||||||
├── games/ HTML 小游戏合集
|
├── games/ HTML 小游戏合集
|
||||||
@@ -27,15 +32,46 @@ static :9200 (Flask + CORS)
|
|||||||
|
|
||||||
## 路由
|
## 路由
|
||||||
|
|
||||||
|
### 核心路由
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `GET /` | 社区首页(index.html),不存在时回退 `__view.html` |
|
| `GET /` | 登录页(login.html),如不存在回退 `__view.html` |
|
||||||
| `GET /<path>` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 |
|
| `GET /<path>` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 |
|
||||||
| `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 |
|
| `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 |
|
||||||
| `GET /video/<path>` | 视频/音频流媒体(HTTP Range 206 分段) |
|
| `GET /video/<path>` | 视频/音频流媒体(HTTP Range 206 分段) |
|
||||||
| `GET /<path>?download=true` | 强制下载 |
|
| `GET /<path>?download=true` | 强制下载 |
|
||||||
| `GET /health` | 健康检查 |
|
| `GET /health` | 健康检查 |
|
||||||
|
|
||||||
|
### 内部工具路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `POST /api/hash` | SHA256 密码哈希 |
|
||||||
|
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
||||||
|
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
||||||
|
|
||||||
|
### yhdet OAuth 路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `GET/POST /yhdet_callback` | yhdet.top OAuth 回调,自动注册/登录并跳转 dnfs 页 |
|
||||||
|
| `POST /api/yhdet_login` | yhdet OAuth 用户名密码登录 |
|
||||||
|
| `POST /api/yhdet_verify` | yhdet Token 验证 |
|
||||||
|
| `POST /api/proxy_send_code` | yhdet 验证码代理转发 |
|
||||||
|
|
||||||
|
### dnfs 代理路由
|
||||||
|
|
||||||
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs:8006) |
|
||||||
|
| `POST /add_note` | 新增笔记(代理至 dnfs:8006) |
|
||||||
|
| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) |
|
||||||
|
| `POST /update_note` | 更新笔记(代理至 dnfs:8006) |
|
||||||
|
| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) |
|
||||||
|
|
||||||
|
以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
|
- 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
|
||||||
@@ -43,6 +79,8 @@ static :9200 (Flask + CORS)
|
|||||||
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
||||||
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
||||||
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
||||||
|
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
||||||
|
- 集中日志:`log_client.py` 通过 pymsg TCP 异步批量推送日志至 db:8005 的 Logs 表
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -60,6 +98,11 @@ root = ./public
|
|||||||
enabled = false
|
enabled = false
|
||||||
cert_file = cert.pem
|
cert_file = cert.pem
|
||||||
key_file = key.pem
|
key_file = key.pem
|
||||||
|
|
||||||
|
[cors]
|
||||||
|
# 允许跨域的 Origin 白名单(逗号分隔)
|
||||||
|
# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 *
|
||||||
|
allowed_origins = https://qsont.me,https://yhdet.top
|
||||||
```
|
```
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
@@ -67,6 +110,8 @@ key_file = key.pem
|
|||||||
```
|
```
|
||||||
flask>=2.3.0,<3.0.0
|
flask>=2.3.0,<3.0.0
|
||||||
flask-cors>=4.0.0,<5.0.0
|
flask-cors>=4.0.0,<5.0.0
|
||||||
|
waitress>=2.1.2,<4.0.0 # 生产级 WSGI 服务器(非 SSL 模式默认使用)
|
||||||
|
pymsg # 集中日志推送(log_client 依赖)
|
||||||
nuitka # 可选,编译为独立二进制
|
nuitka # 可选,编译为独立二进制
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+49
-12
@@ -22,7 +22,7 @@
|
|||||||
| 列出所有规则 | `./waf.bin -list` |
|
| 列出所有规则 | `./waf.bin -list` |
|
||||||
| 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` |
|
| 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` |
|
||||||
| 热加载(不重启) | `kill -HUP $(pgrep waf)` |
|
| 热加载(不重启) | `kill -HUP $(pgrep waf)` |
|
||||||
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` |
|
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` (引擎仅加载 `.conf` 后缀) |
|
||||||
|
|
||||||
### 规则文件组织
|
### 规则文件组织
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ rules/
|
|||||||
├── local/ # 本地自定义规则(ID 段 100000-199999)
|
├── local/ # 本地自定义规则(ID 段 100000-199999)
|
||||||
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
|
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
|
||||||
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
||||||
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取)
|
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取,可选)
|
||||||
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
|
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
|
||||||
├── LICENSE # Apache-2.0
|
├── LICENSE # Apache-2.0
|
||||||
└── rules/*.conf # CRS 规则文件
|
└── rules/*.conf # CRS 规则文件
|
||||||
@@ -54,8 +54,8 @@ rules/
|
|||||||
| ID 段 | 用途 |
|
| ID 段 | 用途 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| 100000-199999 | 本地自定义规则 |
|
| 100000-199999 | 本地自定义规则 |
|
||||||
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
|
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义,仅当启用 CRS 时存在) |
|
||||||
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
|
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改,仅当启用 CRS 时存在) |
|
||||||
|
|
||||||
### 规则语法(SecLang)
|
### 规则语法(SecLang)
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ rules_dir: "./rules"
|
|||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| `listen` | `:443` | 监听地址 |
|
| `listen` | `:443` | 监听地址 |
|
||||||
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
|
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
|
||||||
| `upstream` | — | gateway 地址,**必填** |
|
| `upstream` | — | gateway 地址,**必填**(启动时硬性校验,缺失将导致进程退出) |
|
||||||
| `rules_dir` | `./rules` | 规则根目录 |
|
| `rules_dir` | `./rules` | 规则根目录 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
@@ -112,8 +112,10 @@ waf/
|
|||||||
├── internal/
|
├── internal/
|
||||||
│ ├── engine/
|
│ ├── engine/
|
||||||
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
|
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
|
||||||
|
│ ├── logclient/
|
||||||
|
│ │ └── logclient.go # Go 版 pymsg 日志客户端(异步批量推送)
|
||||||
│ └── proxy/
|
│ └── proxy/
|
||||||
│ └── proxy.go # ReverseProxy + WAF 事务接入
|
│ └── proxy.go # ReverseProxy + WAF 事务接入 + 安全响应头注入
|
||||||
├── rules/
|
├── rules/
|
||||||
│ ├── waf.conf
|
│ ├── waf.conf
|
||||||
│ └── local/
|
│ └── local/
|
||||||
@@ -156,13 +158,38 @@ bash /opt/micro_servers/waf/_run.sh
|
|||||||
|
|
||||||
并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。
|
并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。
|
||||||
|
|
||||||
|
## 安全响应头
|
||||||
|
|
||||||
|
WAF 在边缘层统一注入安全响应头([proxy.go:54-77](internal/proxy/proxy.go#L54-L77) `setSecurityHeaders`),覆盖所有出站响应(包括正常转发、命中拦截、上游 502)。下游服务(static/dnfs 等)的同名头会被 WAF 覆盖,避免 CSP 绕过。
|
||||||
|
|
||||||
|
| Header | 值 | 说明 |
|
||||||
|
|--------|----|------|
|
||||||
|
| X-Frame-Options | SAMEORIGIN | 允许本站 iframe 嵌套(登录页条例弹窗),防跨站点击劫持 |
|
||||||
|
| X-Content-Type-Options | nosniff | — |
|
||||||
|
| X-XSS-Protection | 1; mode=block | 现代浏览器已废弃,仅兼容旧浏览器 |
|
||||||
|
| Referrer-Policy | strict-origin-when-cross-origin | — |
|
||||||
|
| Permissions-Policy | camera=(), microphone=(), geolocation=() | — |
|
||||||
|
| Strict-Transport-Security | max-age=15552000 | HSTS 半年期,**不含子域** |
|
||||||
|
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; ... | 暂保留 `'unsafe-inline'` 以兼容 forgot.html 与 games/*.html 的内联脚本 |
|
||||||
|
|
||||||
## 拦截日志
|
## 拦截日志
|
||||||
|
|
||||||
命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录):
|
命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录):
|
||||||
|
|
||||||
- `service=waf`、`level=WARNING`
|
- **规则命中拦截**(block 触发):`service=waf`、`level=WARNING`
|
||||||
- `message`:`block rule_id=100001 GET /path`
|
- **规则加载/解析错误**(`LogError` 回调触发,引擎初始化/热加载时的语义错误):`service=waf`、`level=ERROR`
|
||||||
- `extra`(JSON 字符串):`rule_id` / `ip` / `method` / `path` / `query` / `ua` / `cost_ms`
|
|
||||||
|
`block()` 推送的日志 `extra` 字段(JSON 字符串):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `rule_id` | int | 命中规则 ID |
|
||||||
|
| `ip` | string | `RemoteAddr` 原始值,含端口号(如 `1.2.3.4:54321`) |
|
||||||
|
| `method` | string | 请求方法 |
|
||||||
|
| `path` | string | 请求路径 |
|
||||||
|
| `query` | string | 查询字符串 |
|
||||||
|
| `ua` | string | User-Agent |
|
||||||
|
| `cost_ms` | int64 | 处理耗时(毫秒) |
|
||||||
|
|
||||||
fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。
|
fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。
|
||||||
|
|
||||||
@@ -181,6 +208,13 @@ sqlite3 /opt/micro_servers/db/database.db \
|
|||||||
WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;"
|
WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
查看规则加载/解析错误:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 /opt/micro_servers/db/database.db \
|
||||||
|
"SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='ERROR' ORDER BY id DESC LIMIT 20;"
|
||||||
|
```
|
||||||
|
|
||||||
## 启用 OWASP CRS(可选)
|
## 启用 OWASP CRS(可选)
|
||||||
|
|
||||||
通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用):
|
通过 Go module proxy 拉取(不依赖 GitHub 直连,国内可用):
|
||||||
@@ -214,26 +248,29 @@ kill -HUP $(pgrep waf)
|
|||||||
|
|
||||||
## 请求处理流程
|
## 请求处理流程
|
||||||
|
|
||||||
[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`:
|
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||||
|
|
||||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
5. 捕获响应头 → `ProcessResponseHeaders`
|
||||||
|
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
||||||
|
|
||||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||||
|
|
||||||
ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。
|
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
||||||
- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
||||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||||
|
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||||
|
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user