feederback — Go Feedback 系统
Go 服务端 + Go 客户端 SDK + 原生 Webpanel。提交反馈时执行 PoW(工作量证明,按本机校准约 30 秒),服务端用 Ed25519 签名每条记录;HTTP + protobuf 传输;从 nginx 头部取 IP 做自动限流;按最大存储字节滚动剔除旧数据。
特性
- PoW 防滥用:SHA-256 前导零,难度按本机校准(
cmd/benchpow),默认约 30 秒;challenge 一次性 + TTL + 上限防 DoS
- Ed25519 签名:每条记录签名(覆盖产品 ID、二进制指纹等全部提交字段),公钥随响应下发,客户端本地验签,防篡改
- 多产品区分:面板创建产品(自定义字符 ID,如
game-app),各产品独立配额与滚动剔除;SDK 按产品 ID 提交,面板按产品筛选
- 可执行文件指纹:SDK 上传时自动计算运行中可执行文件(可附加文件)的 sha256,随反馈提交,可直接
sha256sum 校验,定位故障来自哪个构建版本
- 自动限流:每个 IP 10/min、每个机器码 2/min(滑动窗口),IP 取自 nginx 透传头部(trust 门控防伪造)
- 字段限制:logs ≤ 64KB、content ≤ 1KB、os_info ≤ 1KB
- 滚动剔除:
MAX_BYTES 容量上限,超出按时间从旧到新删除(每产品独立)
- 原生 Webpanel:纯 HTML/CSS/JS,管理密码登录,无前端框架
- 三库支持:sqlite / mysql / postgres,通过
CONNINFO 环境变量选择
- 多实例扩展:
BACKEND=redis 让 challenge/限流跨实例共享(接口抽象,可再扩展其他后端)
快速开始
# 校准 PoW 难度(输出推荐值,写回 POW_DIFFICULTY)
go run ./cmd/benchpow
# 启动服务端(sqlite)
CONNINFO=sqlite://./data/feed.db ADMIN_PASSWORD=secret go run ./cmd/server
# 健康检查
curl localhost:8080/healthz
# 公钥(供离线验签)
curl localhost:8080/api/v1/pubkey
# 面板:浏览器打开 http://localhost:8080/panel/
# → 首次登录后在「产品管理」创建产品,记下自定义字符 ID(如 game-app)
# SDK 提交一条反馈(PoW 默认约 30 秒;SDK 会自动计算本可执行文件的 sha256)
go run ./examples/submit -server http://127.0.0.1:8080 -product <产品ID> "反馈文本" "日志" "Linux x86_64; app v1.0"
# 或用命令行提交工具(make build 产出 bin/submit)
bin/submit -product <产品ID> -content "反馈文本" [-logs-file ./app.log] [-os "Linux; v1.0"]
echo "反馈文本" | bin/submit -product <产品ID> # 内容可从 stdin 管道输入
bin/submit -product <产品ID> -content "..." -json # JSON 输出便于脚本化
环境变量
| 变量 |
默认 |
说明 |
HTTP_ADDR |
:8080 |
监听地址 |
CONNINFO |
sqlite://./data/feed.db |
三库 DSN |
DATA_DIR |
./data |
私钥等文件目录 |
MAX_BYTES |
536870912 (512MiB) |
默认滚动剔除容量(每产品配额;产品创建时可单独指定,0=继承此值) |
ADMIN_PASSWORD |
(空) |
面板密码;空则登录一律拒绝 |
SIGNING_PRIVKEY |
(空) |
base64(std) 32B seed;优先于文件 |
PRIVKEY_FILE |
<DATA_DIR>/ed25519.key |
无 env 时自动生成(0600) |
POW_DIFFICULTY |
26 |
下发难度;默认按本机校准约 30s,换机器用 benchpow -target 30 重校 |
POW_MAX_DIFFICULTY |
48 |
拒绝过高难度配置 |
CHALLENGE_TTL / CHALLENGE_MAX |
5m / 100000 |
challenge 生命周期与上限(需远大于 PoW 时长,默认 30s,留足余量) |
RATE_WINDOW |
60s |
滑动窗口 |
RATE_IP_SUBMIT / RATE_MACHINE_SUBMIT |
10 / 2 |
submit 限流 |
RATE_IP_CHALLENGE / RATE_LOGIN |
60 / 5 |
challenge / 登录限流 |
TRUST_PROXY_HEADERS |
false |
信任 X-Real-IP/X-Forwarded-For |
TRUSTED_PROXIES |
(空) |
逗号分隔 CIDR;命中则信任头部 |
SESSION_SECRET |
(空) |
面板会话密钥;空则启动随机(重启失效) |
BACKEND |
memory |
共享后端:memory(单机) / redis(多实例) |
REDIS_ADDR |
127.0.0.1:6379 |
Redis/Valkey 地址 |
REDIS_PASSWORD / REDIS_DB |
(空)/ 0 |
|
REDIS_PREFIX |
fb |
Redis key 前缀,多环境隔离 |
LOG_LEVEL |
info |
debug/info/warn/error |
数据库
CONNINFO 按 scheme 选择驱动:
# sqlite(pure-Go,无需 cgo)
CONNINFO=sqlite://./data/feed.db
CONNINFO=sqlite://:memory: # 内存库(测试)
# mysql
CONNINFO=mysql://user:pass@tcp(127.0.0.1:3306)/feederback
# postgres
CONNINFO=postgres://user:pass@127.0.0.1:5432/feederback
首次启动自动建表(AutoMigrate),创建默认产品 default(ID 即 default),并初始化其 total_bytes:{product_id} 滚动剔除计数器;之后面板创建的产品各自独立计数。
多产品
产品在面板「产品管理」创建,SDK 提交时按产品 ID 区分:
- 自定义字符 ID:创建时设置一个便于记忆的字符 ID(如
game-app),SDK / CLI 的 -product 直接使用它;面板默认产品为 default
- 独立配额:每个产品有自己的滚动剔除上限(创建时指定
max_bytes,0=继承全局 MAX_BYTES),互不挤占
- 按产品筛选:面板可按产品过滤反馈、查看单产品统计(
/api/panel/stats?product=<id>)
- 默认产品:首次启动自动创建,兼容直接使用
nginx 反代与真实 IP
/api/v1/submit 的 IP 限流依赖真实客户端 IP。在 nginx 前配置:
server {
listen 8081;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
服务端设置 TRUST_PROXY_HEADERS=true(或 TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8)。
默认不信任头部——直接暴露 8080 时伪造 X-Real-IP 无效,防止绕过 IP 限流。
机器码由客户端生成,客户端可自改,机器码限流为 best-effort。
多实例部署
单实例默认 BACKEND=memory(challenge 与限流为进程内实现)。多实例(横向扩容)时,这些状态必须共享,否则 challenge 在 A 下发、B 收不到,限流被 N 倍绕过:
# 每个实例相同的配置(除 HTTP_ADDR 外)
BACKEND=redis
REDIS_ADDR=127.0.0.1:6379
REDIS_PREFIX=fb
SESSION_SECRET=<固定值,必须所有实例一致>
SIGNING_PRIVKEY=<base64 32B seed,必须所有实例一致>
CONNINFO=mysql://user:pass@tcp(host:3306)/feederback # 必须共享 DB
多实例硬性要求:
| 项 |
要求 |
| 数据库 |
必须共享 CONNINFO=mysql/pg。sqlite 文件多进程有锁风险,:memory: 每实例独立 |
SESSION_SECRET |
所有实例一致;否则 LB 转发后面板登录态失效(redis 模式下为空会启动 WARN) |
SIGNING_PRIVKEY |
所有实例一致(或用 env seed);否则各实例公钥不同,SDK 验签失败 |
| Redis |
challenge/限流共享;REDIS_PREFIX 用于多环境隔离 |
实现原理:pow.ChallengeStore 与 ratelimit.RateLimiter 为接口,internal/backend 提供
memory(单机)与 redis(多实例)两套实现,BACKEND 环境变量切换。
RedisChallengeStore:SET NX EX 下发 + GETDEL 原子一次性
RedisLimiter:ZSET + Lua 原子滑动窗口,多实例并发在 Redis 内串行化
- Redis 故障时fail-closed(拒绝提交/限流),日志可见
- 滚动剔除的
total_bytes 计数器在 DB 内原子维护,天然多实例安全
限制说明:CHALLENGE_MAX 仅对 memory 后端生效;redis 后端靠共享限流 + TTL 兜底。
各实例时钟需 NTP 同步(限流窗口基于本地时钟)。
产品数据:产品/配额/反馈存于共享数据库,多实例天然一致(Redis 仅负责 challenge 与限流)。
SDK 使用
完整使用文档见 docs/sdk.md(包路径 github.com/cxykevin/feederback/sdk)。
import feedbacksdk "github.com/cxykevin/feederback/sdk"
// 第二个参数为产品 ID(面板创建的自定义字符 ID),必填
client, err := feedbacksdk.New("http://127.0.0.1:8080", "game-app")
if err != nil { /* ... */ }
// ctx 可取消:PoW 求解与网络请求均响应取消
// 上传时自动计算可执行文件(os.Executable)sha256 并随反馈提交
result, err := client.Submit(ctx, content, logs, osInfo)
if err != nil { /* 处理错误 */ }
fmt.Println(result.FeedbackID) // 已本地验签通过
可执行文件指纹(默认开启):
- 上传时自动哈希
os.Executable() 的完整内容;单文件场景 binary_hash 即该文件的 sha256sum
WithExtraFile(path) 可附加文件一并纳入同一哈希流(按路径排序,结果确定)
WithExecutableHash(false) 关闭指纹计算(失败/禁用时相关字段为空串,不影响提交)
选项:WithHTTPClient、WithTimeout、WithMachineIDPath、WithMaxRetries、WithExecutableHash、WithExtraFile
- 机器码:首次生成 UUID 持久化到
os.UserCacheDir()/feederback/machine.id(可用 WithMachineIDPath 覆盖)
- 重试:429 按
Retry-After 等待、INVALID_CHALLENGE 重取、5xx/网络错误指数退避(WithMaxRetries)
- SDK 不记录 content/logs
限流与 429
| 限制 |
值 |
key |
| 每个 IP submit |
10/min |
ip:{ip} |
| 每个机器码 submit |
2/min |
mc:{machine_code} |
| 每个 IP challenge |
60/min |
ip:{ip} |
| 面板登录 |
5/min |
ip:{ip} |
超限返回 HTTP 429 + Retry-After + proto Error{RATE_LIMITED}。
协议(proto)
/api/v1/* 均为 protobuf body(Content-Type: application/x-protobuf):
POST /api/v1/challenge → Challenge{challenge_id, challenge, difficulty, expires_at, version}
POST /api/v1/submit → SubmitRequest{challenge_id, nonce, content, logs, os_info, machine_code,
product_id, binary_hash, binary_path, binary_size}
→ SubmitResponse{feedback_id, created_at, signature(Ed25519), public_key}
GET /api/v1/pubkey → PublicKey{key, issued_at}
失败体 → Error{code, message, retry_after_seconds}
PoW:SHA-256(challenge32 || be64(nonce)) 前导零 bit 数 ≥ difficulty。
签名字节串覆盖 id/content/logs/os_info/machine_code/product_id/binary_hash/created_at,见 internal/sign.CanonicalBytes。
开发
make gen # 重新生成 protobuf 代码
make test # go test -race ./...
make vet
make build # 输出 bin/server 与 bin/submit
make bench # PoW 校准
后台运行服务器(日志 data/server.log,PID data/server.pid):
make start # 编译并后台启动,等待 /healthz 就绪后返回
make status # 查看运行状态
make logs # 跟随日志
make stop # 按 PID 停止
# 自定义配置:环境变量直接前缀
ADMIN_PASSWORD=secret POW_DIFFICULTY=26 HTTP_ADDR=:8080 make start
目录结构
proto/ # protobuf 定义
gen/ # 生成代码
cmd/server # 服务端入口
cmd/benchpow # PoW 难度校准
cmd/submit # 简易提交 CLI(bin/submit)
internal/config # env + DSN 三库 + GORM
internal/pow # PoW 求解/验证 + challenge store
internal/sign # Ed25519 签名 + 规范字节串
internal/store # GORM 模型 + 滚动剔除
internal/ratelimit# 滑动窗口限流
internal/server # HTTP 路由/中间件/handler
internal/webpanel # 原生面板(embed + session + JSON API)
sdk/ # 客户端 SDK
examples/submit # SDK 演示
cmd/submit # 提交 CLI(见 cmd/submit)
docs/sdk.md # SDK 使用文档
test/ # 端到端测试