+
+
让 NAS 访问更简单
+ +
+ 面向 飞牛 fnOS 的轻量公网访问管理工具
+ 反向代理 · DDNS · HTTPS 证书 · 内网穿透 · 一站式 Web 管理
+
` 左右对照期望/现状)、操作按钮(下发配置 / 释放端口 / 推送证书);
+- 规则保存时的自动下发提示:非阻塞 toast「检测到 vhost 配置变化,点击下发」。
+
+## 6. 实现拆解与工作量
+
+| 模块 | 文件 | 内容 | 量级 |
+| --- | --- | --- | --- |
+| 迁移 | `migrations/023_frp_server_ssh.sql` | SSH 字段 | 小 |
+| SSH 客户端 | `internal/frp/ssh.go`(新) | 连接池/单会话命令执行/SFTP 推送/超时 | 中(x/crypto/ssh 已在 go.mod) |
+| 检测 | `internal/frp/probe.go`(新) | 探测命令编排 + 结果结构体 + 80 占用者判定 | 中 |
+| 配置 diff/应用 | `internal/frp/frps_apply.go`(新) | 期望配置生成(扩展现有 frps_template)、diff、原子推送、admin API reload、验证 | 中 |
+| 证书推送 | `internal/frp/cert_push.go`(新) | 证书文件推送 + frps.toml 证书段写入 | 小 |
+| Service/API | `frpmulti_handler.go` + `router.go` | 5 个端点 | 小 |
+| 前端 | `FrpView.vue` + `client.ts` | 管理弹窗 + 表单分组 + 按钮 | 中 |
+| 测试 | `probe_test.go` `frps_apply_test.go` | 命令输出解析、diff、原子写逻辑 | 中 |
+
+总计约 1500–2000 行(含测试),纯增量、不动现有链路(`mgmt_enabled=0` 时行为与 v1.1.0 完全一致)。
+
+## 7. 安全设计
+
+1. SSH 凭据 secret.Box 加密落库,API 永不回显(同 token 掩码机制);
+2. 推荐文档引导用户用**密钥登录**(Fonu 可一键生成 ed25519 密钥对、展示公钥让用户贴到 VPS `authorized_keys`);
+3. 所有远程命令为**固定白名单模板**(版本/端口/状态/替换文件/reload),不拼接用户输入,杜绝命令注入;SFTP 只写 `/etc/fonu/frps/` 前缀内路径;
+4. 危险动作(释放端口、重启 frps)二次确认 + 动作日志;
+5. Apply 幂等:重复执行无副作用(diff 无变化即跳过);
+6. frps admin API 调用走本机 127.0.0.1:7500(SSH 远端执行 curl),不暴露公网。
+
+## 8. 里程碑
+
+| 阶段 | 内容 | 验收 |
+| --- | --- | --- |
+| M1 | 迁移 + SSH 客户端 + 检测(Probe) | 服务端「检测」按钮能报告 frps 状态与 80 占用者 |
+| M2 | 期望配置生成 + diff + 原子推送 + reload + 验证 | 改规则后一键下发,`http://域名` 直达,`port unavailable` 类问题由系统自愈 |
+| M3 | 证书推送 + 续期联动 | 证书页一键部署到 VPS,续期自动重推 |
+| M4 | UI 完善 + 文档(手册第 6 节追加方案 A') | 手册更新,`port unavailable` 类问题由系统自愈 |
+
+## 9. 明确不做(本期)
+
+- 不实现 VPS 伴生 agent(能力更强但需安装,作为后续可选演进);
+- 不做 frps 二进制的远程安装/升级(用户自装,检测报告给出版本即可);
+- 不做多 VPS 并行下发编排(现有逐服务端模型已覆盖);
+- 不改 frp 二进制、不依赖 frp 私有接口(只用 admin API 与文件系统)。
diff --git a/docs/screenshots/certificates-apply.png b/docs/screenshots/certificates-apply.png
new file mode 100644
index 0000000..8b410fd
Binary files /dev/null and b/docs/screenshots/certificates-apply.png differ
diff --git a/docs/screenshots/certificates.png b/docs/screenshots/certificates.png
new file mode 100644
index 0000000..5d1197a
Binary files /dev/null and b/docs/screenshots/certificates.png differ
diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png
new file mode 100644
index 0000000..12dafcc
Binary files /dev/null and b/docs/screenshots/dashboard.png differ
diff --git a/docs/screenshots/ddns.png b/docs/screenshots/ddns.png
new file mode 100644
index 0000000..c319f46
Binary files /dev/null and b/docs/screenshots/ddns.png differ
diff --git a/docs/screenshots/frp.png b/docs/screenshots/frp.png
new file mode 100644
index 0000000..25c5034
Binary files /dev/null and b/docs/screenshots/frp.png differ
diff --git a/docs/screenshots/logs.png b/docs/screenshots/logs.png
new file mode 100644
index 0000000..9be7488
Binary files /dev/null and b/docs/screenshots/logs.png differ
diff --git a/docs/screenshots/proxy-detail.png b/docs/screenshots/proxy-detail.png
new file mode 100644
index 0000000..df9e2ce
Binary files /dev/null and b/docs/screenshots/proxy-detail.png differ
diff --git a/docs/screenshots/proxy-rule-basic.png b/docs/screenshots/proxy-rule-basic.png
new file mode 100644
index 0000000..bc19242
Binary files /dev/null and b/docs/screenshots/proxy-rule-basic.png differ
diff --git a/docs/screenshots/proxy-rule-nginx.png b/docs/screenshots/proxy-rule-nginx.png
new file mode 100644
index 0000000..f48b874
Binary files /dev/null and b/docs/screenshots/proxy-rule-nginx.png differ
diff --git a/docs/screenshots/proxy-rule-security.png b/docs/screenshots/proxy-rule-security.png
new file mode 100644
index 0000000..9a757ff
Binary files /dev/null and b/docs/screenshots/proxy-rule-security.png differ
diff --git a/docs/screenshots/proxy.png b/docs/screenshots/proxy.png
new file mode 100644
index 0000000..f8c3363
Binary files /dev/null and b/docs/screenshots/proxy.png differ
diff --git a/docs/screenshots/settings-advanced.png b/docs/screenshots/settings-advanced.png
new file mode 100644
index 0000000..b787388
Binary files /dev/null and b/docs/screenshots/settings-advanced.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
new file mode 100644
index 0000000..0481b26
Binary files /dev/null and b/docs/screenshots/settings.png differ
diff --git a/fonu-agent.exe b/fonu-agent.exe
new file mode 100644
index 0000000..e1dd5aa
Binary files /dev/null and b/fonu-agent.exe differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..b5bad1f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,51 @@
+module github.com/fonu/fonu
+
+go 1.23.0
+
+require (
+ github.com/go-acme/lego/v4 v4.23.1
+ github.com/nrdcg/dnspod-go v0.4.0
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128
+ github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128
+ github.com/volcengine/volcengine-go-sdk v1.2.53
+ golang.org/x/crypto v0.36.0
+ golang.org/x/sys v0.31.0
+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
+ modernc.org/sqlite v1.34.4
+)
+
+require (
+ github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cloudflare/cloudflare-go v0.115.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
+ github.com/google/go-querystring v1.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/miekg/dns v1.1.64 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/volcengine/volc-sdk-golang v1.0.199 // indirect
+ golang.org/x/mod v0.23.0 // indirect
+ golang.org/x/net v0.37.0 // indirect
+ golang.org/x/sync v0.12.0 // indirect
+ golang.org/x/text v0.23.0 // indirect
+ golang.org/x/time v0.11.0 // indirect
+ golang.org/x/tools v0.30.0 // indirect
+ gopkg.in/ini.v1 v1.67.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7261931
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,908 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
+cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
+cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
+cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
+cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
+cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
+cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
+cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
+cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
+cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
+cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
+cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
+cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
+cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
+cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
+cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
+cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
+cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
+cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
+cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
+cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
+cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
+cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
+cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
+cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
+cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
+cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
+github.com/HdrHistogram/hdrhistogram-go v1.1.0/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
+github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
+github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
+github.com/Shopify/sarama v1.30.1/go.mod h1:hGgx05L/DiW8XYBXeJdKIN6V2QUy2H6JqME5VT1NLRw=
+github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
+github.com/Shopify/toxiproxy/v2 v2.1.6-0.20210914104332-15ea381dcdae/go.mod h1:/cvHQkZ1fst0EmZnA5dFtiQdWCNCFYzb+uE2vqVgvx0=
+github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
+github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
+github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
+github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.100 h1:yUkCbrSM1cWtgBfRVKMQtdt22KhDvKY7g4V+92eG9wA=
+github.com/aliyun/alibaba-cloud-sdk-go v1.63.100/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
+github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
+github.com/armon/go-metrics v0.3.9/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
+github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
+github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
+github.com/aws/aws-sdk-go v1.40.45/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
+github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
+github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
+github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/casbin/casbin/v2 v2.37.0/go.mod h1:vByNa/Fchek0KZUgG5wEsl7iFsiviAYKRtgrQfcJqHg=
+github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
+github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
+github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
+github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
+github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
+github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM=
+github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
+github.com/eapache/go-resiliency v1.2.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
+github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
+github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
+github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
+github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/franela/goblin v0.0.0-20210519012713-85d372ac71e2/go.mod h1:VzmDKDJVZI3aJmnRI9VjAn9nJ8qPPsN1fqzr9dqInIo=
+github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
+github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/go-acme/lego/v4 v4.23.1 h1:lZ5fGtGESA2L9FB8dNTvrQUq3/X4QOb8ExkKyY7LSV4=
+github.com/go-acme/lego/v4 v4.23.1/go.mod h1:7UMVR7oQbIYw6V7mTgGwi4Er7B6Ww0c+c8feiBM0EgI=
+github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
+github.com/go-kit/kit v0.12.0/go.mod h1:lHd+EkCZPIwYItmGDDRdhinkzX2A1sj+M9biaEaizzs=
+github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
+github.com/go-zookeeper/zk v1.0.2/go.mod h1:nOB03cncLtlp4t+UAkGSV+9beXP/akpekBwL+UX1Qcw=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
+github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
+github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
+github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
+github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
+github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
+github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
+github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
+github.com/hashicorp/consul/api v1.10.1/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
+github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-hclog v0.16.2/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
+github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
+github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
+github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
+github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
+github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
+github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
+github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
+github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
+github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/hudl/fargo v1.4.0/go.mod h1:9Ai6uvFy5fQNq6VPKtg+Ceq1+eTY4nKUlR2JElEOcDo=
+github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/influxdata/influxdb1-client v0.0.0-20200827194710-b269163b24ab/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
+github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
+github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
+github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
+github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
+github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
+github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
+github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4=
+github.com/miekg/dns v1.1.64 h1:wuZgD9wwCE6XMT05UU/mlSko71eRSXEAm2EbjQXLKnQ=
+github.com/miekg/dns v1.1.64/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
+github.com/minio/highwayhash v1.0.1/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
+github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
+github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.4.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nats-io/jwt v1.2.2/go.mod h1:/xX356yQA6LuXI9xWW7mZNpxgF2mBmGecH+Fj34sP5Q=
+github.com/nats-io/jwt/v2 v2.0.3/go.mod h1:VRP+deawSXyhNjXmxPCHskrR6Mq50BqpEI5SEcNiGlY=
+github.com/nats-io/nats-server/v2 v2.5.0/go.mod h1:Kj86UtrXAL6LwYRA6H4RqzkHhK0Vcv2ZnKD5WbQ1t3g=
+github.com/nats-io/nats.go v1.12.1/go.mod h1:BPko4oXsySz4aSWeFgOHLZs3G4Jq4ZAyE6/zMCxRT6w=
+github.com/nats-io/nkeys v0.2.0/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s=
+github.com/nats-io/nkeys v0.3.0/go.mod h1:gvUNGjVcM2IPr5rCsRsC6Wb3Hr2CQAm08dsxtV6A5y4=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/nrdcg/dnspod-go v0.4.0 h1:c/jn1mLZNKF3/osJ6mz3QPxTudvPArXTjpkmYj0uK6U=
+github.com/nrdcg/dnspod-go v0.4.0/go.mod h1:vZSoFSFeQVm2gWLMkyX61LZ8HI3BaqtHZWgPTGKr6KQ=
+github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
+github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
+github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
+github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
+github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
+github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
+github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
+github.com/openzipkin/zipkin-go v0.2.5/go.mod h1:KpXfKdgRDnnhsxw4pNIH9Md5lyFqKUa4YDFlwRYAMyE=
+github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
+github.com/performancecopilot/speed/v4 v4.0.0/go.mod h1:qxrSyuDGrTOWfV+uKRFhfxw6h/4HXRGUiZiufxo49BM=
+github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
+github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
+github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
+github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
+github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
+github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
+github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
+github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
+github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
+github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
+github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
+github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
+github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
+github.com/prometheus/common v0.30.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
+github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
+github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
+github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
+github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
+github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
+github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
+github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
+github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
+github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
+github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
+github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/amqp v1.0.0/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
+github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128 h1:NGnqDc8FQL0YdiCHgTO4Wkso6ToD8rE3JW9VOzoPBNA=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1128/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128 h1:mrJ5Fbkd7sZIJ5F6oRfh5zebPQaudPH9Y0+GUmFytYU=
+github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1128/go.mod h1:zbsYIBT+VTX4z4ocjTAdLBIWyNYj3z0BRqd0iPdnjsk=
+github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
+github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
+github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk=
+github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg=
+github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
+github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
+github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
+github.com/volcengine/volc-sdk-golang v1.0.199 h1:zv9QOqTl/IsLwtfC37GlJtcz6vMAHi+pjq8ILWjLYUc=
+github.com/volcengine/volc-sdk-golang v1.0.199/go.mod h1:stZX+EPgv1vF4nZwOlEe8iGcriUPRBKX8zA19gXycOQ=
+github.com/volcengine/volcengine-go-sdk v1.2.53 h1:aWooc5D8+Tx+R+Z7VsIp8aJCbWgfGdb687tq8G5b2JI=
+github.com/volcengine/volcengine-go-sdk v1.2.53/go.mod h1:DURKPVQ8Rsi4OQu4/NPjUyjla3MeHPgXT/qV+400fAw=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
+github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
+github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
+go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
+go.etcd.io/etcd/client/v3 v3.5.0/go.mod h1:AIKXXVX/DQXtfTEqBryiLTUXwON+GuvO6Z7lLS/oTh0=
+go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
+go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
+go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
+go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
+go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
+go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
+go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
+golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210915214749-c084706c2272/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210920023735-84f357641f63/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
+golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
+golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
+golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
+golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
+golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
+golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
+golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
+golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
+golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
+golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
+golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210917221730-978cfadd31cf/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
+golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
+golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
+golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
+golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
+golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
+golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
+golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
+gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
+gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
+gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
+google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
+google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
+google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
+google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
+google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
+google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
+google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
+google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
+google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
+google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
+google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
+google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
+google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
+google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
+google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
+google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
+google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
+google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
+google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
+google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
+google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
+google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
+google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
+google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
+google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
+google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
+honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
+modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
+rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=
diff --git a/internal/acme/ca.go b/internal/acme/ca.go
new file mode 100644
index 0000000..4388f2b
--- /dev/null
+++ b/internal/acme/ca.go
@@ -0,0 +1,89 @@
+package acme
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/go-acme/lego/v4/lego"
+)
+
+const (
+ CALetsEncrypt = "letsencrypt"
+ CALetsEncryptStaging = "letsencrypt-staging"
+ CAZeroSSL = "zerossl"
+ CABuypass = "buypass"
+)
+
+const (
+ zeroSSLDirectoryURL = "https://acme.zerossl.com/v2/DV90"
+ buypassDirectoryURL = "https://api.buypass.com/acme/directory"
+)
+
+func NormalizeCA(ca string) string {
+ switch ca {
+ case "", CALetsEncrypt, "production":
+ return CALetsEncrypt
+ case CALetsEncryptStaging, "staging", "letsencrypt-test":
+ return CALetsEncryptStaging
+ case CAZeroSSL:
+ return CAZeroSSL
+ case CABuypass, "buypass-go":
+ return CABuypass
+ default:
+ return ca
+ }
+}
+
+func DirectoryURL(ca string) (string, error) {
+ switch NormalizeCA(ca) {
+ case CALetsEncrypt:
+ return lego.LEDirectoryProduction, nil
+ case CALetsEncryptStaging:
+ return lego.LEDirectoryStaging, nil
+ case CAZeroSSL:
+ return zeroSSLDirectoryURL, nil
+ case CABuypass:
+ return buypassDirectoryURL, nil
+ default:
+ return "", fmt.Errorf("不支持的颁发机构:%s", ca)
+ }
+}
+
+func CALabel(ca string) string {
+ switch NormalizeCA(ca) {
+ case CALetsEncrypt:
+ return "Let's Encrypt"
+ case CALetsEncryptStaging:
+ return "Let's Encrypt 测试"
+ case CAZeroSSL:
+ return "ZeroSSL"
+ case CABuypass:
+ return "Buypass"
+ default:
+ if ca == "" {
+ return "手动导入"
+ }
+ return ca
+ }
+}
+
+func ValidateCA(ca string) error {
+ _, err := DirectoryURL(ca)
+ return err
+}
+
+func ValidateCADomains(ca string, domains []string) error {
+ switch NormalizeCA(ca) {
+ case CABuypass:
+ if len(domains) > 5 {
+ return fmt.Errorf("Buypass 单张证书最多支持 5 个域名")
+ }
+ for _, domain := range domains {
+ if strings.HasPrefix(domain, "*.") {
+ return fmt.Errorf("Buypass 不支持通配符证书")
+ }
+ }
+ }
+ return nil
+}
+
diff --git a/internal/acme/ca_test.go b/internal/acme/ca_test.go
new file mode 100644
index 0000000..cdf67e6
--- /dev/null
+++ b/internal/acme/ca_test.go
@@ -0,0 +1,37 @@
+package acme
+
+import "testing"
+
+func TestDirectoryURL(t *testing.T) {
+ url, err := DirectoryURL(CALetsEncrypt)
+ if err != nil || url == "" {
+ t.Fatalf("production: %v", err)
+ }
+ url, err = DirectoryURL(CALetsEncryptStaging)
+ if err != nil || url == "" {
+ t.Fatalf("staging: %v", err)
+ }
+ url, err = DirectoryURL(CAZeroSSL)
+ if err != nil || url == "" {
+ t.Fatalf("zerossl: %v", err)
+ }
+ url, err = DirectoryURL(CABuypass)
+ if err != nil || url == "" {
+ t.Fatalf("buypass: %v", err)
+ }
+ if err := ValidateCA("unknown"); err == nil {
+ t.Fatal("expected error for unknown ca")
+ }
+}
+
+func TestValidateCADomainsBuypass(t *testing.T) {
+ if err := ValidateCADomains(CABuypass, []string{"*.example.com"}); err == nil {
+ t.Fatal("expected wildcard error")
+ }
+ if err := ValidateCADomains(CABuypass, []string{"a.com", "b.com", "c.com", "d.com", "e.com", "f.com"}); err == nil {
+ t.Fatal("expected domain limit error")
+ }
+ if err := ValidateCADomains(CALetsEncrypt, []string{"*.example.com"}); err != nil {
+ t.Fatalf("letsencrypt wildcard should be allowed: %v", err)
+ }
+}
diff --git a/internal/acme/dns_provider.go b/internal/acme/dns_provider.go
new file mode 100644
index 0000000..a95a01d
--- /dev/null
+++ b/internal/acme/dns_provider.go
@@ -0,0 +1,98 @@
+package acme
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/providers/dns/alidns"
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare"
+ "github.com/go-acme/lego/v4/providers/dns/dnspod"
+ "github.com/go-acme/lego/v4/providers/dns/tencentcloud"
+
+ "github.com/fonu/fonu/internal/ddns"
+)
+
+func newDNS01Provider(provider string, cred ddns.Credentials) (challenge.Provider, error) {
+ switch provider {
+ case "dnspod":
+ cfg := dnspod.NewDefaultConfig()
+ cfg.LoginToken = cred.LoginToken()
+ return dnspod.NewDNSProviderConfig(cfg)
+ case "alidns":
+ cfg := alidns.NewDefaultConfig()
+ cfg.APIKey = cred.Token
+ cfg.SecretKey = cred.Secret
+ return alidns.NewDNSProviderConfig(cfg)
+ case "tencentcloud":
+ cfg := tencentcloud.NewDefaultConfig()
+ cfg.SecretID = cred.Token
+ cfg.SecretKey = cred.Secret
+ return tencentcloud.NewDNSProviderConfig(cfg)
+ case "volcengine":
+ return newVolcengineDNSProvider(cred)
+ default:
+ cfg := cloudflare.NewDefaultConfig()
+ cfg.AuthToken = cred.Token
+ return cloudflare.NewDNSProviderConfig(cfg)
+ }
+}
+
+func dnsProviderName(provider string) string {
+ switch provider {
+ case "dnspod", "alidns", "tencentcloud", "volcengine":
+ return provider
+ default:
+ return "cloudflare"
+ }
+}
+
+type loggingDNSProvider struct {
+ inner challenge.Provider
+ job *Job
+}
+
+func wrapDNSProvider(inner challenge.Provider, job *Job) challenge.Provider {
+ if job == nil {
+ return inner
+ }
+ return &loggingDNSProvider{inner: inner, job: job}
+}
+
+func (p *loggingDNSProvider) Present(domain, token, keyAuth string) error {
+ record := strings.TrimSuffix(domain, ".")
+ p.job.Info(fmt.Sprintf("添加 DNS TXT 记录: _acme-challenge.%s", record))
+ if err := p.inner.Present(domain, token, keyAuth); err != nil {
+ p.job.Error(fmt.Sprintf("添加 DNS TXT 记录失败: %v", err))
+ return err
+ }
+ p.job.Info("DNS TXT 记录已添加,等待 DNS 传播…")
+ return nil
+}
+
+func (p *loggingDNSProvider) CleanUp(domain, token, keyAuth string) error {
+ p.job.Info("清理 DNS TXT 验证记录…")
+ return p.inner.CleanUp(domain, token, keyAuth)
+}
+
+func validateDNSCredentials(provider string, cred ddns.Credentials) error {
+ if err := cred.Validate(dnsProviderName(provider)); err != nil {
+ return err
+ }
+ if provider == "dnspod" && cred.TokenID == "" {
+ return fmt.Errorf("请先在 DDNS 页面配置 DNSPod ID 和 Token")
+ }
+ if provider == "alidns" && cred.Secret == "" {
+ return fmt.Errorf("请先在 DDNS 页面配置阿里云 AccessKey")
+ }
+ if provider == "tencentcloud" && cred.Secret == "" {
+ return fmt.Errorf("请先在 DDNS 页面配置腾讯云 SecretKey")
+ }
+ if provider == "volcengine" && cred.Secret == "" {
+ return fmt.Errorf("请先在 DDNS 页面配置火山引擎 Secret Access Key")
+ }
+ if provider != "dnspod" && provider != "alidns" && provider != "tencentcloud" && provider != "volcengine" && cred.Token == "" {
+ return fmt.Errorf("请先在 DDNS 页面配置 Cloudflare API Token")
+ }
+ return nil
+}
diff --git a/internal/acme/dns_provider_test.go b/internal/acme/dns_provider_test.go
new file mode 100644
index 0000000..de2422a
--- /dev/null
+++ b/internal/acme/dns_provider_test.go
@@ -0,0 +1,23 @@
+package acme
+
+import (
+ "testing"
+
+ "github.com/fonu/fonu/internal/ddns"
+)
+
+func TestValidateDNSCredentialsVolcengine(t *testing.T) {
+ if err := validateDNSCredentials("volcengine", ddns.Credentials{Token: "ak", Secret: "sk"}); err != nil {
+ t.Fatalf("expected volcengine credentials to pass: %v", err)
+ }
+}
+
+func TestNewVolcengineDNSProvider(t *testing.T) {
+ p, err := newVolcengineDNSProvider(ddns.Credentials{Token: "ak", Secret: "sk"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p == nil {
+ t.Fatal("expected provider")
+ }
+}
diff --git a/internal/acme/domains.go b/internal/acme/domains.go
new file mode 100644
index 0000000..83593bf
--- /dev/null
+++ b/internal/acme/domains.go
@@ -0,0 +1,166 @@
+package acme
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/validate"
+)
+
+func NormalizeCertDomains(raw []string) ([]string, error) {
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("请填写至少一个域名")
+ }
+ if len(raw) > 100 {
+ return nil, fmt.Errorf("域名数量不能超过 100 个")
+ }
+
+ seen := map[string]bool{}
+ out := make([]string, 0, len(raw))
+ for _, item := range raw {
+ for _, part := range splitDomainInput(item) {
+ domain, err := normalizeCertDomain(part)
+ if err != nil {
+ return nil, err
+ }
+ if seen[domain] {
+ continue
+ }
+ seen[domain] = true
+ out = append(out, domain)
+ }
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("请填写至少一个域名")
+ }
+ return out, nil
+}
+
+func splitDomainInput(input string) []string {
+ input = strings.ReplaceAll(input, "\r\n", "\n")
+ input = strings.ReplaceAll(input, ",", "\n")
+ parts := strings.Split(input, "\n")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(part)
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
+func normalizeCertDomain(domain string) (string, error) {
+ domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
+ if domain == "" {
+ return "", fmt.Errorf("域名不能为空")
+ }
+ wildcard := strings.HasPrefix(domain, "*.")
+ base := strings.TrimPrefix(domain, "*.")
+ if err := validate.Domain(base); err != nil {
+ return "", err
+ }
+ if wildcard {
+ return "*." + base, nil
+ }
+ return base, nil
+}
+
+func DomainsUnderZone(domains []string, zone string) error {
+ return DomainsUnderZones(domains, []string{zone})
+}
+
+func DomainsUnderZones(domains []string, zones []string) error {
+ normalized := normalizeManagedZones(zones)
+ if len(normalized) == 0 {
+ return fmt.Errorf("请选择 DNS 凭证")
+ }
+ for _, domain := range domains {
+ base := strings.TrimPrefix(domain, "*.")
+ if !domainUnderAnyZone(base, normalized) {
+ return fmt.Errorf("域名 %s 不在所选 DNS 凭证的管理范围内(%s)", domain, strings.Join(normalized, "、"))
+ }
+ }
+ return nil
+}
+
+func normalizeManagedZones(zones []string) []string {
+ seen := map[string]bool{}
+ out := make([]string, 0, len(zones))
+ for _, zone := range zones {
+ zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
+ if zone == "" || seen[zone] {
+ continue
+ }
+ seen[zone] = true
+ out = append(out, zone)
+ }
+ return out
+}
+
+func domainUnderAnyZone(domain string, zones []string) bool {
+ for _, zone := range zones {
+ if domain == zone || strings.HasSuffix(domain, "."+zone) {
+ return true
+ }
+ }
+ return false
+}
+
+func PrimaryCertDomain(domains []string) string {
+ for _, domain := range domains {
+ if !strings.HasPrefix(domain, "*.") {
+ return domain
+ }
+ }
+ if len(domains) > 0 {
+ return strings.TrimPrefix(domains[0], "*.")
+ }
+ return ""
+}
+
+func HasWildcardDomain(domains []string) bool {
+ for _, domain := range domains {
+ if strings.HasPrefix(domain, "*.") {
+ return true
+ }
+ }
+ return false
+}
+
+func LegacyCertDomains(domain string, wildcard bool) []string {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ return nil
+ }
+ if wildcard {
+ return []string{domain, "*." + domain}
+ }
+ return []string{domain}
+}
+
+func EncodeCertDomains(domains []string) string {
+ if len(domains) == 0 {
+ return ""
+ }
+ b, err := json.Marshal(domains)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+func DecodeCertDomains(raw string, domain string, wildcard bool) []string {
+ raw = strings.TrimSpace(raw)
+ if raw != "" {
+ var domains []string
+ if err := json.Unmarshal([]byte(raw), &domains); err == nil && len(domains) > 0 {
+ normalized, err := NormalizeCertDomains(domains)
+ if err == nil {
+ return normalized
+ }
+ }
+ }
+ return LegacyCertDomains(domain, wildcard)
+}
diff --git a/internal/acme/domains_test.go b/internal/acme/domains_test.go
new file mode 100644
index 0000000..1c7f4da
--- /dev/null
+++ b/internal/acme/domains_test.go
@@ -0,0 +1,43 @@
+package acme
+
+import "testing"
+
+func TestNormalizeCertDomains(t *testing.T) {
+ domains, err := NormalizeCertDomains([]string{"example.com", "*.example.com", "nas.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(domains) != 3 {
+ t.Fatalf("expected 3 domains, got %d", len(domains))
+ }
+ if domains[1] != "*.example.com" {
+ t.Fatalf("unexpected wildcard: %s", domains[1])
+ }
+}
+
+func TestDomainsUnderZone(t *testing.T) {
+ domains := []string{"example.com", "*.example.com", "nas.example.com"}
+ if err := DomainsUnderZone(domains, "example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := DomainsUnderZone([]string{"other.com"}, "example.com"); err == nil {
+ t.Fatal("expected zone mismatch error")
+ }
+}
+
+func TestDomainsUnderZonesMultiRoot(t *testing.T) {
+ zones := []string{"roven.cc", "chiak.cc"}
+ if err := DomainsUnderZones([]string{"chiak.cc", "*.chiak.cc"}, zones); err != nil {
+ t.Fatal(err)
+ }
+ if err := DomainsUnderZones([]string{"other.com"}, zones); err == nil {
+ t.Fatal("expected zone mismatch error")
+ }
+}
+
+func TestDecodeCertDomainsLegacy(t *testing.T) {
+ got := DecodeCertDomains("", "example.com", true)
+ if len(got) != 2 || got[1] != "*.example.com" {
+ t.Fatalf("unexpected legacy domains: %v", got)
+ }
+}
diff --git a/internal/acme/download.go b/internal/acme/download.go
new file mode 100644
index 0000000..46ef9ce
--- /dev/null
+++ b/internal/acme/download.go
@@ -0,0 +1,117 @@
+package acme
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ certstore "github.com/fonu/fonu/internal/certificate"
+)
+
+type DownloadPart string
+
+const (
+ DownloadZIP DownloadPart = "zip"
+ DownloadCert DownloadPart = "cert"
+ DownloadKey DownloadPart = "key"
+)
+
+func (s *Service) Download(ctx context.Context, domain string, part string) (filename string, contentType string, data []byte, err error) {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ return "", "", nil, fmt.Errorf("域名不能为空")
+ }
+
+ rec, err := s.store.GetByDomain(ctx, domain)
+ if err != nil {
+ return "", "", nil, err
+ }
+ if rec.CertPath == "" || rec.KeyPath == "" {
+ return "", "", nil, fmt.Errorf("证书文件不存在,可能尚未申请成功")
+ }
+ if err := validateCertFilePath(s.cfg.CertsDir(), rec.CertPath); err != nil {
+ return "", "", nil, err
+ }
+ if err := validateCertFilePath(s.cfg.CertsDir(), rec.KeyPath); err != nil {
+ return "", "", nil, err
+ }
+
+ certPEM, err := os.ReadFile(rec.CertPath)
+ if err != nil {
+ return "", "", nil, fmt.Errorf("读取证书失败")
+ }
+ keyPEM, err := os.ReadFile(rec.KeyPath)
+ if err != nil {
+ return "", "", nil, fmt.Errorf("读取私钥失败")
+ }
+
+ base := downloadBaseName(rec)
+ switch DownloadPart(strings.ToLower(strings.TrimSpace(part))) {
+ case "", DownloadZIP:
+ data, err = zipCertBundle(base, certPEM, keyPEM)
+ if err != nil {
+ return "", "", nil, err
+ }
+ return base + ".zip", "application/zip", data, nil
+ case DownloadCert:
+ return base + "-fullchain.pem", "application/x-pem-file", certPEM, nil
+ case DownloadKey:
+ return base + "-privatekey.pem", "application/x-pem-file", keyPEM, nil
+ default:
+ return "", "", nil, fmt.Errorf("不支持的下载类型")
+ }
+}
+
+func downloadBaseName(rec certstore.Record) string {
+ name := strings.TrimPrefix(rec.Domain, "*.")
+ name = strings.ReplaceAll(name, "*", "wildcard")
+ name = strings.ReplaceAll(name, "/", "_")
+ if name == "" {
+ name = "certificate"
+ }
+ return name
+}
+
+func validateCertFilePath(certsDir, path string) error {
+ absCerts, err := filepath.Abs(certsDir)
+ if err != nil {
+ return fmt.Errorf("证书目录无效")
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return fmt.Errorf("证书路径无效")
+ }
+ rel, err := filepath.Rel(absCerts, absPath)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ return fmt.Errorf("证书路径无效")
+ }
+ return nil
+}
+
+func zipCertBundle(base string, certPEM, keyPEM []byte) ([]byte, error) {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ if err := writeZipEntry(zw, "fullchain.pem", certPEM); err != nil {
+ return nil, err
+ }
+ if err := writeZipEntry(zw, "privatekey.pem", keyPEM); err != nil {
+ return nil, err
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func writeZipEntry(zw *zip.Writer, name string, data []byte) error {
+ w, err := zw.Create(name)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(data)
+ return err
+}
diff --git a/internal/acme/download_test.go b/internal/acme/download_test.go
new file mode 100644
index 0000000..b1be252
--- /dev/null
+++ b/internal/acme/download_test.go
@@ -0,0 +1,26 @@
+package acme
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestValidateCertFilePath(t *testing.T) {
+ certsDir := filepath.Clean("/data/certs")
+ if err := validateCertFilePath(certsDir, "/data/certs/example.com/fullchain.pem"); err != nil {
+ t.Fatalf("expected valid path: %v", err)
+ }
+ if err := validateCertFilePath(certsDir, "/etc/passwd"); err == nil {
+ t.Fatal("expected invalid path")
+ }
+}
+
+func TestZipCertBundle(t *testing.T) {
+ data, err := zipCertBundle("example.com", []byte("cert"), []byte("key"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) == 0 {
+ t.Fatal("empty zip")
+ }
+}
diff --git a/internal/acme/job.go b/internal/acme/job.go
new file mode 100644
index 0000000..c6cb48c
--- /dev/null
+++ b/internal/acme/job.go
@@ -0,0 +1,183 @@
+package acme
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "sync"
+ "time"
+
+ certstore "github.com/fonu/fonu/internal/certificate"
+)
+
+type JobEvent struct {
+ Type string `json:"type"`
+ Level string `json:"level,omitempty"`
+ Message string `json:"message,omitempty"`
+ Result *JobDonePayload `json:"result,omitempty"`
+}
+
+type JobDonePayload struct {
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+ Domain string `json:"domain,omitempty"`
+ CertPath string `json:"cert_path,omitempty"`
+ KeyPath string `json:"key_path,omitempty"`
+ CertDir string `json:"cert_dir,omitempty"`
+ ExpiresAt string `json:"expires_at,omitempty"`
+}
+
+type Job struct {
+ id string
+ mu sync.Mutex
+ events []JobEvent
+ done bool
+ waiters map[chan JobEvent]struct{}
+}
+
+type JobManager struct {
+ mu sync.Mutex
+ jobs map[string]*Job
+}
+
+func NewJobManager() *JobManager {
+ return &JobManager{jobs: map[string]*Job{}}
+}
+
+func (m *JobManager) Create() *Job {
+ job := &Job{
+ id: fmt.Sprintf("%d", time.Now().UnixNano()),
+ waiters: map[chan JobEvent]struct{}{},
+ }
+ m.mu.Lock()
+ m.jobs[job.id] = job
+ m.mu.Unlock()
+ return job
+}
+
+func (m *JobManager) Get(id string) *Job {
+ m.mu.Lock()
+ job := m.jobs[id]
+ m.mu.Unlock()
+ return job
+}
+
+func (j *Job) ID() string {
+ return j.id
+}
+
+func (j *Job) Append(level, message string) {
+ j.emit(JobEvent{Type: "log", Level: level, Message: message})
+}
+
+func (j *Job) Info(message string) { j.Append("info", message) }
+func (j *Job) Warn(message string) { j.Append("warn", message) }
+func (j *Job) Error(message string) { j.Append("error", message) }
+
+func (j *Job) Finish(result JobDonePayload) {
+ j.emit(JobEvent{Type: "done", Result: &result})
+}
+
+func (j *Job) emit(ev JobEvent) {
+ j.mu.Lock()
+ j.events = append(j.events, ev)
+ if ev.Type == "done" {
+ j.done = true
+ }
+ for ch := range j.waiters {
+ ch <- ev
+ }
+ j.mu.Unlock()
+}
+
+func (j *Job) Snapshot() []JobEvent {
+ j.mu.Lock()
+ out := append([]JobEvent(nil), j.events...)
+ j.mu.Unlock()
+ return out
+}
+
+func (j *Job) Subscribe() chan JobEvent {
+ ch := make(chan JobEvent, 32)
+ j.mu.Lock()
+ j.waiters[ch] = struct{}{}
+ j.mu.Unlock()
+ return ch
+}
+
+func (j *Job) Unsubscribe(ch chan JobEvent) {
+ j.mu.Lock()
+ delete(j.waiters, ch)
+ j.mu.Unlock()
+ close(ch)
+}
+
+func (m *JobManager) Stream(ctx context.Context, w http.ResponseWriter, id string, flush func() error) error {
+ job := m.Get(id)
+ if job == nil {
+ return fmt.Errorf("任务不存在或已过期")
+ }
+
+ ch := job.Subscribe()
+ defer job.Unsubscribe(ch)
+
+ for _, ev := range job.Snapshot() {
+ if err := writeJobSSE(w, ev); err != nil {
+ return err
+ }
+ if err := flush(); err != nil {
+ return err
+ }
+ if ev.Type == "done" {
+ return nil
+ }
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case ev := <-ch:
+ if err := writeJobSSE(w, ev); err != nil {
+ return err
+ }
+ if err := flush(); err != nil {
+ return err
+ }
+ if ev.Type == "done" {
+ return nil
+ }
+ }
+ }
+}
+
+func writeJobSSE(w io.Writer, ev JobEvent) error {
+ data, err := json.Marshal(ev)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
+ return err
+}
+
+func jobDoneFromRecord(rec certstore.Record, err error) JobDonePayload {
+ if err != nil {
+ return JobDonePayload{OK: false, Error: err.Error()}
+ }
+ payload := JobDonePayload{
+ OK: true,
+ Domain: rec.Domain,
+ CertPath: rec.CertPath,
+ KeyPath: rec.KeyPath,
+ }
+ if rec.CertPath != "" {
+ payload.CertDir = filepath.Dir(rec.CertPath)
+ }
+ if rec.ExpiresAt != nil {
+ payload.ExpiresAt = rec.ExpiresAt.UTC().Format(time.RFC3339)
+ }
+ return payload
+}
diff --git a/internal/acme/service.go b/internal/acme/service.go
new file mode 100644
index 0000000..97dbd9f
--- /dev/null
+++ b/internal/acme/service.go
@@ -0,0 +1,621 @@
+package acme
+
+import (
+ "context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/mail"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/go-acme/lego/v4/certcrypto"
+ legocert "github.com/go-acme/lego/v4/certificate"
+ "github.com/go-acme/lego/v4/lego"
+ "github.com/go-acme/lego/v4/registration"
+
+ certstore "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/ddns"
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+ cfg config.Config
+ store *certstore.Store
+ ddnsSvc *ddns.Service
+ settings *settings.Store
+ proxySvc *service.ProxyService
+ logger *slog.Logger
+ notify *notify.Service
+ jobs *JobManager
+
+ expiryAlertMu sync.Mutex
+ expiryAlerted map[string]string
+}
+
+func NewService(cfg config.Config, store *certstore.Store, ddnsSvc *ddns.Service, settings *settings.Store, proxySvc *service.ProxyService, logger *slog.Logger, notifySvc *notify.Service) *Service {
+ return &Service{
+ cfg: cfg,
+ store: store,
+ ddnsSvc: ddnsSvc,
+ settings: settings,
+ proxySvc: proxySvc,
+ logger: logger.With("module", "ACME"),
+ notify: notifySvc,
+ jobs: NewJobManager(),
+ expiryAlerted: map[string]string{},
+ }
+}
+
+func (s *Service) List(ctx context.Context) ([]certstore.Record, error) {
+ return s.store.List(ctx)
+}
+
+func (s *Service) StartApply(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) (string, error) {
+ if ddnsConfigID <= 0 {
+ return "", fmt.Errorf("请选择 DNS 任务")
+ }
+ domains, ca, email, primary, dnsZone, provider, cred, err := s.prepareApplyWithConfig(ctx, domains, ca, email, ddnsConfigID)
+ if err != nil {
+ return "", err
+ }
+ job := s.jobs.Create()
+ go s.runApplyJob(job, dnsZone, domains, ca, email, primary, provider, cred)
+ return job.ID(), nil
+}
+
+func (s *Service) StreamJob(ctx context.Context, w http.ResponseWriter, jobID string, flush func() error) error {
+ return s.jobs.Stream(ctx, w, jobID, flush)
+}
+
+func (s *Service) prepareApplyWithConfig(ctx context.Context, domains []string, ca, email string, ddnsConfigID int64) ([]string, string, string, string, string, string, ddns.Credentials, error) {
+ domains, err := NormalizeCertDomains(domains)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ primary := PrimaryCertDomain(domains)
+ if primary == "" {
+ return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
+ }
+ email, err = s.resolveACMEEmail(ctx, email)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ ca, err = s.resolveCA(ctx, ca)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ if err := ValidateCADomains(ca, domains); err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+
+ cfg, provider, cred, err := s.ddnsSvc.CredentialsForConfigID(ctx, ddnsConfigID)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ if err := validateDNSCredentials(provider, cred); err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ dnsZone := cfg.RootDomain
+ zones := cfg.ManagedDNSZones()
+ if len(zones) > 0 {
+ dnsZone = zones[0]
+ }
+ return domains, ca, email, primary, dnsZone, provider, cred, nil
+}
+
+func (s *Service) prepareApplyByZone(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]string, string, string, string, string, string, ddns.Credentials, error) {
+ domains, err := NormalizeCertDomains(domains)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ dnsZone = strings.ToLower(strings.TrimSpace(dnsZone))
+ ddnsCfg, err := s.ddnsSvc.ConfigForDNSZone(ctx, dnsZone)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ if err := DomainsUnderZones(domains, ddnsCfg.ManagedDNSZones()); err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ primary := PrimaryCertDomain(domains)
+ if primary == "" {
+ return nil, "", "", "", "", "", ddns.Credentials{}, fmt.Errorf("请填写至少一个域名")
+ }
+ email, err = s.resolveACMEEmail(ctx, email)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ ca, err = s.resolveCA(ctx, ca)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ if err := ValidateCADomains(ca, domains); err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
+ if err != nil {
+ return nil, "", "", "", "", "", ddns.Credentials{}, err
+ }
+ return domains, ca, email, primary, dnsZone, provider, cred, nil
+}
+
+func (s *Service) runApplyJob(job *Job, dnsZone string, domains []string, ca, email, primary, provider string, cred ddns.Credentials) {
+ ctx := context.Background()
+ job.Info("开始申请证书…")
+ job.Info(fmt.Sprintf("主域名: %s", primary))
+ job.Info(fmt.Sprintf("覆盖域名: %s", strings.Join(domains, ", ")))
+ job.Info(fmt.Sprintf("DNS 凭证: %s (%s)", dnsZone, provider))
+
+ rec, err := s.obtain(ctx, job, ca, email, provider, cred, primary, domains)
+ if err != nil {
+ s.markCertError(ctx, primary, domains, ca, err)
+ job.Error(err.Error())
+ job.Finish(JobDonePayload{OK: false, Error: err.Error(), Domain: primary})
+ return
+ }
+ if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
+ job.Warn("保存 ACME 颁发机构设置失败: " + err.Error())
+ }
+ if err := s.proxySvc.ReloadAll(ctx); err != nil {
+ job.Warn("Nginx 重载失败: " + err.Error())
+ } else {
+ job.Info("Nginx 已重载")
+ }
+ job.Info("证书申请完成")
+ job.Finish(jobDoneFromRecord(rec, nil))
+}
+
+func (s *Service) Apply(ctx context.Context, dnsZone string, domains []string, ca, email string) ([]certstore.Record, error) {
+ domains, ca, email, primary, _, provider, cred, err := s.prepareApplyByZone(ctx, dnsZone, domains, ca, email)
+ if err != nil {
+ return nil, err
+ }
+ rec, err := s.obtain(ctx, nil, ca, email, provider, cred, primary, domains)
+ if err != nil {
+ s.markCertError(ctx, primary, domains, ca, err)
+ return nil, err
+ }
+ if err := s.settings.Set(ctx, settings.KeyACMECA, ca); err != nil {
+ s.logger.Warn("save acme ca setting failed", "error", err.Error())
+ }
+ if err := s.proxySvc.ReloadAll(ctx); err != nil {
+ s.logger.Error("nginx reload after cert apply failed", "error", err.Error())
+ }
+ _ = rec
+ return s.store.List(ctx)
+}
+
+func (s *Service) Import(ctx context.Context, certPEM, keyPEM, certFile, keyFile string) (certstore.Record, error) {
+ certBytes, err := certstore.ResolvePEMSource(certPEM, certFile)
+ if err != nil {
+ return certstore.Record{}, fmt.Errorf("证书:%w", err)
+ }
+ keyBytes, err := certstore.ResolvePEMSource(keyPEM, keyFile)
+ if err != nil {
+ return certstore.Record{}, fmt.Errorf("私钥:%w", err)
+ }
+
+ info, err := certstore.InspectImport(certBytes, keyBytes)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+
+ certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), info.Domains, certBytes, keyBytes)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+
+ status := "ok"
+ if time.Now().After(info.ExpiresAt) {
+ status = "expired"
+ }
+
+ domainsJSON := EncodeCertDomains(info.Domains)
+ record, err := s.store.Upsert(ctx, info.Primary, info.Wildcard, domainsJSON, "imported", certPath, keyPath, info.ExpiresAt, status, "")
+ if err != nil {
+ return certstore.Record{}, err
+ }
+
+ if err := s.proxySvc.ReloadAll(ctx); err != nil {
+ s.logger.Warn("nginx reload after cert import failed", "error", err.Error())
+ }
+ s.logger.Info("certificate imported", "domain", info.Primary, "domains", info.Domains, "expires_at", info.ExpiresAt.Format(time.RFC3339))
+ return record, nil
+}
+
+func (s *Service) Delete(ctx context.Context, domain string) error {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ return fmt.Errorf("域名不能为空")
+ }
+ existing, err := s.store.GetByDomain(ctx, domain)
+ if err != nil {
+ return err
+ }
+ if err := removeCertFiles(s.cfg.CertsDir(), existing); err != nil {
+ s.logger.Warn("remove cert files failed", "domain", domain, "error", err.Error())
+ }
+ if err := s.store.Delete(ctx, domain); err != nil {
+ return err
+ }
+ if err := s.proxySvc.ReloadAll(ctx); err != nil {
+ s.logger.Warn("nginx reload after cert delete failed", "error", err.Error())
+ }
+ s.logger.Info("certificate deleted", "domain", domain)
+ return nil
+}
+
+func (s *Service) Renew(ctx context.Context, domain string, ca string) (certstore.Record, error) {
+ rootDomain := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+ if rootDomain == "" {
+ return certstore.Record{}, fmt.Errorf("请选择要续签的证书域名")
+ }
+ existing, err := s.store.GetByDomain(ctx, rootDomain)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ if existing.ACMECA == "imported" {
+ return certstore.Record{}, fmt.Errorf("手动导入的证书请重新导入,无法自动续签")
+ }
+ if ca == "" {
+ ca = existing.ACMECA
+ }
+ ca, err = s.resolveCA(ctx, ca)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ email, err := s.resolveACMEEmail(ctx, "")
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ domains := existing.Domains
+ if len(domains) == 0 {
+ domains = LegacyCertDomains(existing.Domain, existing.Wildcard)
+ }
+ dnsZone, err := s.resolveDNSZone(ctx, domains)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ provider, cred, err := s.dnsCredentialsForDomain(ctx, dnsZone)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ if _, err := s.obtain(ctx, nil, ca, email, provider, cred, existing.Domain, domains); err != nil {
+ s.markCertError(ctx, rootDomain, domains, ca, err)
+ return certstore.Record{}, err
+ }
+ if err := s.proxySvc.ReloadAll(ctx); err != nil {
+ s.logger.Error("nginx reload after cert renew failed", "error", err.Error())
+ }
+ rec, err := s.store.GetByDomain(ctx, rootDomain)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ if s.notify != nil {
+ expires := "未知"
+ if rec.ExpiresAt != nil {
+ expires = rec.ExpiresAt.Local().Format("2006-01-02")
+ }
+ s.notify.Alert(ctx, notify.EventCertRenewSuccess, "证书续签成功",
+ fmt.Sprintf("%s 已续签,到期 %s", rootDomain, expires))
+ }
+ return rec, nil
+}
+
+func (s *Service) Tick(ctx context.Context) {
+ records, err := s.store.List(ctx)
+ if err != nil {
+ return
+ }
+ threshold, err := s.settings.GetInt(ctx, settings.KeyCertRenewThreshold)
+ if err != nil || threshold <= 0 {
+ threshold = 30
+ }
+ for _, rec := range records {
+ if rec.ExpiresAt == nil {
+ continue
+ }
+ if rec.ACMECA != "imported" && rec.DaysLeft <= threshold {
+ s.maybeAlertCertExpiry(ctx, rec.Domain, rec.DaysLeft)
+ }
+ if rec.DaysLeft <= threshold {
+ if rec.ACMECA == "imported" {
+ continue
+ }
+ if _, err := s.Renew(ctx, rec.Domain, rec.ACMECA); err != nil {
+ s.logger.Error("certificate renew failed", "domain", rec.Domain, "error", err.Error())
+ if s.notify != nil {
+ s.notify.Alert(ctx, notify.EventCertRenewFailure, "证书续签失败",
+ fmt.Sprintf("%s: %s", rec.Domain, err.Error()))
+ }
+ }
+ }
+ }
+}
+
+func (s *Service) maybeAlertCertExpiry(ctx context.Context, domain string, daysLeft int) {
+ if s.notify == nil {
+ return
+ }
+ today := time.Now().Format("2006-01-02")
+ s.expiryAlertMu.Lock()
+ if s.expiryAlerted[domain] == today {
+ s.expiryAlertMu.Unlock()
+ return
+ }
+ s.expiryAlerted[domain] = today
+ s.expiryAlertMu.Unlock()
+
+ msg := fmt.Sprintf("%s 剩余 %d 天到期", domain, daysLeft)
+ if daysLeft <= 0 {
+ msg = fmt.Sprintf("%s 已到期或即将失效", domain)
+ }
+ s.notify.Alert(ctx, notify.EventCertExpiry, "证书即将到期", msg)
+}
+
+func (s *Service) markCertError(ctx context.Context, primary string, domains []string, ca string, err error) {
+ if err == nil {
+ return
+ }
+ errMsg := err.Error()
+ s.logger.Error("certificate operation failed", "domain", primary, "error", errMsg)
+ _, upsertErr := s.store.Upsert(ctx, primary, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, "", "", time.Time{}, "error", errMsg)
+ if upsertErr != nil {
+ s.logger.Error("save cert error status failed", "domain", primary, "error", upsertErr.Error())
+ }
+}
+
+func (s *Service) resolveCA(ctx context.Context, ca string) (string, error) {
+ if strings.TrimSpace(ca) == "" {
+ ca, err := s.settings.Get(ctx, settings.KeyACMECA)
+ if err != nil || strings.TrimSpace(ca) == "" {
+ ca = CALetsEncrypt
+ }
+ }
+ ca = NormalizeCA(ca)
+ if err := ValidateCA(ca); err != nil {
+ return "", err
+ }
+ return ca, nil
+}
+
+func (s *Service) registerACMEAccount(ctx context.Context, client *lego.Client, ca string, logStep func(string)) (*registration.Resource, error) {
+ reg, err := client.Registration.ResolveAccountByKey()
+ if err == nil {
+ return reg, nil
+ }
+ logStep("注册 ACME 账户…")
+ switch NormalizeCA(ca) {
+ case CAZeroSSL:
+ apiKey, err := s.settings.Get(ctx, settings.KeyZeroSSLAPIKey)
+ if err != nil {
+ return nil, err
+ }
+ kid, hmac, err := zerosslEABCredentials(ctx, apiKey)
+ if err != nil {
+ return nil, err
+ }
+ return client.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
+ TermsOfServiceAgreed: true,
+ Kid: kid,
+ HmacEncoded: hmac,
+ })
+ default:
+ return client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
+ }
+}
+
+func (s *Service) obtain(ctx context.Context, job *Job, ca, email, provider string, cred ddns.Credentials, rootDomain string, domains []string) (certstore.Record, error) {
+ logStep := func(msg string) {
+ if job != nil {
+ job.Info(msg)
+ }
+ }
+ caDir, err := DirectoryURL(ca)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ logStep(fmt.Sprintf("连接 ACME 服务器 (%s)", CALabel(ca)))
+ user, err := newUser(email)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ config := lego.NewConfig(user)
+ config.CADirURL = caDir
+ config.Certificate.KeyType = certcrypto.EC256
+
+ client, err := lego.NewClient(config)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+
+ dnsProvider, err := newDNS01Provider(provider, cred)
+ if err != nil {
+ return certstore.Record{}, fmt.Errorf("初始化 DNS Provider 失败:%w", err)
+ }
+ dnsProvider = wrapDNSProvider(dnsProvider, job)
+ if err := client.Challenge.SetDNS01Provider(dnsProvider); err != nil {
+ return certstore.Record{}, err
+ }
+
+ logStep("检查 ACME 账户…")
+ reg, err := s.registerACMEAccount(ctx, client, ca, logStep)
+ if err != nil {
+ return certstore.Record{}, fmt.Errorf("ACME 注册失败:%w", err)
+ }
+ user.registration = reg
+
+ logStep("开始 DNS-01 验证并申请证书(可能需要 1~3 分钟)…")
+ request := legocert.ObtainRequest{
+ Domains: domains,
+ Bundle: true,
+ }
+ cert, err := client.Certificate.Obtain(request)
+ if err != nil {
+ return certstore.Record{}, fmt.Errorf("证书申请失败:%w", err)
+ }
+
+ certPath, keyPath, err := writeCertFiles(s.cfg.CertsDir(), domains, cert.Certificate, cert.PrivateKey)
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ logStep(fmt.Sprintf("证书已保存: %s", certPath))
+ logStep(fmt.Sprintf("私钥已保存: %s", keyPath))
+
+ expiresAt, err := parseCertExpiry(cert.Certificate)
+ if err != nil {
+ expiresAt = time.Now().Add(90 * 24 * time.Hour)
+ }
+ logStep(fmt.Sprintf("到期时间: %s", expiresAt.Local().Format("2006-01-02 15:04:ss")))
+ rec, err := s.store.Upsert(ctx, rootDomain, HasWildcardDomain(domains), EncodeCertDomains(domains), ca, certPath, keyPath, expiresAt, "ok", "")
+ if err != nil {
+ return certstore.Record{}, err
+ }
+ s.logger.Info("certificate obtained", "domain", rootDomain, "ca", ca, "expires_at", expiresAt.Format(time.RFC3339))
+ return rec, nil
+}
+
+func (s *Service) resolveACMEEmail(ctx context.Context, email string) (string, error) {
+ email = strings.TrimSpace(email)
+ if email == "" {
+ stored, err := s.settings.Get(ctx, settings.KeyACMEEmail)
+ if err != nil || strings.TrimSpace(stored) == "" {
+ return "", fmt.Errorf("请先填写 ACME 邮箱,用于注册 ACME 账户(首次申请必填)")
+ }
+ email = strings.TrimSpace(stored)
+ }
+ if err := validateACMEEmail(email); err != nil {
+ return "", err
+ }
+ if err := s.settings.Set(ctx, settings.KeyACMEEmail, email); err != nil {
+ s.logger.Warn("save acme email failed", "error", err.Error())
+ }
+ return email, nil
+}
+
+func validateACMEEmail(email string) error {
+ addr, err := mail.ParseAddress(email)
+ if err != nil || addr.Address != email {
+ return fmt.Errorf("ACME 邮箱格式不正确,请填写类似 admin@example.com 的地址")
+ }
+ return nil
+}
+
+func (s *Service) resolveDNSZone(ctx context.Context, domains []string) (string, error) {
+ for _, domain := range domains {
+ cfg, err := s.ddnsSvc.ConfigForAnyDomain(ctx, domain)
+ if err == nil {
+ return cfg.RootDomain, nil
+ }
+ }
+ return "", fmt.Errorf("请先在 DDNS 页面配置对应域名的 DNS 凭证")
+}
+
+func (s *Service) dnsCredentialsForDomain(ctx context.Context, rootDomain string) (string, ddns.Credentials, error) {
+ provider, cred, err := s.ddnsSvc.CredentialsForDomain(ctx, rootDomain)
+ if err != nil {
+ return "", ddns.Credentials{}, fmt.Errorf("请先在 DDNS 页面为 %s 配置 DNS 凭证", rootDomain)
+ }
+ if err := validateDNSCredentials(provider, cred); err != nil {
+ return "", ddns.Credentials{}, err
+ }
+ return provider, cred, nil
+}
+
+func writeCertFiles(certsDir string, domains []string, certPEM, keyPEM []byte) (string, string, error) {
+ names := certStorageNames(domains)
+ if len(names) == 0 {
+ return "", "", fmt.Errorf("证书域名不能为空")
+ }
+ var certPath, keyPath string
+ for _, name := range names {
+ dir := filepath.Join(certsDir, name)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", "", err
+ }
+ cp := filepath.Join(dir, "fullchain.pem")
+ kp := filepath.Join(dir, "privatekey.pem")
+ if err := os.WriteFile(cp, certPEM, 0o600); err != nil {
+ return "", "", err
+ }
+ if err := os.WriteFile(kp, keyPEM, 0o600); err != nil {
+ return "", "", err
+ }
+ certPath, keyPath = cp, kp
+ }
+ return certPath, keyPath, nil
+}
+
+func removeCertFiles(certsDir string, record certstore.Record) error {
+ domains := record.Domains
+ if len(domains) == 0 {
+ domains = LegacyCertDomains(record.Domain, record.Wildcard)
+ }
+ var firstErr error
+ for _, name := range certStorageNames(domains) {
+ dir := filepath.Join(certsDir, name)
+ if err := os.RemoveAll(dir); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
+
+func certStorageNames(domains []string) []string {
+ seen := map[string]bool{}
+ var names []string
+ for _, domain := range domains {
+ name := domain
+ if strings.HasPrefix(domain, "*.") {
+ name = "wildcard." + strings.TrimPrefix(domain, "*.")
+ }
+ if seen[name] {
+ continue
+ }
+ seen[name] = true
+ names = append(names, name)
+ }
+ return names
+}
+
+func parseCertExpiry(pemBytes []byte) (time.Time, error) {
+ block, _ := pem.Decode(pemBytes)
+ if block == nil {
+ return time.Time{}, fmt.Errorf("invalid pem")
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return cert.NotAfter, nil
+}
+
+type acmeUser struct {
+ email string
+ registration *registration.Resource
+ key crypto.PrivateKey
+}
+
+func (u *acmeUser) GetEmail() string { return u.email }
+func (u *acmeUser) GetRegistration() *registration.Resource { return u.registration }
+func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
+
+func newUser(email string) (*acmeUser, error) {
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ return &acmeUser{email: email, key: privateKey}, nil
+}
diff --git a/internal/acme/volcengine_dns.go b/internal/acme/volcengine_dns.go
new file mode 100644
index 0000000..20ca455
--- /dev/null
+++ b/internal/acme/volcengine_dns.go
@@ -0,0 +1,37 @@
+package acme
+
+import (
+ "context"
+
+ "github.com/go-acme/lego/v4/challenge"
+ "github.com/go-acme/lego/v4/challenge/dns01"
+
+ "github.com/fonu/fonu/internal/ddns"
+)
+
+type volcengineDNS struct {
+ provider *ddns.Volcengine
+ cred ddns.Credentials
+}
+
+func newVolcengineDNSProvider(cred ddns.Credentials) (challenge.Provider, error) {
+ if err := cred.Validate("volcengine"); err != nil {
+ return nil, err
+ }
+ return &volcengineDNS{
+ provider: ddns.NewVolcengine(),
+ cred: cred,
+ }, nil
+}
+
+func (p *volcengineDNS) Present(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+ fqdn := dns01.UnFqdn(info.EffectiveFQDN)
+ return p.provider.UpsertTXTRecord(context.Background(), p.cred, fqdn, info.Value)
+}
+
+func (p *volcengineDNS) CleanUp(domain, token, keyAuth string) error {
+ info := dns01.GetChallengeInfo(domain, keyAuth)
+ fqdn := dns01.UnFqdn(info.EffectiveFQDN)
+ return p.provider.DeleteTXTRecord(context.Background(), p.cred, fqdn, info.Value)
+}
diff --git a/internal/acme/zerossl.go b/internal/acme/zerossl.go
new file mode 100644
index 0000000..979f219
--- /dev/null
+++ b/internal/acme/zerossl.go
@@ -0,0 +1,62 @@
+package acme
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+type zeroSSLEABResponse struct {
+ Success int `json:"success"`
+ EABKid string `json:"eab_kid"`
+ EABHmacKey string `json:"eab_hmac_key"`
+ Error struct {
+ Code int `json:"code"`
+ Type string `json:"type"`
+ } `json:"error"`
+}
+
+func zerosslEABCredentials(ctx context.Context, apiKey string) (kid, hmac string, err error) {
+ apiKey = strings.TrimSpace(apiKey)
+ if apiKey == "" {
+ return "", "", fmt.Errorf("请在设置中配置 ZeroSSL API Key")
+ }
+
+ endpoint := "https://api.zerossl.com/acme/eab-credentials?access_key=" + url.QueryEscape(apiKey)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return "", "", err
+ }
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", "", fmt.Errorf("请求 ZeroSSL API 失败:%w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", "", err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("ZeroSSL API 返回 %d", resp.StatusCode)
+ }
+
+ var payload zeroSSLEABResponse
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return "", "", fmt.Errorf("解析 ZeroSSL 响应失败:%w", err)
+ }
+ if payload.Success != 1 || payload.EABKid == "" || payload.EABHmacKey == "" {
+ if payload.Error.Type != "" {
+ return "", "", fmt.Errorf("ZeroSSL API 错误:%s", payload.Error.Type)
+ }
+ return "", "", fmt.Errorf("ZeroSSL API 未返回有效的 EAB 凭据")
+ }
+ return payload.EABKid, payload.EABHmacKey, nil
+}
diff --git a/internal/agent/server.go b/internal/agent/server.go
new file mode 100644
index 0000000..a666dec
--- /dev/null
+++ b/internal/agent/server.go
@@ -0,0 +1,1450 @@
+package agent
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
+// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
+// 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。
+// 0.9.0:Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
+// 0.9.1:status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
+const AgentVersion = "0.9.1"
+
+// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
+type Config struct {
+ ListenAddr string
+ Token string
+ NginxConfDir string
+ NginxBinary string
+ CertsDir string
+ FrpsConfigPath string
+ FrpsBinary string
+ DataDir string
+}
+
+// Server 暴露 agent HTTP 服务。
+type Server struct {
+ cfg Config
+ mu sync.Mutex
+ // nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
+ nginxStatusMu sync.Mutex
+ nginxStatusAt time.Time
+ nginxStatus nginxProbe
+}
+
+// nginxProbe Nginx 可用性探测结果。
+type nginxProbe struct {
+ OK bool `json:"ok"` // nginx -t 通过
+ Missing bool `json:"missing"` // 未找到可执行文件
+ Running bool `json:"running"` // 有 nginx 进程在运行
+ Path string `json:"path,omitempty"`
+ Version string `json:"version,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// resolveNginxConfDir 预先解析并缓存 vhost 目录:探测结果落盘到 DataDir/nginx_conf_dir,
+// 启动时后台执行一次(不阻断监听),路由写入时优先读缓存。
+// 返回目录与错误;缓存命中时直接返回。
+func (s *Server) resolveNginxConfDir() (string, error) {
+ s.mu.Lock()
+ cached := strings.TrimSpace(s.cfg.NginxConfDir)
+ s.mu.Unlock()
+ if cached != "" {
+ return cached, nil
+ }
+ dir, err := detectNginxConfDir(s.nginxPath())
+ if err != nil {
+ return "", err
+ }
+ s.mu.Lock()
+ s.cfg.NginxConfDir = dir
+ s.mu.Unlock()
+ _ = os.MkdirAll(dir, 0o755)
+ // 结果落盘,下次启动优先复用(避免每次启动都跑 -T)
+ if s.cfg.DataDir != "" {
+ _ = os.WriteFile(filepath.Join(s.cfg.DataDir, "nginx_conf_dir"), []byte(dir), 0o644)
+ }
+ return dir, nil
+}
+
+// preloadNginxConfDir 启动后台预解析:成功与否不影响监听,结果进缓存供状态与路由写入使用。
+func (s *Server) preloadNginxConfDir() {
+ go func() {
+ if _, err := s.resolveNginxConfDir(); err != nil {
+ log.Printf("nginx conf dir 预解析失败(路由写入时会重试): %v", err)
+ }
+ }()
+}
+
+var domainPattern = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$`)
+
+func NewServer(cfg Config) (*Server, error) {
+ if strings.TrimSpace(cfg.ListenAddr) == "" {
+ cfg.ListenAddr = ":7700"
+ }
+ if strings.TrimSpace(cfg.NginxConfDir) == "" {
+ // 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
+ cfg.NginxConfDir = ""
+ }
+ if strings.TrimSpace(cfg.NginxBinary) == "" {
+ cfg.NginxBinary = "nginx"
+ }
+ if strings.TrimSpace(cfg.CertsDir) == "" {
+ cfg.CertsDir = "/var/lib/fonu-agent/certs"
+ }
+ if strings.TrimSpace(cfg.FrpsConfigPath) == "" {
+ cfg.FrpsConfigPath = "/etc/frp/frps.toml"
+ }
+ if strings.TrimSpace(cfg.FrpsBinary) == "" {
+ cfg.FrpsBinary = "/usr/local/bin/frps"
+ } else if !filepath.IsAbs(cfg.FrpsBinary) {
+ // 安装器固定把 frps 装到 /usr/local/bin;状态展示与实际安装位置保持一致
+ cfg.FrpsBinary = "/usr/local/bin/" + filepath.Base(cfg.FrpsBinary)
+ }
+ if strings.TrimSpace(cfg.DataDir) == "" {
+ cfg.DataDir = "/var/lib/fonu-agent"
+ }
+ if strings.TrimSpace(cfg.Token) == "" {
+ return nil, errors.New("agent token is required")
+ }
+ for _, dir := range []string{cfg.NginxConfDir, cfg.CertsDir, cfg.DataDir} {
+ // NginxConfDir 可能为空(探测延迟到路由写入时重试),跳过而不是报错
+ if strings.TrimSpace(dir) == "" {
+ continue
+ }
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return nil, fmt.Errorf("create agent directory: %w", err)
+ }
+ }
+ server := &Server{cfg: cfg}
+ // 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
+ // 历史错误目录的缓存会被自动修正。
+ server.preloadNginxConfDir()
+ return server, nil
+}
+
+func (s *Server) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/ping", s.auth(s.ping))
+ mux.HandleFunc("GET /api/v1/status", s.auth(s.status))
+ mux.HandleFunc("GET /api/v1/routes", s.auth(s.routes))
+ mux.HandleFunc("PUT /api/v1/routes", s.auth(s.putRoute))
+ mux.HandleFunc("GET /api/v1/routes/{domain}/conf", s.auth(s.routeConf))
+ mux.HandleFunc("PUT /api/v1/china-cidr", s.auth(s.putChinaCIDR))
+ mux.HandleFunc("POST /api/v1/nginx/install", s.auth(s.installNginx))
+ mux.HandleFunc("DELETE /api/v1/routes/{domain}", s.auth(s.deleteRoute))
+ mux.HandleFunc("PUT /api/v1/frps/config", s.auth(s.putFRPSConfig))
+ mux.HandleFunc("POST /api/v1/frps/install", s.auth(s.installFRPS))
+ mux.HandleFunc("POST /api/v1/frps/action", s.auth(s.frpsAction))
+ mux.HandleFunc("POST /api/v1/certs", s.auth(s.putCertificate))
+ return mux
+}
+
+func (s *Server) ListenAndServe(ctx context.Context) error {
+ server := &http.Server{Addr: s.cfg.ListenAddr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = server.Shutdown(shutdownCtx)
+ }()
+ return server.ListenAndServe()
+}
+
+func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer "+s.cfg.Token {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+ next(w, r)
+ }
+}
+
+func (s *Server) ping(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "fonu-agent"})
+}
+
+func (s *Server) status(w http.ResponseWriter, _ *http.Request) {
+ ports := map[string]bool{"80": portListening(80), "443": portListening(443), "7000": portListening(7000), "8080": portListening(8080)}
+ nginxProbe := s.probeNginx()
+ frpsActive := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
+ writeJSON(w, http.StatusOK, map[string]any{
+ "service": "fonu-agent",
+ "agent_version": AgentVersion,
+ "nginx_binary": s.cfg.NginxBinary,
+ "nginx_conf_dir": s.cfg.NginxConfDir,
+ "nginx_ok": nginxProbe.OK,
+ "nginx_missing": nginxProbe.Missing,
+ "nginx_running": nginxProbe.Running,
+ "nginx_path": nginxProbe.Path,
+ "nginx_version": nginxProbe.Version,
+ "nginx_error": nginxProbe.Error,
+ "frps_binary": s.cfg.FrpsBinary,
+ "frps_config_path": s.cfg.FrpsConfigPath,
+ "ports": ports,
+ "frps_active": frpsActive,
+ "frps_vhost_hint": "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通",
+ "frps_unit_exec": strings.TrimSpace(execCommandOutput("systemctl", "show", "frps", "-p", "ExecStart", "--value")),
+ "frps_enabled": exec.Command("systemctl", "is-enabled", "--quiet", "frps").Run() == nil,
+ "frps_version": strings.TrimSpace(frpsVersion(s.cfg.FrpsBinary)),
+ "frps_log_tail": strings.TrimSpace(journalTail("frps", 6)),
+ "dashboard_addr": strings.TrimSpace(dashboardAddr(s.cfg.FrpsConfigPath)),
+ "routes": s.routeNames(),
+ "checked_at": time.Now().UTC().Format(time.RFC3339),
+ })
+}
+
+type frpsInstallRequest struct {
+ Version string `json:"version"`
+ Proxy string `json:"proxy,omitempty"`
+}
+
+// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
+func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
+ var req frpsInstallRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+ return
+ }
+ version := strings.TrimPrefix(strings.TrimSpace(req.Version), "v")
+ if matched, _ := regexp.MatchString(`^\d+\.\d+\.\d+$`, version); !matched {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "version 需形如 0.71.0"})
+ return
+ }
+ arch := map[string]string{"amd64": "amd64", "arm64": "arm64"}[runtime.GOARCH]
+ if arch == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
+ return
+ }
+ downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch)
+ if proxy := strings.TrimSpace(req.Proxy); proxy != "" {
+ downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL
+ }
+ workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
+ _ = os.RemoveAll(workDir)
+ defer os.RemoveAll(workDir)
+ if err := os.MkdirAll(workDir, 0o750); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ tarball := filepath.Join(workDir, "frp.tar.gz")
+ if err := downloadToFile(downloadURL, tarball); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "下载 frps 失败: " + err.Error()})
+ return
+ }
+ installPath := s.cfg.FrpsBinary
+ if !filepath.IsAbs(installPath) {
+ installPath = "/usr/local/bin/frps"
+ }
+ if err := extractFile(tarball, "frp_"+version+"_linux_"+arch+"/frps", installPath, 0o755); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
+ return
+ }
+ notes := []string{"frps 二进制已安装: " + installPath}
+ unitPath := "/etc/systemd/system/frps.service"
+ writeUnit := false
+ if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
+ writeUnit = true
+ notes = append(notes, "已创建 systemd 单位 frps.service")
+ } else if existing, err := os.ReadFile(unitPath); err == nil {
+ // 旧 unit(如 frps-admin 面板遗留)的 ExecStart 可能指向已不存在的二进制;检测到失效路径时覆写接管
+ for _, line := range strings.Split(string(existing), "\n") {
+ if execPath, found := strings.CutPrefix(strings.TrimSpace(line), "ExecStart="); found {
+ ref := strings.Fields(execPath)
+ if len(ref) > 0 {
+ if _, statErr := os.Stat(ref[0]); statErr != nil {
+ writeUnit = true
+ notes = append(notes, fmt.Sprintf("检测到现有 frps.service 的 ExecStart 指向不存在的 %s,已覆写接管", ref[0]))
+ }
+ }
+ break
+ }
+ }
+ }
+ if writeUnit {
+ unit := fmt.Sprintf("[Unit]\nDescription=frps service (managed by fonu-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n", installPath, s.cfg.FrpsConfigPath)
+ if err := atomicWrite(unitPath, []byte(unit), 0o644); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "写入 frps.service 失败: " + err.Error()})
+ return
+ }
+ _ = exec.Command("systemctl", "daemon-reload").Run()
+ }
+ // nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
+ if exec.Command("systemctl", "is-active", "--quiet", "frps").Run() != nil {
+ if exec.Command("pgrep", "-x", "frps").Run() == nil {
+ _ = exec.Command("pkill", "-x", "frps").Run()
+ time.Sleep(time.Second)
+ notes = append(notes, "已停止 nohup 方式运行的旧 frps 进程")
+ }
+ _ = exec.Command("systemctl", "enable", "--now", "frps").Run()
+ notes = append(notes, "frps.service 已启动")
+ } else {
+ _ = exec.Command("systemctl", "restart", "frps").Run()
+ notes = append(notes, "frps.service 已重启以加载新版本")
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version, "binary": installPath, "notes": notes})
+}
+
+type frpsActionRequest struct {
+ Action string `json:"action"` // start | stop | restart
+}
+
+// frpsAction 对 VPS 本机 frps 服务执行启停(仅白名单动作,经 systemctl)。
+func (s *Server) frpsAction(w http.ResponseWriter, r *http.Request) {
+ var req frpsActionRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+ return
+ }
+ var command *exec.Cmd
+ switch req.Action {
+ case "start":
+ command = exec.Command("systemctl", "start", "frps")
+ case "stop":
+ command = exec.Command("systemctl", "stop", "frps")
+ case "restart":
+ command = exec.Command("systemctl", "restart", "frps")
+ default:
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "action 需为 start/stop/restart"})
+ return
+ }
+ if output, err := command.CombinedOutput(); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": fmt.Sprintf("systemctl %s frps 失败: %s", req.Action, strings.TrimSpace(string(output)))})
+ return
+ }
+ time.Sleep(800 * time.Millisecond) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
+ active := exec.Command("systemctl", "is-active", "--quiet", "frps").Run() == nil
+ result := map[string]any{"ok": true, "action": req.Action, "frps_active": active}
+ if !active {
+ // 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
+ journal, _ := exec.Command("journalctl", "-u", "frps", "-n", "8", "--no-pager").CombinedOutput()
+ result["ok"] = false
+ result["error"] = fmt.Sprintf("systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s", req.Action, strings.TrimSpace(string(journal)))
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+func downloadToFile(url, path string) error {
+ client := &http.Client{Timeout: 10 * time.Minute}
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ out, err := os.Create(path)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, io.LimitReader(resp.Body, 256<<20))
+ return err
+}
+
+// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
+func extractFile(tarball, name, dest string, mode os.FileMode) error {
+ file, err := os.Open(tarball)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ gz, err := gzip.NewReader(file)
+ if err != nil {
+ return err
+ }
+ defer gz.Close()
+ reader := tar.NewReader(gz)
+ for {
+ header, err := reader.Next()
+ if errors.Is(err, io.EOF) {
+ return fmt.Errorf("压缩包内未找到 %s", name)
+ }
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(filepath.Clean(header.Name)) != name {
+ continue
+ }
+ data, err := io.ReadAll(io.LimitReader(reader, 256<<20))
+ if err != nil {
+ return err
+ }
+ return atomicWrite(dest, data, mode)
+ }
+}
+
+func execCommandOutput(name string, args ...string) string {
+ output, _ := exec.Command(name, args...).CombinedOutput()
+ return string(output)
+}
+
+// dashboardAddr 已废弃:面板地址改由 Fonu 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
+func dashboardAddr(configPath string) string {
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ return ""
+ }
+ var addr, port string
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if value, found := strings.CutPrefix(line, "webServer.addr"); found {
+ addr = strings.Trim(strings.TrimSpace(strings.TrimPrefix(value, "=")), "\"")
+ } else if value, found := strings.CutPrefix(line, "webServer.port"); found {
+ port = strings.TrimSpace(strings.TrimPrefix(value, "="))
+ }
+ }
+ if addr == "" || port == "" || port == "0" {
+ return ""
+ }
+ if addr == "0.0.0.0" || addr == "::" {
+ addr = "127.0.0.1"
+ }
+ return fmt.Sprintf("http://%s:%s", addr, port)
+}
+
+func frpsVersion(binary string) string {
+ output, err := exec.Command(binary, "--version").CombinedOutput()
+ if err != nil {
+ return ""
+ }
+ return string(output)
+}
+
+func journalTail(unit string, lines int) string {
+ output, err := exec.Command("journalctl", "-u", unit, "-n", strconv.Itoa(lines), "--no-pager").CombinedOutput()
+ if err != nil {
+ return ""
+ }
+ return string(output)
+}
+
+// nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
+// 否则从运行中的 nginx 主进程反查(/proc//exe),零硬编码安装位置。
+func nginxBinaryPath(configured string) string {
+ if p := strings.TrimSpace(configured); p != "" {
+ if filepath.IsAbs(p) {
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ } else if _, err := exec.LookPath(p); err == nil {
+ return p
+ }
+ }
+ // 从运行中的 nginx master 进程反查二进制(不依赖安装位置)
+ if entries, err := os.ReadDir("/proc"); err == nil {
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ if data, err := os.ReadFile("/proc/" + entry.Name() + "/comm"); err != nil || strings.TrimSpace(string(data)) != "nginx" {
+ continue
+ }
+ if link, err := os.Readlink("/proc/" + entry.Name() + "/exe"); err == nil && strings.Contains(strings.ToLower(link), "nginx") {
+ return link
+ }
+ }
+ }
+ return ""
+}
+
+// detectNginxConfDir 自动发现 Nginx 实际加载完整 server 配置的目录(零硬编码路径):
+// 1) nginx -T dump 完整生效配置,收集所有被加载的 .conf 所在目录(Nginx 亲口说的事实);
+// 2) 按内容启发排序:目录下已有含 server_name 的 .conf(HTTP vhost 语义)优先,片段目录(location-only)靠后;
+// 3) 逐个试探:写入 → nginx -t → 通过即选定并缓存;失败则清理该次写入并换下一个。
+// Nginx 自己是唯一裁判,agent 不猜测、不评分、不写死任何安装路径。
+func detectNginxConfDir(binary string) (string, error) {
+ nginx := nginxBinaryPath(binary)
+ if nginx == "" {
+ return "", fmt.Errorf("未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx(apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径")
+ }
+ out, err := exec.Command(nginx, "-T").CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("nginx -T 执行失败: %w", err)
+ }
+ loadedDirs := map[string]bool{}
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimSpace(line)
+ if value, found := strings.CutPrefix(line, "# configuration file "); found {
+ p := strings.TrimSuffix(strings.TrimSpace(value), ":")
+ if strings.HasSuffix(p, ".conf") {
+ loadedDirs[filepath.Dir(p)] = true
+ }
+ }
+ }
+ delete(loadedDirs, ".")
+ if len(loadedDirs) == 0 {
+ return "", fmt.Errorf("nginx -T 输出中未发现任何被加载的 .conf 目录")
+ }
+ // 内容启发:目录下已有含 server_name 的 .conf 视为 HTTP vhost 目录,优先尝试。
+ dirs := make([]string, 0, len(loadedDirs))
+ for dir := range loadedDirs {
+ dirs = append(dirs, dir)
+ }
+ sort.Slice(dirs, func(i, j int) bool {
+ si, sj := dirHasVhostConf(dirs[i]), dirHasVhostConf(dirs[j])
+ if si != sj {
+ return si // vhost 语义目录在前
+ }
+ return dirs[i] < dirs[j]
+ })
+ for _, dir := range dirs {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ continue
+ }
+ probe := filepath.Join(dir, "fonu-probe.conf")
+ probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n"
+ if err := atomicWrite(probe, []byte(probeBody), 0o644); err != nil {
+ continue
+ }
+ // 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。
+ // 必须再执行 -T 确认探针文件真的出现在生效配置清单里,才算“会被加载”的目录。
+ if err := runCommand(nginx, "-t"); err == nil {
+ if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil && strings.Contains(string(tOut), probe) {
+ _ = os.Remove(probe)
+ _ = runCommand(nginx, "-s", "reload") // 清除 probe 后恢复原状
+ return dir, nil
+ }
+ // -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个
+ _ = os.Remove(probe)
+ continue
+ }
+ // 该目录不接受完整 server 块(片段目录):删除 probe 文件。
+ // 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
+ _ = os.Remove(probe)
+ if tOut, tErr := exec.Command(nginx, "-t").CombinedOutput(); tErr != nil {
+ cleanupLegacyFonuConf(string(tOut))
+ if err := runCommand(nginx, "-t"); err == nil {
+ continue // 清理残留后配置恢复可用,换下一个目录
+ }
+ }
+ }
+ return "", fmt.Errorf("在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);可用 FONU_AGENT_NGINX_CONF_DIR 显式指定", len(dirs), strings.Join(dirs, ", "))
+}
+
+// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf(HTTP vhost 语义)。
+func dirHasVhostConf(dir string) bool {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return false
+ }
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
+ continue
+ }
+ if data, err := os.ReadFile(filepath.Join(dir, entry.Name())); err == nil && strings.Contains(string(data), "server_name") {
+ return true
+ }
+ }
+ return false
+}
+
+// cleanupLegacyFonuConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 fonu- 前缀)。
+func cleanupLegacyFonuConf(nginxTestOutput string) {
+ for _, line := range strings.Split(nginxTestOutput, "\n") {
+ idx := strings.Index(line, "fonu-")
+ if idx < 0 {
+ continue
+ }
+ start, end := -1, -1
+ for i := idx; i >= 0 && line[i] != ' '; i-- {
+ start = i
+ }
+ for i := idx; i < len(line) && line[i] != ' ' && line[i] != ':'; i++ {
+ end = i + 1
+ }
+ if start < 0 || end <= start {
+ continue
+ }
+ path := line[start:end]
+ if !strings.HasSuffix(path, ".conf") {
+ continue
+ }
+ if data, err := os.ReadFile(path); err == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
+ _ = os.Remove(path)
+ }
+ }
+}
+
+func portListening(port int) bool {
+ conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 300*time.Millisecond)
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+}
+
+func (s *Server) routes(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, s.routeDetails())
+}
+
+// routeDetails 返回每条路由的真实状态:文件存在/语法通过/被 Nginx 实际加载。
+func (s *Server) routeDetails() []map[string]any {
+ entries, err := os.ReadDir(s.cfg.NginxConfDir)
+ if err != nil {
+ return []map[string]any{}
+ }
+ details := []map[string]any{}
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
+ continue
+ }
+ domain := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf")
+ path := filepath.Join(s.cfg.NginxConfDir, entry.Name())
+ item := map[string]any{
+ "domain": domain,
+ "file_exists": true,
+ "syntax_ok": false,
+ "loaded": false,
+ }
+ // 语法与加载判定合并在一次 -T 中:-T 成功且输出含本文件 = 语法通过且被加载
+ if nginx := s.nginxPath(); nginx != "" {
+ if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
+ item["syntax_ok"] = true
+ item["loaded"] = strings.Contains(string(tOut), path)
+ }
+ }
+ details = append(details, item)
+ }
+ sort.Slice(details, func(i, j int) bool { return details[i]["domain"].(string) < details[j]["domain"].(string) })
+ return details
+}
+
+type routeRequest struct {
+ Domain string `json:"domain"`
+ Upstream string `json:"upstream"`
+ TLS bool `json:"tls"`
+ CertDir string `json:"cert_dir,omitempty"`
+ WebSocket bool `json:"websocket,omitempty"`
+ VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Fonu 传入本次部署的 frps vhost 端口
+ RedirectTLS bool `json:"redirect_https,omitempty"` // HTTP 80 强制跳转 HTTPS
+ AllowIPs []string `json:"allow_ips,omitempty"` // IP 白名单;空则不限制
+ BasicAuthUser string `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成)
+ // 第一梯队部署设置(仅作用于公网 Nginx vhost,不写入 frpc.toml)
+ DenyIPs []string `json:"deny_ips,omitempty"` // IP 黑名单;与白名单可同时使用
+ BasicAuthPassword string `json:"basic_auth_password,omitempty"` // Basic Auth 自定义密码;空则首次自动生成
+ HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS(证书已推送也不监听 443)
+ ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小限制,如 50m
+ ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
+ SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头(HSTS 等)
+ TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
+ // 第二梯队部署设置:限流 / 连接数 / 仅中国大陆 IP
+ RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
+ RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
+ ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
+ ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP(需先下发 IP 段)
+}
+
+func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
+ var req routeRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+ return
+ }
+ if err := validateRoute(req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
+ return
+ }
+ // 部署预检:未装 Nginx 时给出人话提示,而不是后续的 nginx config rejected
+ if probe := s.probeNginx(); probe.Missing {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx(或不在常见路径):请先安装(apt install nginx / yum install nginx),或在「公网反代」页点「一键安装 Nginx」后再部署"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.writeRoute(req); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
+ // 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
+ // 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
+ if vhost := req.VhostPort; vhost > 0 {
+ code, body := s.probeVhost(vhost, req.Domain)
+ result["probe_status"] = code
+ result["probe_ok"] = code >= 200 && code < 400
+ result["probe_notfound"] = code == 404
+ result["probe_body_snip"] = body
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+// probeVhost 从 VPS 本机模拟 Nginx 回源:返回 HTTP 状态码与响应片段(限长)。
+func (s *Server) probeVhost(port int, host string) (int, string) {
+ client := &http.Client{
+ Timeout: 5 * time.Second,
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/", port), nil)
+ if err != nil {
+ return 0, err.Error()
+ }
+ req.Host = host
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err.Error()
+ }
+ defer resp.Body.Close()
+ snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
+ return resp.StatusCode, strings.TrimSpace(string(snippet))
+}
+
+// routeConf 返回指定域名的当前反代配置内容(查看生成的 nginx 配置)。
+func (s *Server) routeConf(w http.ResponseWriter, r *http.Request) {
+ domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
+ if !validDomain(domain) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ data, err := os.ReadFile(s.routePath(domain))
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "该域名尚未部署反代"})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"domain": domain, "content": string(data)})
+}
+
+func (s *Server) deleteRoute(w http.ResponseWriter, r *http.Request) {
+ domain := strings.ToLower(strings.TrimSpace(r.PathValue("domain")))
+ if !validDomain(domain) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid domain"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ path := s.routePath(domain)
+ if err := s.clearLimitState(domain); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := s.reloadNginx(); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": domain})
+}
+
+type frpsConfigRequest struct {
+ Content string `json:"content"`
+}
+
+func (s *Server) putFRPSConfig(w http.ResponseWriter, r *http.Request) {
+ var req frpsConfigRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 512<<10)).Decode(&req); err != nil || strings.TrimSpace(req.Content) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "frps config content is required"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ backup := s.cfg.FrpsConfigPath + ".bak"
+ if old, err := os.ReadFile(s.cfg.FrpsConfigPath); err == nil {
+ if err := atomicWrite(backup, old, 0o600); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ }
+ if err := atomicWrite(s.cfg.FrpsConfigPath, []byte(req.Content), 0o600); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": s.cfg.FrpsConfigPath, "restart_required": true})
+}
+
+type certificateRequest struct {
+ Domain string `json:"domain"`
+ Fullchain string `json:"fullchain_pem"`
+ PrivateKey string `json:"key_pem"`
+}
+
+func (s *Server) putCertificate(w http.ResponseWriter, r *http.Request) {
+ var req certificateRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+ return
+ }
+ req.Domain = strings.ToLower(strings.TrimSpace(req.Domain))
+ if !validDomain(req.Domain) || strings.TrimSpace(req.Fullchain) == "" || strings.TrimSpace(req.PrivateKey) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "domain and PEM values are required"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ dir := filepath.Join(s.cfg.CertsDir, req.Domain)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := atomicWrite(filepath.Join(dir, "fullchain.pem"), []byte(req.Fullchain), 0o600); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := atomicWrite(filepath.Join(dir, "privkey.pem"), []byte(req.PrivateKey), 0o600); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "domain": req.Domain, "directory": dir})
+}
+
+// nginxInstallResult 一键安装 Nginx 的执行结果。
+type nginxInstallResult struct {
+ OK bool `json:"ok"`
+ Manager string `json:"manager,omitempty"`
+ Output string `json:"output,omitempty"`
+ Path string `json:"path,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// installNginx 一键安装 Nginx:识别 apt-get/dnf/yum/apk → 非交互安装 → 启动 → 回写探测结果。
+// 需要 agent 以 root 运行(systemd 单元默认如此)。
+func (s *Server) installNginx(w http.ResponseWriter, r *http.Request) {
+ if os.Geteuid() != 0 {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "安装 Nginx 需要 root 权限:请以 root 运行 fonu-agent"})
+ return
+ }
+ if probe := s.probeNginx(); probe.OK {
+ writeJSON(w, http.StatusOK, nginxInstallResult{OK: true, Path: probe.Path, Output: "Nginx 已可用,无需安装"})
+ return
+ }
+ manager, installArgs := detectPackageManager()
+ if manager == "" {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "未识别到支持的包管理器(apt-get/dnf/yum/apk),请在 VPS 上手动安装 Nginx"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
+ defer cancel()
+ if manager == "apt-get" {
+ update := exec.CommandContext(ctx, "apt-get", "update", "-y")
+ update.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
+ _, _ = update.CombinedOutput() // 索引更新失败不阻断安装尝试
+ }
+ install := exec.CommandContext(ctx, manager, installArgs...)
+ install.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive")
+ out, err := install.CombinedOutput()
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, nginxInstallResult{Manager: manager, Output: tailText(string(out), 4000), Error: err.Error()})
+ return
+ }
+ // 启动并设置开机自启:systemd 优先,回退 service 命令(两者失败都忽略,最终以探测结果为准)
+ _ = exec.Command("systemctl", "enable", "--now", "nginx").Run()
+ _ = exec.Command("service", "nginx", "start").Run()
+ s.invalidateNginxProbe()
+ probe := s.probeNginx()
+ result := nginxInstallResult{OK: probe.OK, Manager: manager, Output: tailText(string(out), 4000), Path: probe.Path, Error: probe.Error}
+ if !probe.OK {
+ writeJSON(w, http.StatusBadGateway, result)
+ return
+ }
+ // 安装成功后解析并缓存 vhost 目录,省去后续首次部署的探测
+ if dir, err := detectNginxConfDir(s.nginxPath()); err == nil {
+ s.cfg.NginxConfDir = dir
+ _ = os.MkdirAll(dir, 0o755)
+ if s.cfg.DataDir != "" {
+ _ = os.WriteFile(filepath.Join(s.cfg.DataDir, "nginx_conf_dir"), []byte(dir), 0o644)
+ }
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+type chinaCIDRRequest struct {
+ Content string `json:"content"`
+}
+
+// putChinaCIDR 接收 Fonu 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
+func (s *Server) putChinaCIDR(w http.ResponseWriter, r *http.Request) {
+ var req chinaCIDRRequest
+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20)).Decode(&req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
+ return
+ }
+ content := strings.TrimSpace(req.Content)
+ if content == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "中国 IP 段内容为空"})
+ return
+ }
+ if probe := s.probeNginx(); probe.Missing {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "VPS 未安装 Nginx:请先安装 Nginx 再同步中国 IP 段"})
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if strings.TrimSpace(s.cfg.NginxConfDir) == "" {
+ dir, err := detectNginxConfDir(s.cfg.NginxBinary)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ s.cfg.NginxConfDir = dir
+ }
+ if err := atomicWrite(chinaListPath(), []byte(content+"\n"), 0o640); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ geo := "# Managed by fonu-agent. Do not edit.\ngeo $fonu_china_client {\n default 0;\n include " + chinaListPath() + ";\n}\n"
+ if err := atomicWrite(s.chinaGeoFilePath(), []byte(geo), 0o640); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ if err := s.validateNginx(); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": "nginx config rejected: " + err.Error()})
+ return
+ }
+ if err := s.reloadNginx(); err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": len(content)})
+}
+
+func (s *Server) writeRoute(req routeRequest) error {
+ // 目录为空时惰性探测(启动时探测失败不阻断,这里重试;systemd 下补 PATH)
+ if strings.TrimSpace(s.cfg.NginxConfDir) == "" {
+ dir, err := detectNginxConfDir(s.cfg.NginxBinary)
+ if err != nil {
+ return err
+ }
+ // putRoute 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
+ s.cfg.NginxConfDir = dir
+ }
+ certDir := req.CertDir
+ if certDir == "" {
+ certDir = filepath.Join(s.cfg.CertsDir, req.Domain)
+ }
+ // 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 fonuagent-china.conf 中定义)
+ if req.ChinaOnly {
+ if _, err := os.Stat(chinaListPath()); err != nil {
+ return fmt.Errorf("启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)")
+ }
+ }
+ content := nginxRoute(req, certDir)
+ path := s.routePath(req.Domain)
+ // 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对,
+ // 非当前目录但内容带 Agent 标记的 fonu-.conf 一律删除(不碰宝塔自己的配置)。
+ if nginx := s.nginxPath(); nginx != "" {
+ if tOut, tErr := exec.Command(nginx, "-T").CombinedOutput(); tErr == nil {
+ for _, line := range strings.Split(string(tOut), "\n") {
+ line = strings.TrimSpace(line)
+ if value, found := strings.CutPrefix(line, "# configuration file "); found {
+ p := strings.TrimSuffix(strings.TrimSpace(value), ":")
+ base := filepath.Base(p)
+ if p != path && base == "fonu-"+req.Domain+".conf" {
+ if data, readErr := os.ReadFile(p); readErr == nil && strings.HasPrefix(string(data), "# Managed by fonu-agent.") {
+ _ = os.Remove(p)
+ }
+ }
+ }
+ }
+ }
+ }
+ // 先落 zone 文件再写 vhost:nginx -t 需要 zone 已存在
+ if err := s.applyLimitState(req); err != nil {
+ return err
+ }
+ if err := atomicWrite(path, []byte(content), 0o640); err != nil {
+ return err
+ }
+ if err := s.validateNginx(); err != nil {
+ _ = os.Remove(path)
+ return fmt.Errorf("nginx config rejected: %w", err)
+ }
+ return s.reloadNginx()
+}
+
+func (s *Server) routeNames() []string {
+ entries, err := os.ReadDir(s.cfg.NginxConfDir)
+ if err != nil {
+ return []string{}
+ }
+ var result []string
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasPrefix(entry.Name(), "fonu-") || !strings.HasSuffix(entry.Name(), ".conf") {
+ continue
+ }
+ result = append(result, strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "fonu-"), ".conf"))
+ }
+ sort.Strings(result)
+ return result
+}
+
+func (s *Server) routePath(domain string) string {
+ return filepath.Join(s.cfg.NginxConfDir, "fonu-"+domain+".conf")
+}
+
+func (s *Server) validateNginx() error {
+ return runCommand(s.nginxPath(), "-t")
+}
+
+func (s *Server) reloadNginx() error {
+ return runCommand(s.nginxPath(), "-s", "reload")
+}
+
+// nginxPath 返回可用的 nginx 绝对路径:配置的路径可用则用之;否则探测常见安装位置
+// (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。
+func (s *Server) nginxPath() string {
+ if p := strings.TrimSpace(s.cfg.NginxBinary); p != "" {
+ if filepath.IsAbs(p) {
+ if _, err := os.Stat(p); err == nil {
+ return p
+ }
+ } else if _, err := exec.LookPath(p); err == nil {
+ return p
+ }
+ }
+ for _, candidate := range []string{"/www/server/nginx/sbin/nginx", "/usr/local/nginx/sbin/nginx", "/usr/sbin/nginx"} {
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate
+ }
+ }
+ return s.cfg.NginxBinary
+}
+
+// probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。
+func (s *Server) probeNginx() nginxProbe {
+ s.nginxStatusMu.Lock()
+ defer s.nginxStatusMu.Unlock()
+ if !s.nginxStatusAt.IsZero() && time.Since(s.nginxStatusAt) < 30*time.Second {
+ return s.nginxStatus
+ }
+ probe := nginxProbe{}
+ path := strings.TrimSpace(s.nginxPath())
+ if path == "" {
+ probe.Missing = true
+ probe.Error = "未找到 nginx 可执行文件(已尝试配置值、常见安装路径与运行中进程)"
+ } else {
+ probe.Path = path
+ probe.Version = nginxVersion(path)
+ probe.Running = nginxRunning()
+ if out, err := exec.Command(path, "-t").CombinedOutput(); err != nil {
+ probe.Error = strings.TrimSpace(string(out))
+ } else {
+ probe.OK = true
+ }
+ }
+ s.nginxStatus = probe
+ s.nginxStatusAt = time.Now()
+ return probe
+}
+
+// invalidateNginxProbe 清除探测缓存(安装/升级 Nginx 后强制重新探测)。
+func (s *Server) invalidateNginxProbe() {
+ s.nginxStatusMu.Lock()
+ s.nginxStatusAt = time.Time{}
+ s.nginxStatusMu.Unlock()
+}
+
+// nginxRunning 判断是否有 nginx 进程在运行(扫描 /proc,不依赖端口占用)。
+func nginxRunning() bool {
+ entries, err := os.ReadDir("/proc")
+ if err != nil {
+ return false
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ if _, err := strconv.Atoi(entry.Name()); err != nil {
+ continue
+ }
+ if data, err := os.ReadFile("/proc/" + entry.Name() + "/comm"); err == nil && strings.TrimSpace(string(data)) == "nginx" {
+ return true
+ }
+ }
+ return false
+}
+
+// nginxVersion 返回 nginx 版本字符串(如 nginx/1.24.0);-v 输出走 stderr。
+func nginxVersion(path string) string {
+ out, _ := exec.Command(path, "-v").CombinedOutput()
+ text := strings.TrimSpace(string(out))
+ if idx := strings.Index(text, "nginx version: "); idx >= 0 {
+ return strings.TrimSpace(text[idx+len("nginx version: "):])
+ }
+ return text
+}
+
+// detectPackageManager 识别可用的包管理器与非交互安装参数。
+func detectPackageManager() (string, []string) {
+ candidates := []struct {
+ bin string
+ args []string
+ }{
+ {"apt-get", []string{"install", "-y", "nginx"}},
+ {"dnf", []string{"install", "-y", "nginx"}},
+ {"yum", []string{"install", "-y", "nginx"}},
+ {"apk", []string{"add", "--no-cache", "nginx"}},
+ }
+ for _, candidate := range candidates {
+ if path, err := exec.LookPath(candidate.bin); err == nil && path != "" {
+ return candidate.bin, candidate.args
+ }
+ }
+ return "", nil
+}
+
+// tailText 截取文本尾部,避免把整个安装日志塞进响应。
+func tailText(value string, limit int) string {
+ value = strings.TrimSpace(value)
+ if len(value) <= limit {
+ return value
+ }
+ return "…(已截断)" + value[len(value)-limit:]
+}
+
+func runCommand(name string, args ...string) error {
+ output, err := exec.Command(name, args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(output)))
+ }
+ return nil
+}
+
+func validateRoute(req routeRequest) error {
+ if !validDomain(strings.ToLower(strings.TrimSpace(req.Domain))) {
+ return errors.New("invalid domain")
+ }
+ if strings.TrimSpace(req.Upstream) == "" || strings.ContainsAny(req.Upstream, " \t\r\n;'\"") {
+ return errors.New("invalid upstream")
+ }
+ if req.TLS && strings.TrimSpace(req.CertDir) != "" && !filepath.IsAbs(req.CertDir) {
+ return errors.New("certificate directory must be absolute")
+ }
+ return nil
+}
+
+func validDomain(value string) bool {
+ if len(value) == 0 || len(value) > 253 || strings.Contains(value, "..") {
+ return false
+ }
+ return domainPattern.MatchString(value)
+}
+
+func nginxRoute(req routeRequest, certDir string) string {
+ var b strings.Builder
+ b.WriteString("# Managed by fonu-agent. Do not edit.\n")
+ // HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为)
+ tlsEnabled := req.TLS && !req.HTTPSDisabled
+ if req.RedirectTLS && tlsEnabled {
+ // 80 端口只做 301 跳转,正常代理由 443 TLS server 承担
+ fmt.Fprintf(&b, "server {\n listen 80;\n server_name %s;\n return 301 https://$host$request_uri;\n}\n\n", req.Domain)
+ fmt.Fprintf(&b, "server {\n listen 443 ssl;\n server_name %s;\n", req.Domain)
+ b.WriteString(sslDirectives(certDir, req.TLS13Only))
+ } else {
+ fmt.Fprintf(&b, "server {\n listen 80;\n server_name %s;\n", req.Domain)
+ if tlsEnabled {
+ b.WriteString(" listen 443 ssl;\n")
+ b.WriteString(sslDirectives(certDir, req.TLS13Only))
+ }
+ }
+ // 上传大小与代理读超时:按规则覆盖 Nginx 默认值(大文件上传 / 长轮询 / 流媒体必需)
+ if size := strings.TrimSpace(req.ClientMaxBodySize); size != "" {
+ fmt.Fprintf(&b, " client_max_body_size %s;\n", size)
+ }
+ if timeout := strings.TrimSpace(req.ProxyReadTimeout); timeout != "" {
+ fmt.Fprintf(&b, " proxy_read_timeout %s;\n", timeout)
+ }
+ // 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
+ if req.SecurityHeaders {
+ b.WriteString(" add_header X-Content-Type-Options \"nosniff\" always;\n")
+ b.WriteString(" add_header X-Frame-Options \"SAMEORIGIN\" always;\n")
+ b.WriteString(" add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n")
+ if tlsEnabled {
+ b.WriteString(" add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n")
+ }
+ }
+ // 限流/连接数的 zone 定义在 fonuagent-limits.conf(http 上下文),此处只声明状态码
+ if req.RateLimitRate > 0 {
+ b.WriteString(" limit_req_status 429;\n")
+ }
+ if req.ConnLimitMax > 0 {
+ b.WriteString(" limit_conn_status 503;\n")
+ }
+ if req.WebSocket {
+ b.WriteString(" proxy_http_version 1.1;\n")
+ b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n")
+ b.WriteString(" proxy_set_header Connection \"upgrade\";\n")
+ }
+ // IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
+ // Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。
+ var acl strings.Builder
+ for _, ip := range req.AllowIPs {
+ ip = strings.TrimSpace(ip)
+ if ip != "" {
+ fmt.Fprintf(&acl, " allow %s;\n", ip)
+ }
+ }
+ for _, ip := range req.DenyIPs {
+ if ip = strings.TrimSpace(ip); ip != "" {
+ fmt.Fprintf(&acl, " deny %s;\n", ip)
+ }
+ }
+ if len(req.AllowIPs) > 0 {
+ acl.WriteString(" deny all;\n")
+ }
+ // Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式)
+ authLine := ""
+ var authNote string
+ if user := strings.TrimSpace(req.BasicAuthUser); user != "" {
+ if password := strings.TrimSpace(req.BasicAuthPassword); password != "" {
+ if err := writeBasicAuthSecret(req.Domain, user, password); err == nil {
+ authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
+ }
+ } else if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
+ authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
+ if generated != "" {
+ authNote = fmt.Sprintf("# Basic Auth: user=%s password=%s\n", user, generated)
+ }
+ }
+ }
+ b.WriteString(authNote)
+ // 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
+ var limitText strings.Builder
+ if req.RateLimitRate > 0 {
+ zone := zoneKey(req.Domain)
+ if req.RateLimitBurst > 0 {
+ fmt.Fprintf(&limitText, " limit_req zone=%s_req burst=%d nodelay;\n", zone, req.RateLimitBurst)
+ } else {
+ fmt.Fprintf(&limitText, " limit_req zone=%s_req nodelay;\n", zone)
+ }
+ }
+ if req.ConnLimitMax > 0 {
+ fmt.Fprintf(&limitText, " limit_conn %s_conn %d;\n", zoneKey(req.Domain), req.ConnLimitMax)
+ }
+ if req.ChinaOnly {
+ limitText.WriteString(" if ($fonu_china_client = 0) { return 403; }\n")
+ }
+ aclText := acl.String()
+ fmt.Fprintf(&b, " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n", aclText, authLine, limitText.String(), req.Upstream)
+ return b.String()
+}
+
+// agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。
+const agentStateDir = "/var/lib/fonu-agent"
+
+// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
+func zoneKey(domain string) string {
+ var b strings.Builder
+ b.WriteString("fonu_")
+ for _, r := range strings.ToLower(domain) {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ b.WriteRune(r)
+ } else {
+ b.WriteByte('_')
+ }
+ }
+ return b.String()
+}
+
+// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
+func (s *Server) limitsFilePath() string {
+ return filepath.Join(s.cfg.NginxConfDir, "fonuagent-limits.conf")
+}
+
+// chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。
+func (s *Server) chinaGeoFilePath() string {
+ return filepath.Join(s.cfg.NginxConfDir, "fonuagent-china.conf")
+}
+
+// chinaListPath 返回中国 IP 段文件(geo include 格式: 1;)。
+func chinaListPath() string {
+ return filepath.Join(agentStateDir, "geo", "china.conf")
+}
+
+func limitsStatePath() string {
+ return filepath.Join(agentStateDir, "route-limits.json")
+}
+
+// routeLimit 单条路由的限流参数。
+type routeLimit struct {
+ Rate int `json:"rate,omitempty"`
+ Burst int `json:"burst,omitempty"`
+ Conn int `json:"conn,omitempty"`
+}
+
+func readLimitState() map[string]routeLimit {
+ state := map[string]routeLimit{}
+ data, err := os.ReadFile(limitsStatePath())
+ if err != nil {
+ return state
+ }
+ _ = json.Unmarshal(data, &state)
+ return state
+}
+
+// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
+func (s *Server) writeLimitState(state map[string]routeLimit) error {
+ data, err := json.Marshal(state)
+ if err != nil {
+ return err
+ }
+ if err := atomicWrite(limitsStatePath(), data, 0o640); err != nil {
+ return err
+ }
+ path := s.limitsFilePath()
+ if len(state) == 0 {
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return nil
+ }
+ var b strings.Builder
+ b.WriteString("# Managed by fonu-agent. Do not edit.\n")
+ domains := make([]string, 0, len(state))
+ for domain := range state {
+ domains = append(domains, domain)
+ }
+ sort.Strings(domains)
+ for _, domain := range domains {
+ item := state[domain]
+ key := zoneKey(domain)
+ if item.Rate > 0 {
+ fmt.Fprintf(&b, "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n", key, item.Rate)
+ }
+ if item.Conn > 0 {
+ fmt.Fprintf(&b, "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n", key)
+ }
+ }
+ return atomicWrite(path, []byte(b.String()), 0o640)
+}
+
+// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
+func (s *Server) applyLimitState(req routeRequest) error {
+ state := readLimitState()
+ if req.RateLimitRate > 0 || req.ConnLimitMax > 0 {
+ state[req.Domain] = routeLimit{Rate: req.RateLimitRate, Burst: req.RateLimitBurst, Conn: req.ConnLimitMax}
+ } else {
+ delete(state, req.Domain)
+ }
+ return s.writeLimitState(state)
+}
+
+// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
+func (s *Server) clearLimitState(domain string) error {
+ state := readLimitState()
+ if _, ok := state[domain]; !ok {
+ return nil
+ }
+ delete(state, domain)
+ return s.writeLimitState(state)
+}
+
+// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
+func sslDirectives(certDir string, tls13Only bool) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", filepath.Join(certDir, "fullchain.pem"), filepath.Join(certDir, "privkey.pem"))
+ if tls13Only {
+ b.WriteString(" ssl_protocols TLSv1.3;\n")
+ }
+ return b.String()
+}
+
+// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
+func writeBasicAuthSecret(domain, user, password string) error {
+ out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ return atomicWrite(basicAuthFilePath(domain), []byte(user+":"+strings.TrimSpace(string(out))+"\n"), 0o640)
+}
+
+// basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。
+func basicAuthFilePath(domain string) string {
+ return filepath.Join("/var/lib/fonu-agent", "basicauth", domain+".htpasswd")
+}
+
+// ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。
+// 返回明文密码(只在生成配置时写进注释,供用户查看一次)。
+func ensureBasicAuthSecret(domain, user string) (string, error) {
+ path := basicAuthFilePath(domain)
+ if data, err := os.ReadFile(path); err == nil {
+ // 已有凭据:格式 user:$apr1$...,无法反解;返回空表示沿用旧密码
+ if len(data) > 0 {
+ return "", nil
+ }
+ }
+ password := randomToken()[:16]
+ // 用 openssl 生成 apr1 哈希(Nginx 原生支持);系统无 openssl 时退化为 crypt 失败报错
+ out, err := exec.Command("openssl", "passwd", "-apr1", password).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("生成 htpasswd 失败: %w: %s", err, strings.TrimSpace(string(out)))
+ }
+ hash := strings.TrimSpace(string(out))
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ return "", err
+ }
+ if err := atomicWrite(path, []byte(user+":"+hash+"\n"), 0o640); err != nil {
+ return "", err
+ }
+ return password, nil
+}
+
+func atomicWrite(path string, data []byte, mode os.FileMode) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".fonu-agent-*")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath)
+ if err := tmp.Chmod(mode); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
+
+func writeJSON(w http.ResponseWriter, status int, value any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(value)
+}
+
+func randomToken() string {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return hex.EncodeToString([]byte(time.Now().String()))
+ }
+ return hex.EncodeToString(buf)
+}
diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go
new file mode 100644
index 0000000..59d8c26
--- /dev/null
+++ b/internal/api/auth_handler.go
@@ -0,0 +1,131 @@
+package api
+
+import (
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/auth"
+ "github.com/fonu/fonu/internal/notify"
+)
+
+type AuthHandler struct {
+ auth *auth.Service
+ notify *notify.Service
+}
+
+func NewAuthHandler(authSvc *auth.Service, notifySvc *notify.Service) *AuthHandler {
+ return &AuthHandler{auth: authSvc, notify: notifySvc}
+}
+
+type loginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type authStatusResponse struct {
+ Initialized bool `json:"initialized"`
+ Authenticated bool `json:"authenticated"`
+}
+
+func (h *AuthHandler) Status(w http.ResponseWriter, r *http.Request) {
+ initialized, err := h.auth.IsInitialized(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取初始化状态失败")
+ return
+ }
+ authenticated := false
+ if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
+ if err := h.auth.ValidateSession(r.Context(), sessionID); err == nil {
+ authenticated = true
+ }
+ }
+ writeJSON(w, http.StatusOK, authStatusResponse{
+ Initialized: initialized,
+ Authenticated: authenticated,
+ })
+}
+
+func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+ var req loginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ sessionID, err := h.auth.Login(r.Context(), req.Username, req.Password)
+ if err != nil {
+ if errors.Is(err, auth.ErrUnauthorized) {
+ if h.notify != nil {
+ h.notify.RecordLoginFailure(r.Context(), clientIP(r))
+ }
+ writeError(r, w, http.StatusUnauthorized, "用户名或密码错误")
+ return
+ }
+ writeError(r, w, http.StatusInternalServerError, "登录失败")
+ return
+ }
+ setSessionCookie(w, sessionID)
+ writeJSON(w, http.StatusOK, map[string]string{"message": "登录成功"})
+}
+
+func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ OldPassword string `json:"old_password"`
+ NewPassword string `json:"new_password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ sessionID := sessionIDFromContext(r.Context())
+ adminID, err := h.auth.AdminIDForSession(r.Context(), sessionID)
+ if err != nil {
+ writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
+ return
+ }
+ if err := h.auth.ChangePassword(r.Context(), adminID, req.OldPassword, req.NewPassword); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "密码已更新"})
+}
+
+func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+ if sessionID := sessionIDFromContext(r.Context()); sessionID != "" {
+ _ = h.auth.Logout(r.Context(), sessionID)
+ }
+ clearSessionCookie(w)
+ writeJSON(w, http.StatusOK, map[string]string{"message": "已退出登录"})
+}
+
+func setSessionCookie(w http.ResponseWriter, sessionID string) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "fonu_session",
+ Value: sessionID,
+ Path: "/",
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ MaxAge: int((7 * 24 * time.Hour).Seconds()),
+ })
+}
+
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return strings.TrimSpace(r.RemoteAddr)
+ }
+ return host
+}
+
+func clearSessionCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "fonu_session",
+ Value: "",
+ Path: "/",
+ HttpOnly: true,
+ MaxAge: -1,
+ })
+}
diff --git a/internal/api/auth_handler_test.go b/internal/api/auth_handler_test.go
new file mode 100644
index 0000000..4a02302
--- /dev/null
+++ b/internal/api/auth_handler_test.go
@@ -0,0 +1,96 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/fonu/fonu/internal/auth"
+ "github.com/fonu/fonu/internal/backup"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/db"
+ "github.com/fonu/fonu/internal/discovery"
+ "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+func TestAuthSetupAndLoginFlow(t *testing.T) {
+ conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer conn.Close()
+
+ if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ tmpDir := t.TempDir()
+ authSvc := auth.New(conn)
+ proxySvc := service.NewProxyService(config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil)
+ handler := NewRouter(Deps{
+ Config: config.Config{DataDir: tmpDir, NginxPIDFile: tmpDir + "/nginx.pid"},
+ Auth: authSvc,
+ Proxy: proxySvc,
+ Backup: backup.New(tmpDir),
+ Discovery: discovery.New(),
+ StartedAt: time.Now().UTC().Format(time.RFC3339),
+ })
+
+ statusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
+ statusRec := httptest.NewRecorder()
+ handler.ServeHTTP(statusRec, statusReq)
+ if statusRec.Code != http.StatusOK {
+ t.Fatalf("status code: %d", statusRec.Code)
+ }
+
+ if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
+ t.Fatalf("setup admin: %v", err)
+ }
+
+ loginBody, _ := json.Marshal(map[string]string{
+ "username": "admin",
+ "password": "password123",
+ })
+ loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(loginBody))
+ loginRec := httptest.NewRecorder()
+ handler.ServeHTTP(loginRec, loginReq)
+ if loginRec.Code != http.StatusOK {
+ t.Fatalf("login status: %d body=%s", loginRec.Code, loginRec.Body.String())
+ }
+
+ cookie := loginRec.Result().Cookies()[0]
+ if cookie.Name != "fonu_session" || cookie.HttpOnly == false {
+ t.Fatal("expected httponly session cookie")
+ }
+
+ authStatusReq := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
+ authStatusReq.AddCookie(cookie)
+ authStatusRec := httptest.NewRecorder()
+ handler.ServeHTTP(authStatusRec, authStatusReq)
+ if authStatusRec.Code != http.StatusOK {
+ t.Fatalf("auth status code: %d", authStatusRec.Code)
+ }
+ var authStatus authStatusResponse
+ if err := json.Unmarshal(authStatusRec.Body.Bytes(), &authStatus); err != nil {
+ t.Fatalf("decode auth status: %v", err)
+ }
+ if !authStatus.Initialized || !authStatus.Authenticated {
+ t.Fatalf("expected authenticated status, got %+v", authStatus)
+ }
+
+ proxiesReq := httptest.NewRequest(http.MethodGet, "/api/proxies", nil)
+ proxiesReq.AddCookie(cookie)
+ proxiesRec := httptest.NewRecorder()
+ handler.ServeHTTP(proxiesRec, proxiesReq)
+ if proxiesRec.Code != http.StatusOK {
+ t.Fatalf("proxies status: %d", proxiesRec.Code)
+ }
+}
diff --git a/internal/api/backup_handler.go b/internal/api/backup_handler.go
new file mode 100644
index 0000000..7a6f739
--- /dev/null
+++ b/internal/api/backup_handler.go
@@ -0,0 +1,73 @@
+package api
+
+import (
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+
+ "github.com/fonu/fonu/internal/backup"
+)
+
+type BackupHandler struct {
+ svc *backup.Service
+}
+
+func NewBackupHandler(svc *backup.Service) *BackupHandler {
+ return &BackupHandler{svc: svc}
+}
+
+func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
+ path, err := h.svc.Export(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "创建备份失败")
+ return
+ }
+ defer os.Remove(path)
+
+ file, err := os.Open(path)
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取备份失败")
+ return
+ }
+ defer file.Close()
+
+ w.Header().Set("Content-Type", "application/gzip")
+ w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
+ _, _ = io.Copy(w, file)
+}
+
+func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(64 << 20); err != nil {
+ writeError(r, w, http.StatusBadRequest, "上传文件无效")
+ return
+ }
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "请选择备份文件")
+ return
+ }
+ defer file.Close()
+
+ tmpPath := filepath.Join(os.TempDir(), header.Filename)
+ out, err := os.Create(tmpPath)
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
+ return
+ }
+ if _, err := io.Copy(out, file); err != nil {
+ out.Close()
+ _ = os.Remove(tmpPath)
+ writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
+ return
+ }
+ out.Close()
+
+ if err := h.svc.Restore(r.Context(), tmpPath); err != nil {
+ _ = os.Remove(tmpPath)
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ _ = os.Remove(tmpPath)
+ writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
+}
diff --git a/internal/api/cert_handler.go b/internal/api/cert_handler.go
new file mode 100644
index 0000000..b749fb2
--- /dev/null
+++ b/internal/api/cert_handler.go
@@ -0,0 +1,151 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "github.com/fonu/fonu/internal/acme"
+)
+
+type CertHandler struct {
+ svc *acme.Service
+}
+
+func NewCertHandler(svc *acme.Service) *CertHandler {
+ return &CertHandler{svc: svc}
+}
+
+func (h *CertHandler) List(w http.ResponseWriter, r *http.Request) {
+ records, err := h.svc.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取证书失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, records)
+}
+
+func (h *CertHandler) Options(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, []map[string]string{
+ {"value": acme.CALetsEncrypt, "label": acme.CALabel(acme.CALetsEncrypt)},
+ {"value": acme.CAZeroSSL, "label": acme.CALabel(acme.CAZeroSSL)},
+ {"value": acme.CABuypass, "label": acme.CALabel(acme.CABuypass)},
+ {"value": acme.CALetsEncryptStaging, "label": acme.CALabel(acme.CALetsEncryptStaging)},
+ })
+}
+
+func (h *CertHandler) Apply(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Domain string `json:"domain"`
+ DNSZone string `json:"dns_zone"`
+ DDNSConfigID int64 `json:"ddns_config_id"`
+ Domains []string `json:"domains"`
+ CA string `json:"ca"`
+ Email string `json:"email"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ domains := req.Domains
+ if len(domains) == 0 && strings.TrimSpace(req.Domain) != "" {
+ domains = []string{req.Domain}
+ }
+ if req.DDNSConfigID <= 0 {
+ writeError(r, w, http.StatusBadRequest, "请选择 DNS 任务")
+ return
+ }
+ jobID, err := h.svc.StartApply(r.Context(), domains, req.CA, req.Email, req.DDNSConfigID)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusAccepted, map[string]string{"job_id": jobID})
+}
+
+func (h *CertHandler) ApplyJobStream(w http.ResponseWriter, r *http.Request) {
+ jobID := strings.TrimSpace(r.PathValue("id"))
+ if jobID == "" {
+ writeError(r, w, http.StatusBadRequest, "无效的任务 ID")
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+ return
+ }
+ if err := h.svc.StreamJob(r.Context(), w, jobID, func() error {
+ flusher.Flush()
+ return nil
+ }); err != nil {
+ return
+ }
+}
+
+func (h *CertHandler) Import(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Certificate string `json:"certificate"`
+ PrivateKey string `json:"private_key"`
+ CertPath string `json:"cert_path"`
+ KeyPath string `json:"key_path"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ record, err := h.svc.Import(r.Context(), req.Certificate, req.PrivateKey, req.CertPath, req.KeyPath)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, record)
+}
+
+func (h *CertHandler) Download(w http.ResponseWriter, r *http.Request) {
+ domain := strings.TrimSpace(r.PathValue("domain"))
+ if domain == "" {
+ writeError(r, w, http.StatusBadRequest, "域名不能为空")
+ return
+ }
+ part := r.URL.Query().Get("part")
+ filename, contentType, data, err := h.svc.Download(r.Context(), domain, part)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(data)
+}
+
+func (h *CertHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ domain := strings.TrimSpace(r.PathValue("domain"))
+ if domain == "" {
+ writeError(r, w, http.StatusBadRequest, "域名不能为空")
+ return
+ }
+ if err := h.svc.Delete(r.Context(), domain); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "证书已删除"})
+}
+
+func (h *CertHandler) Renew(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Domain string `json:"domain"`
+ CA string `json:"ca"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ record, err := h.svc.Renew(r.Context(), req.Domain, req.CA)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, record)
+}
diff --git a/internal/api/chinacidr_handler.go b/internal/api/chinacidr_handler.go
new file mode 100644
index 0000000..3775792
--- /dev/null
+++ b/internal/api/chinacidr_handler.go
@@ -0,0 +1,29 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/fonu/fonu/internal/chinacidr"
+)
+
+type ChinaCIDRHandler struct {
+ svc *chinacidr.Service
+}
+
+func NewChinaCIDRHandler(svc *chinacidr.Service) *ChinaCIDRHandler {
+ return &ChinaCIDRHandler{svc: svc}
+}
+
+func (h *ChinaCIDRHandler) Status(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *ChinaCIDRHandler) Refresh(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ if err := h.svc.Update(ctx); err != nil {
+ h.svc.RecordLastError(ctx, err)
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, h.svc.Status(ctx))
+}
diff --git a/internal/api/ddns_handler.go b/internal/api/ddns_handler.go
new file mode 100644
index 0000000..292f764
--- /dev/null
+++ b/internal/api/ddns_handler.go
@@ -0,0 +1,157 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "github.com/fonu/fonu/internal/ddns"
+)
+
+type DDNSHandler struct {
+ svc *ddns.Service
+}
+
+func NewDDNSHandler(svc *ddns.Service) *DDNSHandler {
+ return &DDNSHandler{svc: svc}
+}
+
+type ddnsRequest struct {
+ Provider string `json:"provider"`
+ RootDomain string `json:"root_domain"`
+ RecordName string `json:"record_name"`
+ RecordNames []string `json:"record_names"`
+ Domains []string `json:"domains"`
+ IPv4Enabled *bool `json:"ipv4_enabled"`
+ IPv6Enabled *bool `json:"ipv6_enabled"`
+ Enabled *bool `json:"enabled"`
+ CustomIPv4 string `json:"custom_ipv4"`
+ CustomIPv6 string `json:"custom_ipv6"`
+ APIToken string `json:"api_token"`
+ APITokenID string `json:"api_token_id"`
+ APISecret string `json:"api_secret"`
+}
+
+func (h *DDNSHandler) toInput(req ddnsRequest) ddns.SaveInput {
+ return ddns.SaveInput{
+ Provider: req.Provider,
+ RootDomain: req.RootDomain,
+ RecordName: req.RecordName,
+ RecordNames: req.RecordNames,
+ Domains: req.Domains,
+ IPv4Enabled: boolDefault(req.IPv4Enabled, true),
+ IPv6Enabled: boolDefault(req.IPv6Enabled, false),
+ Enabled: boolDefault(req.Enabled, true),
+ CustomIPv4: req.CustomIPv4,
+ CustomIPv6: req.CustomIPv6,
+ APIToken: req.APIToken,
+ APITokenID: req.APITokenID,
+ APISecret: req.APISecret,
+ }
+}
+
+func (h *DDNSHandler) List(w http.ResponseWriter, r *http.Request) {
+ var configs []ddns.Config
+ var err error
+ if r.URL.Query().Get("lite") == "1" {
+ configs, err = h.svc.ListLite(r.Context())
+ } else {
+ configs, err = h.svc.List(r.Context())
+ }
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取 DDNS 配置失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, configs)
+}
+
+func (h *DDNSHandler) Create(w http.ResponseWriter, r *http.Request) {
+ var req ddnsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ cfg, err := h.svc.Create(r.Context(), h.toInput(req))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) Update(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+ return
+ }
+ var req ddnsRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ cfg, err := h.svc.Update(r.Context(), id, h.toInput(req))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+ return
+ }
+ if err := h.svc.Delete(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *DDNSHandler) Test(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ConfigID int64 `json:"config_id"`
+ Provider string `json:"provider"`
+ APIToken string `json:"api_token"`
+ APITokenID string `json:"api_token_id"`
+ APISecret string `json:"api_secret"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if err := h.svc.Test(r.Context(), ddns.TestInput{
+ ConfigID: req.ConfigID,
+ Provider: req.Provider,
+ APIToken: req.APIToken,
+ APITokenID: req.APITokenID,
+ APISecret: req.APISecret,
+ }); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "连接成功"})
+}
+
+func (h *DDNSHandler) UpdateOne(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的配置 ID")
+ return
+ }
+ cfg, err := h.svc.UpdateNow(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, cfg)
+}
+
+func (h *DDNSHandler) UpdateAll(w http.ResponseWriter, r *http.Request) {
+ configs, err := h.svc.UpdateAll(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, configs)
+}
diff --git a/internal/api/deps.go b/internal/api/deps.go
new file mode 100644
index 0000000..7bd777d
--- /dev/null
+++ b/internal/api/deps.go
@@ -0,0 +1,37 @@
+package api
+
+import (
+ "io/fs"
+ "log/slog"
+
+ "github.com/fonu/fonu/internal/acme"
+ "github.com/fonu/fonu/internal/auth"
+ "github.com/fonu/fonu/internal/backup"
+ "github.com/fonu/fonu/internal/chinacidr"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/ddns"
+ "github.com/fonu/fonu/internal/discovery"
+ "github.com/fonu/fonu/internal/frp"
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+ "github.com/fonu/fonu/internal/traffic"
+)
+
+type Deps struct {
+ Config config.Config
+ Logger *slog.Logger
+ Auth *auth.Service
+ Proxy *service.ProxyService
+ DDNS *ddns.Service
+ ACME *acme.Service
+ Settings *settings.Store
+ ChinaCIDR *chinacidr.Service
+ Backup *backup.Service
+ Discovery *discovery.Service
+ FRPMulti *frp.Service
+ Traffic *traffic.Collector
+ Notify *notify.Service
+ StaticFS fs.FS
+ StartedAt string
+}
diff --git a/internal/api/discovery_handler.go b/internal/api/discovery_handler.go
new file mode 100644
index 0000000..726890d
--- /dev/null
+++ b/internal/api/discovery_handler.go
@@ -0,0 +1,20 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/fonu/fonu/internal/discovery"
+)
+
+type DiscoveryHandler struct {
+ svc *discovery.Service
+}
+
+func NewDiscoveryHandler(svc *discovery.Service) *DiscoveryHandler {
+ return &DiscoveryHandler{svc: svc}
+}
+
+func (h *DiscoveryHandler) Scan(w http.ResponseWriter, r *http.Request) {
+ host := r.URL.Query().Get("host")
+ writeJSON(w, http.StatusOK, h.svc.Scan(r.Context(), host))
+}
diff --git a/internal/api/frpmulti_handler.go b/internal/api/frpmulti_handler.go
new file mode 100644
index 0000000..75617d9
--- /dev/null
+++ b/internal/api/frpmulti_handler.go
@@ -0,0 +1,703 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/fonu/fonu/internal/frp"
+)
+
+// FRPMultiHandler 承载多服务端 FRP 模块的 HTTP 接口(/api/frpmulti/*),
+// 包装 internal/frp 的 Service(0.1.25 核心迁移版)。
+type FRPMultiHandler struct {
+ svc *frp.Service
+}
+
+type frpServerRequest struct {
+ AgentURL string `json:"agent_url"`
+ AgentToken string `json:"agent_token"`
+ ClearAgentToken bool `json:"clear_agent_token"`
+ SSHHost string `json:"ssh_host"`
+ SSHPort int `json:"ssh_port"`
+ SSHUser string `json:"ssh_user"`
+ SSHAuth string `json:"ssh_auth"`
+ SSHSecret string `json:"ssh_secret"`
+ ClearSSHSecret bool `json:"clear_ssh_secret"`
+ MgmtEnabled *bool `json:"mgmt_enabled"`
+ Name string `json:"name"`
+ ServerAddr string `json:"server_addr"`
+ ServerPort int `json:"server_port"`
+ TLSEnabled *bool `json:"tls_enabled"`
+ TLSServerName string `json:"tls_server_name"`
+ Enabled *bool `json:"enabled"`
+ Token string `json:"token"`
+ ClearToken bool `json:"clear_token"`
+ Options frp.ServerOptions `json:"options"`
+ OIDCClientSecret string `json:"oidc_client_secret"`
+ ClearOIDCClientSecret bool `json:"clear_oidc_client_secret"`
+ DashboardPassword string `json:"dashboard_password"`
+ ClearDashboardPassword bool `json:"clear_dashboard_password"`
+ TLSCertificate string `json:"tls_certificate"`
+ TLSKey string `json:"tls_key"`
+ TLSTrustedCA string `json:"tls_trusted_ca"`
+ ClearTLSCertificate bool `json:"clear_tls_certificate"`
+ ClearTLSKey bool `json:"clear_tls_key"`
+ ClearTLSTrustedCA bool `json:"clear_tls_trusted_ca"`
+}
+
+type frpProxyRequest struct {
+ ServerID int64 `json:"server_id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ LocalIP string `json:"local_ip"`
+ LocalPort int `json:"local_port"`
+ RemotePort *int `json:"remote_port"`
+ CustomDomains []string `json:"custom_domains"`
+ HostHeaderRewrite string `json:"host_header_rewrite"`
+ Options frp.ProxyOptions `json:"options"`
+ Enabled *bool `json:"enabled"`
+ Remark string `json:"remark"`
+}
+
+type frpDownloadProxyRequest struct {
+ Proxy string `json:"proxy"`
+}
+
+func NewFRPMultiHandler(svc *frp.Service) *FRPMultiHandler {
+ return &FRPMultiHandler{svc: svc}
+}
+
+func (h *FRPMultiHandler) Status(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Runtime(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, h.svc.Runtime(r.Context()))
+}
+
+func (h *FRPMultiHandler) SetDownloadProxy(w http.ResponseWriter, r *http.Request) {
+ var request frpDownloadProxyRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ runtime, err := h.svc.SetDownloadProxy(r.Context(), request.Proxy)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, runtime)
+}
+
+func (h *FRPMultiHandler) Releases(w http.ResponseWriter, r *http.Request) {
+ releases, err := h.svc.ListReleases(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, releases)
+}
+
+func (h *FRPMultiHandler) Binaries(w http.ResponseWriter, r *http.Request) {
+ binaries, err := h.svc.ListBinaries()
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取已下载 FRP 客户端失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, binaries)
+}
+
+func (h *FRPMultiHandler) DownloadStatus(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, http.StatusOK, h.svc.DownloadStatus())
+}
+
+func (h *FRPMultiHandler) DownloadRelease(w http.ResponseWriter, r *http.Request) {
+ version := r.PathValue("version")
+ task, err := h.svc.StartDownload(version)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusAccepted, task)
+}
+
+func (h *FRPMultiHandler) ActivateBinary(w http.ResponseWriter, r *http.Request) {
+ version := r.PathValue("version")
+ status, err := h.svc.ActivateBinary(r.Context(), version)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, status)
+}
+
+func (h *FRPMultiHandler) DeleteBinary(w http.ResponseWriter, r *http.Request) {
+ version := r.PathValue("version")
+ if err := h.svc.DeleteBinary(version); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) ListServers(w http.ResponseWriter, r *http.Request) {
+ servers, err := h.svc.ListServers(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取 FRP 服务端失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, servers)
+}
+
+func (h *FRPMultiHandler) ServerDiagnostics(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ diagnostics, err := h.svc.ServerDiagnostics(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, diagnostics)
+}
+
+func (h *FRPMultiHandler) ServerDetail(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ detail, err := h.svc.ServerDetail(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, detail)
+}
+
+func (h *FRPMultiHandler) StartServer(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ server, err := h.svc.StartServer(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) StopServer(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ server, err := h.svc.StopServer(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) CreateServer(w http.ResponseWriter, r *http.Request) {
+ request, ok := decodeFRPServerRequest(w, r)
+ if !ok {
+ return
+ }
+ server, err := h.svc.CreateServer(r.Context(), request.serverInput())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) UpdateServer(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ request, ok := decodeFRPServerRequest(w, r)
+ if !ok {
+ return
+ }
+ server, err := h.svc.UpdateServer(r.Context(), id, request.serverInput())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) DeleteServer(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ if err := h.svc.DeleteServer(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) AgentProbe(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ result, err := h.svc.AgentProbe(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+func (h *FRPMultiHandler) AgentInstall(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ server, err := h.svc.AgentInstall(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, server)
+}
+
+func (h *FRPMultiHandler) AgentUninstall(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ keepData := r.URL.Query().Get("keep_data") == "true"
+ output, err := h.svc.AgentUninstall(r.Context(), id, keepData)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "output": output})
+}
+
+func (h *FRPMultiHandler) AgentSSHDiagnose(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ output, err := h.svc.AgentSSHDiagnose(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"output": output})
+}
+
+func (h *FRPMultiHandler) AgentTest(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ if err := h.svc.AgentTest(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (h *FRPMultiHandler) AgentStatus(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ status, err := h.svc.AgentStatus(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, status)
+}
+
+func (h *FRPMultiHandler) AgentRouteCandidates(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ proxies, err := h.svc.ListAgentRouteCandidates(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, proxies)
+}
+
+func (h *FRPMultiHandler) DeployAgentRoute(w http.ResponseWriter, r *http.Request) {
+ proxyID, err := parseID(r.PathValue("proxyID"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ if err := h.svc.DeployAgentRoute(r.Context(), proxyID); err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
+}
+
+func (h *FRPMultiHandler) RemoveAgentRoute(w http.ResponseWriter, r *http.Request) {
+ proxyID, err := parseID(r.PathValue("proxyID"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ if err := h.svc.RemoveAgentRoute(r.Context(), proxyID); err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "proxy_id": proxyID})
+}
+
+func (h *FRPMultiHandler) AgentRoutes(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ // 路由状态明细在独立的 /api/v1/routes 端点(status 里的 routes 仍是字符串数组),
+ // 从 agentClient 直取明细并透传。
+ client, ok := h.svc.AgentClient(r.Context(), id)
+ if !ok {
+ writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
+ return
+ }
+ details, err := client.RoutesDetail(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, details)
+}
+
+// AgentRouteConf 返回指定穿透规则(首个域名)当前反代配置内容。
+func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ proxyID, err := parseID(r.PathValue("proxyID"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ proxy, err := h.svc.GetProxy(r.Context(), proxyID)
+ if err != nil || proxy.ServerID != id {
+ writeError(r, w, http.StatusNotFound, "FRP 规则不存在")
+ return
+ }
+ if len(proxy.CustomDomains) == 0 {
+ writeError(r, w, http.StatusBadRequest, "该规则没有自定义域名")
+ return
+ }
+ client, ok := h.svc.AgentClient(r.Context(), id)
+ if !ok {
+ writeError(r, w, http.StatusBadGateway, "公网 agent 未配置或 Token 未保存")
+ return
+ }
+ content, err := client.RouteConf(r.Context(), proxy.CustomDomains[0])
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": proxy.CustomDomains[0], "content": content})
+}
+
+// AgentInstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (h *FRPMultiHandler) AgentInstallNginx(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ result, err := h.svc.InstallAgentNginx(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+// AgentRouteHealth 返回指定服务端公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
+func (h *FRPMultiHandler) AgentRouteHealth(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ health, err := h.svc.RouteHealth(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, health)
+}
+
+func (h *FRPMultiHandler) AgentFRPSAction(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ var body struct {
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ if err := h.svc.AgentFRPSAction(r.Context(), id, body.Action); err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "action": body.Action})
+}
+
+func (h *FRPMultiHandler) InstallFRPSOnAgent(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ var body struct {
+ Version string `json:"version"`
+ Proxy string `json:"proxy"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&body)
+ if body.Version == "" {
+ body.Version = "0.71.0"
+ }
+ result, err := h.svc.InstallFRPSOnAgent(r.Context(), id, body.Version, body.Proxy)
+ if err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, result)
+}
+
+func (h *FRPMultiHandler) PushFRPSConfigToAgent(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ if err := h.svc.PushFRPSConfigToAgent(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadGateway, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+func (h *FRPMultiHandler) TestServer(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ if err := h.svc.Test(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "FRP 配置校验通过"})
+}
+
+func (h *FRPMultiHandler) ListProxies(w http.ResponseWriter, r *http.Request) {
+ proxies, err := h.svc.ListProxies(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, proxies)
+}
+
+// ListProxiesTraffic 返回各 frps 管理接口采集到的穿透规则流量(含速率)。
+func (h *FRPMultiHandler) ListProxiesTraffic(w http.ResponseWriter, r *http.Request) {
+ traffic, err := h.svc.ListProxiesTraffic(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取 FRP 规则流量失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, traffic)
+}
+
+func (h *FRPMultiHandler) CreateProxy(w http.ResponseWriter, r *http.Request) {
+ request, ok := decodeFRPProxyRequest(w, r)
+ if !ok {
+ return
+ }
+ proxy, err := h.svc.CreateProxy(r.Context(), request.proxyInput())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, proxy)
+}
+
+func (h *FRPMultiHandler) UpdateProxy(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ request, ok := decodeFRPProxyRequest(w, r)
+ if !ok {
+ return
+ }
+ proxy, err := h.svc.UpdateProxy(r.Context(), id, request.proxyInput())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, proxy)
+}
+
+func (h *FRPMultiHandler) DeleteProxy(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ if err := h.svc.DeleteProxy(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *FRPMultiHandler) Start(w http.ResponseWriter, r *http.Request) {
+ if err := h.svc.Start(r.Context()); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Stop(w http.ResponseWriter, r *http.Request) {
+ if err := h.svc.Stop(r.Context()); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Reload(w http.ResponseWriter, r *http.Request) {
+ if err := h.svc.Reload(r.Context()); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, h.svc.Status(r.Context()))
+}
+
+func (h *FRPMultiHandler) Logs(w http.ResponseWriter, r *http.Request) {
+ lines, err := h.svc.ReadLogs(queryInt(r, "limit", 200), int64(queryInt(r, "server_id", 0)))
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取 FRP 日志失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, lines)
+}
+
+// ProxyLogs 返回单条穿透规则的日志(按规则名过滤所属服务端 frpc 日志)。
+func (h *FRPMultiHandler) ProxyLogs(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 规则 ID 无效")
+ return
+ }
+ lines, err := h.svc.ReadProxyLogs(r.Context(), id, queryInt(r, "limit", 200))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, lines)
+}
+
+func (h *FRPMultiHandler) ExportConfig(w http.ResponseWriter, r *http.Request) {
+ content, err := h.svc.ExportMaskedConfig(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"content": content})
+}
+
+// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(bindPort/auth/webServer 与 frpc 侧一致)。
+func (h *FRPMultiHandler) FRPSServerConfig(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "FRP 服务端 ID 无效")
+ return
+ }
+ content, err := h.svc.FRPSServerConfig(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"content": content})
+}
+
+func decodeFRPServerRequest(w http.ResponseWriter, r *http.Request) (frpServerRequest, bool) {
+ var request frpServerRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return frpServerRequest{}, false
+ }
+ return request, true
+}
+
+func decodeFRPProxyRequest(w http.ResponseWriter, r *http.Request) (frpProxyRequest, bool) {
+ var request frpProxyRequest
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return frpProxyRequest{}, false
+ }
+ return request, true
+}
+
+func (request frpServerRequest) serverInput() frp.ServerInput {
+ return frp.ServerInput{
+ AgentURL: request.AgentURL, AgentToken: request.AgentToken, ClearAgentToken: request.ClearAgentToken,
+ SSHHost: request.SSHHost, SSHPort: request.SSHPort, SSHUser: request.SSHUser, SSHAuth: request.SSHAuth,
+ SSHSecret: request.SSHSecret, ClearSSHSecret: request.ClearSSHSecret, MgmtEnabled: boolDefault(request.MgmtEnabled, false),
+ Name: request.Name, ServerAddr: request.ServerAddr, ServerPort: request.ServerPort,
+ TLS: boolDefault(request.TLSEnabled, true), TLSServerName: request.TLSServerName,
+ Enabled: boolDefault(request.Enabled, true), Token: request.Token, ClearToken: request.ClearToken, Options: request.Options,
+ OIDCClientSecret: request.OIDCClientSecret, ClearOIDCClientSecret: request.ClearOIDCClientSecret,
+ DashboardPassword: request.DashboardPassword, ClearDashboardPassword: request.ClearDashboardPassword,
+ TLSCertificate: request.TLSCertificate, TLSKey: request.TLSKey, TLSTrustedCA: request.TLSTrustedCA,
+ ClearTLSCertificate: request.ClearTLSCertificate, ClearTLSKey: request.ClearTLSKey, ClearTLSTrustedCA: request.ClearTLSTrustedCA,
+ }
+}
+
+func (request frpProxyRequest) proxyInput() frp.ProxyInput {
+ return frp.ProxyInput{
+ ServerID: request.ServerID, Name: request.Name, Type: request.Type, LocalIP: request.LocalIP,
+ LocalPort: request.LocalPort, RemotePort: request.RemotePort, CustomDomains: request.CustomDomains,
+ HostHeaderRewrite: request.HostHeaderRewrite, Options: request.Options, Enabled: boolDefault(request.Enabled, true), Remark: request.Remark,
+ }
+}
diff --git a/internal/api/logs_handler.go b/internal/api/logs_handler.go
new file mode 100644
index 0000000..57d8475
--- /dev/null
+++ b/internal/api/logs_handler.go
@@ -0,0 +1,128 @@
+package api
+
+import (
+ "net/http"
+ "path/filepath"
+ "strconv"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/logstore"
+)
+
+type LogsHandler struct {
+ cfg config.Config
+}
+
+func NewLogsHandler(cfg config.Config) *LogsHandler {
+ return &LogsHandler{cfg: cfg}
+}
+
+func (h *LogsHandler) Access(w http.ResponseWriter, r *http.Request) {
+ limit := queryInt(r, "limit", 100)
+ keyword := r.URL.Query().Get("keyword")
+ status := queryInt(r, "status", 0)
+ entries, err := logstore.ReadAccess(filepath.Join(h.cfg.LogsDir(), "access.log"), limit, keyword, status)
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取访问日志失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, entries)
+}
+
+func (h *LogsHandler) Error(w http.ResponseWriter, r *http.Request) {
+ limit := queryInt(r, "limit", 100)
+ keyword := r.URL.Query().Get("keyword")
+ lines, err := logstore.ReadError(filepath.Join(h.cfg.LogsDir(), "error.log"), limit, keyword)
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取错误日志失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, lines)
+}
+
+func (h *LogsHandler) System(w http.ResponseWriter, r *http.Request) {
+ limit := queryInt(r, "limit", 100)
+ level := r.URL.Query().Get("level")
+ keyword := r.URL.Query().Get("keyword")
+ entries, err := logstore.ReadSystem(filepath.Join(h.cfg.LogsDir(), "app.log"), limit, level, keyword)
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取系统日志失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, entries)
+}
+
+func (h *LogsHandler) Stream(w http.ResponseWriter, r *http.Request) {
+ kind := r.URL.Query().Get("type")
+ if kind == "" {
+ kind = "error"
+ }
+ var path string
+ switch kind {
+ case "access":
+ path = filepath.Join(h.cfg.LogsDir(), "access.log")
+ case "error":
+ path = filepath.Join(h.cfg.LogsDir(), "error.log")
+ default:
+ writeError(r, w, http.StatusBadRequest, "实时日志仅支持 Nginx 访问/错误日志")
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+ return
+ }
+ tail := queryInt(r, "tail", 100)
+ var filter logstore.LineFilter
+ if kind == "access" {
+ filter = logstore.AccessHostFilter(r.URL.Query()["host"])
+ }
+ _ = logstore.StreamFile(r.Context(), path, w, func() error {
+ flusher.Flush()
+ return nil
+ }, tail, filter)
+}
+
+func (h *LogsHandler) StreamAccessForHosts(w http.ResponseWriter, r *http.Request, hosts []string) {
+ endpoints := make([]logstore.AccessEndpoint, 0, len(hosts))
+ for _, host := range hosts {
+ endpoints = append(endpoints, logstore.AccessEndpoint{Host: host})
+ }
+ h.StreamAccessForEndpoints(w, r, endpoints)
+}
+
+func (h *LogsHandler) StreamAccessForEndpoints(w http.ResponseWriter, r *http.Request, endpoints []logstore.AccessEndpoint) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ writeError(r, w, http.StatusInternalServerError, "SSE 不可用")
+ return
+ }
+ path := filepath.Join(h.cfg.LogsDir(), "access.log")
+ tail := queryInt(r, "tail", 100)
+ filter := logstore.AccessEndpointFilter(endpoints)
+ _ = logstore.StreamFile(r.Context(), path, w, func() error {
+ flusher.Flush()
+ return nil
+ }, tail, filter)
+}
+
+func queryInt(r *http.Request, key string, fallback int) int {
+ raw := r.URL.Query().Get(key)
+ if raw == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil {
+ return fallback
+ }
+ return n
+}
diff --git a/internal/api/logs_handler_test.go b/internal/api/logs_handler_test.go
new file mode 100644
index 0000000..9236bc8
--- /dev/null
+++ b/internal/api/logs_handler_test.go
@@ -0,0 +1,65 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/fonu/fonu/internal/auth"
+ "github.com/fonu/fonu/internal/backup"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/db"
+ "github.com/fonu/fonu/internal/discovery"
+ "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+func TestLogsAccessRoute(t *testing.T) {
+ dir := t.TempDir()
+ conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer conn.Close()
+ if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ authSvc := auth.New(conn)
+ if err := authSvc.Setup(context.Background(), "admin", "password123"); err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+ sessionID, err := authSvc.Login(context.Background(), "admin", "password123")
+ if err != nil {
+ t.Fatalf("login: %v", err)
+ }
+
+ handler := NewRouter(Deps{
+ Config: config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")},
+ Auth: authSvc,
+ Proxy: service.NewProxyService(config.Config{DataDir: dir, NginxPIDFile: filepath.Join(dir, "nginx.pid")}, conn, proxy.NewStore(conn), certificate.NewStore(conn), settings.NewStore(conn), nil),
+ Backup: backup.New(dir),
+ Discovery: discovery.New(),
+ StartedAt: time.Now().UTC().Format(time.RFC3339),
+ })
+
+ req := httptest.NewRequest(http.MethodGet, "/api/logs/access?limit=100", nil)
+ req.AddCookie(&http.Cookie{Name: "fonu_session", Value: sessionID})
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var body []map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..986ec91
--- /dev/null
+++ b/internal/api/middleware.go
@@ -0,0 +1,43 @@
+package api
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/fonu/fonu/internal/auth"
+)
+
+type contextKey string
+
+const sessionContextKey contextKey = "session_id"
+
+func SessionMiddleware(authSvc *auth.Service) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ cookie, err := r.Cookie("fonu_session")
+ if err == nil && cookie.Value != "" {
+ ctx := context.WithValue(r.Context(), sessionContextKey, cookie.Value)
+ r = r.WithContext(ctx)
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func RequireAuth(authSvc *auth.Service) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sessionID, _ := r.Context().Value(sessionContextKey).(string)
+ if err := authSvc.ValidateSession(r.Context(), sessionID); err != nil {
+ writeError(r, w, http.StatusUnauthorized, "未登录或会话已过期")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+func sessionIDFromContext(ctx context.Context) string {
+ v, _ := ctx.Value(sessionContextKey).(string)
+ return v
+}
diff --git a/internal/api/nginx_handler.go b/internal/api/nginx_handler.go
new file mode 100644
index 0000000..293d61e
--- /dev/null
+++ b/internal/api/nginx_handler.go
@@ -0,0 +1,136 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/fonu/fonu/internal/service"
+)
+
+type NginxHandler struct {
+ proxy *service.ProxyService
+}
+
+func NewNginxHandler(proxy *service.ProxyService) *NginxHandler {
+ return &NginxHandler{proxy: proxy}
+}
+
+type saveRuleNginxRequest struct {
+ Mode string `json:"mode"`
+ Content *string `json:"content"`
+}
+
+type rollbackNginxRequest struct {
+ Backup string `json:"backup"`
+}
+
+func (h *NginxHandler) GetRule(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ view, err := h.proxy.GetRuleNginx(r.Context(), id)
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) PutRule(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ var req saveRuleNginxRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ view, err := h.proxy.SaveRuleNginx(r.Context(), id, service.SaveRuleNginxInput{
+ Mode: req.Mode,
+ Content: req.Content,
+ })
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) RollbackRule(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ var req rollbackNginxRequest
+ if r.Body != nil && r.ContentLength != 0 {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ }
+ view, err := h.proxy.RollbackRuleNginx(r.Context(), id, req.Backup)
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) GetGlobal(w http.ResponseWriter, r *http.Request) {
+ view, err := h.proxy.GetGlobalNginx(r.Context())
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) PutGlobal(w http.ResponseWriter, r *http.Request) {
+ var req saveRuleNginxRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ view, err := h.proxy.SaveGlobalNginx(r.Context(), service.SaveGlobalNginxInput{
+ Mode: req.Mode,
+ Content: req.Content,
+ })
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func (h *NginxHandler) RollbackGlobal(w http.ResponseWriter, r *http.Request) {
+ var req rollbackNginxRequest
+ if r.Body != nil && r.ContentLength != 0 {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ }
+ view, err := h.proxy.RollbackGlobalNginx(r.Context(), req.Backup)
+ if err != nil {
+ writeServiceError(r, w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, view)
+}
+
+func writeServiceError(r *http.Request, w http.ResponseWriter, err error) {
+ if err == nil {
+ return
+ }
+ msg := err.Error()
+ if msg == "规则不存在" {
+ writeError(r, w, http.StatusNotFound, msg, err)
+ return
+ }
+ writeError(r, w, http.StatusBadRequest, msg, err)
+}
diff --git a/internal/api/notify_handler.go b/internal/api/notify_handler.go
new file mode 100644
index 0000000..d261a82
--- /dev/null
+++ b/internal/api/notify_handler.go
@@ -0,0 +1,33 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/fonu/fonu/internal/notify"
+)
+
+type NotifyHandler struct {
+ notify *notify.Service
+}
+
+func NewNotifyHandler(notifySvc *notify.Service) *NotifyHandler {
+ return &NotifyHandler{notify: notifySvc}
+}
+
+func (h *NotifyHandler) Test(w http.ResponseWriter, r *http.Request) {
+ if h.notify == nil {
+ writeError(r, w, http.StatusInternalServerError, "通知服务未初始化")
+ return
+ }
+ var req notify.TestInput
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ if err := h.notify.SendTest(r.Context(), req); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error(), err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "测试通知已发送"})
+}
diff --git a/internal/api/proxy_handler.go b/internal/api/proxy_handler.go
new file mode 100644
index 0000000..156e4c6
--- /dev/null
+++ b/internal/api/proxy_handler.go
@@ -0,0 +1,260 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/logstore"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/traffic"
+)
+
+type ProxyHandler struct {
+ cfg config.Config
+ svc *service.ProxyService
+ logs *LogsHandler
+ traffic *traffic.Collector
+}
+
+func NewProxyHandler(cfg config.Config, svc *service.ProxyService, logs *LogsHandler, collector *traffic.Collector) *ProxyHandler {
+ return &ProxyHandler{cfg: cfg, svc: svc, logs: logs, traffic: collector}
+}
+
+type proxyRequest struct {
+ Domain string `json:"domain"`
+ Upstream string `json:"upstream"`
+ ListenPort *int `json:"listen_port"`
+ ListenIPv4 *bool `json:"listen_ipv4"`
+ ListenIPv6 *bool `json:"listen_ipv6"`
+ Hosts []string `json:"hosts"`
+ HTTPSEnabled *bool `json:"https_enabled"`
+ HTTPRedirect *bool `json:"http_redirect"`
+ Enabled *bool `json:"enabled"`
+ Name *string `json:"name"`
+ Remark *string `json:"remark"` // deprecated alias for name
+ Security *securityRequest `json:"security,omitempty"`
+}
+
+type proxyReorderRequest struct {
+ IDs []int64 `json:"ids"`
+}
+
+func (h *ProxyHandler) List(w http.ResponseWriter, r *http.Request) {
+ rules, err := h.svc.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, proxy.RulesForAPI(rules))
+}
+
+func (h *ProxyHandler) Create(w http.ResponseWriter, r *http.Request) {
+ var req proxyRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+
+ hosts := req.hosts()
+ if len(hosts) == 0 {
+ writeError(r, w, http.StatusBadRequest, "至少需要一个前端域名")
+ return
+ }
+
+ listenPort := intDefault(req.ListenPort, h.cfg.DefaultListenPort(boolDefault(req.HTTPSEnabled, true)))
+ name := ""
+ if resolved := req.resolveName(); resolved != nil {
+ name = *resolved
+ }
+ in := proxy.CreateInput{
+ Upstream: req.Upstream,
+ ListenPort: listenPort,
+ ListenIPv4: boolDefault(req.ListenIPv4, true),
+ ListenIPv6: boolDefault(req.ListenIPv6, false),
+ Hosts: hosts,
+ HTTPSEnabled: boolDefault(req.HTTPSEnabled, true),
+ HTTPRedirect: boolDefault(req.HTTPRedirect, true),
+ Enabled: boolDefault(req.Enabled, true),
+ Name: name,
+ Security: req.securityInput(),
+ }
+
+ rule, err := h.svc.Create(r.Context(), in)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusCreated, proxy.RuleForAPI(rule))
+}
+
+func (h *ProxyHandler) Update(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+
+ var req proxyRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+
+ in := proxy.UpdateInput{}
+ if req.Upstream != "" {
+ in.Upstream = &req.Upstream
+ }
+ if req.ListenPort != nil {
+ in.ListenPort = req.ListenPort
+ }
+ if req.ListenIPv4 != nil {
+ in.ListenIPv4 = req.ListenIPv4
+ }
+ if req.ListenIPv6 != nil {
+ in.ListenIPv6 = req.ListenIPv6
+ }
+ if hosts := req.hosts(); len(hosts) > 0 {
+ in.Hosts = &hosts
+ }
+ if req.HTTPSEnabled != nil {
+ in.HTTPSEnabled = req.HTTPSEnabled
+ }
+ if req.HTTPRedirect != nil {
+ in.HTTPRedirect = req.HTTPRedirect
+ }
+ if req.Enabled != nil {
+ in.Enabled = req.Enabled
+ }
+ if resolved := req.resolveName(); resolved != nil {
+ in.Name = resolved
+ }
+ if req.Security != nil {
+ sec := req.securityInput()
+ in.Security = &sec
+ }
+
+ rule, err := h.svc.Update(r.Context(), id, in)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, proxy.RuleForAPI(rule))
+}
+
+func (h *ProxyHandler) Traffic(w http.ResponseWriter, r *http.Request) {
+ if h.traffic == nil {
+ writeJSON(w, http.StatusOK, []traffic.RuleTraffic{})
+ return
+ }
+ rules, err := h.svc.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取反向代理规则失败")
+ return
+ }
+ writeJSON(w, http.StatusOK, h.traffic.SnapshotForRules(rules))
+}
+
+func (h *ProxyHandler) Clients(w http.ResponseWriter, r *http.Request) {
+ if h.traffic == nil {
+ writeJSON(w, http.StatusOK, []traffic.ClientConn{})
+ return
+ }
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ rule, err := h.svc.Get(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "规则不存在")
+ return
+ }
+ writeJSON(w, http.StatusOK, h.traffic.ClientsForRule(rule))
+}
+
+func (h *ProxyHandler) StreamLogs(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ rule, err := h.svc.Get(r.Context(), id)
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "规则不存在")
+ return
+ }
+ endpoints := rule.Endpoints()
+ if len(endpoints) == 0 {
+ writeError(r, w, http.StatusBadRequest, "该规则没有前端域名")
+ return
+ }
+ matches := make([]logstore.AccessEndpoint, 0, len(endpoints))
+ for _, ep := range endpoints {
+ matches = append(matches, logstore.AccessEndpoint{Host: ep.Hostname, Port: ep.Port})
+ }
+ h.logs.StreamAccessForEndpoints(w, r, matches)
+}
+
+func (h *ProxyHandler) Reorder(w http.ResponseWriter, r *http.Request) {
+ var req proxyReorderRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ if err := h.svc.Reorder(r.Context(), req.IDs); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (h *ProxyHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ id, err := parseID(r.PathValue("id"))
+ if err != nil {
+ writeError(r, w, http.StatusBadRequest, "无效的规则 ID")
+ return
+ }
+ if err := h.svc.Delete(r.Context(), id); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (req proxyRequest) resolveName() *string {
+ if req.Name != nil {
+ return req.Name
+ }
+ return req.Remark
+}
+
+func (req proxyRequest) hosts() []string {
+ if len(req.Hosts) > 0 {
+ return req.Hosts
+ }
+ if req.Domain != "" {
+ return []string{req.Domain}
+ }
+ return nil
+}
+
+func intDefault(v *int, fallback int) int {
+ if v == nil {
+ return fallback
+ }
+ return *v
+}
+
+func parseID(raw string) (int64, error) {
+ return strconv.ParseInt(raw, 10, 64)
+}
+
+func boolDefault(v *bool, fallback bool) bool {
+ if v == nil {
+ return fallback
+ }
+ return *v
+}
diff --git a/internal/api/proxy_security.go b/internal/api/proxy_security.go
new file mode 100644
index 0000000..42e3142
--- /dev/null
+++ b/internal/api/proxy_security.go
@@ -0,0 +1,60 @@
+package api
+
+import (
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+type securityRequest struct {
+ IPBlacklist []string `json:"ip_blacklist,omitempty"`
+ IPWhitelist []string `json:"ip_whitelist,omitempty"`
+ IPWhitelistMode *bool `json:"ip_whitelist_mode,omitempty"`
+ IPBlacklistText *string `json:"ip_blacklist_text,omitempty"`
+ IPWhitelistText *string `json:"ip_whitelist_text,omitempty"`
+ ChinaOnly *bool `json:"china_only,omitempty"`
+ BasicAuth *basicAuthRequest `json:"basic_auth,omitempty"`
+ RateLimit *proxy.RateLimitConfig `json:"rate_limit,omitempty"`
+ ConnLimit *proxy.ConnLimitConfig `json:"conn_limit,omitempty"`
+ ProxySSLVerifyOff *bool `json:"proxy_ssl_verify_off,omitempty"`
+ ProxyHostUpstream *bool `json:"proxy_host_upstream,omitempty"`
+ TLSMin13Only *bool `json:"tls_min_13_only,omitempty"`
+ SecurityHeaders *bool `json:"security_headers,omitempty"`
+}
+
+type basicAuthRequest struct {
+ Enabled *bool `json:"enabled,omitempty"`
+ Username *string `json:"username,omitempty"`
+ Password *string `json:"password,omitempty"`
+}
+
+func (req *proxyRequest) securityInput() proxy.SecurityInput {
+ if req.Security == nil {
+ return proxy.SecurityInput{}
+ }
+ s := req.Security
+ in := proxy.SecurityInput{
+ IPBlacklist: s.IPBlacklist,
+ IPWhitelist: s.IPWhitelist,
+ IPWhitelistMode: s.IPWhitelistMode,
+ ChinaOnly: s.ChinaOnly,
+ RateLimit: s.RateLimit,
+ ConnLimit: s.ConnLimit,
+ ProxySSLVerifyOff: s.ProxySSLVerifyOff,
+ ProxyHostUpstream: s.ProxyHostUpstream,
+ TLSMin13Only: s.TLSMin13Only,
+ SecurityHeaders: s.SecurityHeaders,
+ }
+ if s.IPBlacklistText != nil {
+ in.IPBlacklist = proxy.ParseIPListText(*s.IPBlacklistText)
+ }
+ if s.IPWhitelistText != nil {
+ in.IPWhitelist = proxy.ParseIPListText(*s.IPWhitelistText)
+ }
+ if s.BasicAuth != nil {
+ in.BasicAuth = &proxy.BasicAuthInput{
+ Enabled: s.BasicAuth.Enabled,
+ Username: s.BasicAuth.Username,
+ Password: s.BasicAuth.Password,
+ }
+ }
+ return in
+}
diff --git a/internal/api/response.go b/internal/api/response.go
new file mode 100644
index 0000000..fa53adc
--- /dev/null
+++ b/internal/api/response.go
@@ -0,0 +1,64 @@
+package api
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "reflect"
+)
+
+type ErrorBody struct {
+ Error string `json:"error"`
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ if payload == nil {
+ _, _ = w.Write([]byte("null"))
+ return
+ }
+ // Go nil slices/maps encode as JSON null; clients expect [] / {}.
+ v := reflect.ValueOf(payload)
+ switch v.Kind() {
+ case reflect.Slice:
+ if v.IsNil() {
+ _, _ = w.Write([]byte("[]"))
+ return
+ }
+ case reflect.Map:
+ if v.IsNil() {
+ _, _ = w.Write([]byte("{}"))
+ return
+ }
+ }
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func writeError(r *http.Request, w http.ResponseWriter, status int, message string, cause ...error) {
+ logAPIError(r, status, message, cause)
+ writeJSON(w, status, ErrorBody{Error: message})
+}
+
+func logAPIError(r *http.Request, status int, message string, cause []error) {
+ if status < 400 {
+ return
+ }
+ attrs := []any{
+ "module", "API",
+ "status", status,
+ "message", message,
+ }
+ if r != nil {
+ attrs = append(attrs, "method", r.Method, "path", r.URL.Path)
+ }
+ if len(cause) > 0 && cause[0] != nil {
+ attrs = append(attrs, "error", cause[0].Error())
+ }
+ switch {
+ case status >= 500:
+ slog.Error("request failed", attrs...)
+ default:
+ slog.Warn("request rejected", attrs...)
+ }
+}
diff --git a/internal/api/response_test.go b/internal/api/response_test.go
new file mode 100644
index 0000000..d0827ee
--- /dev/null
+++ b/internal/api/response_test.go
@@ -0,0 +1,64 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestWriteJSONNilSlice(t *testing.T) {
+ var items []string
+ rec := httptest.NewRecorder()
+ writeJSON(rec, http.StatusOK, items)
+
+ if rec.Body.String() != "[]" {
+ t.Fatalf("expected [], got %q", rec.Body.String())
+ }
+}
+
+func TestWriteJSONNilMap(t *testing.T) {
+ var values map[string]string
+ rec := httptest.NewRecorder()
+ writeJSON(rec, http.StatusOK, values)
+
+ if rec.Body.String() != "{}" {
+ t.Fatalf("expected {}, got %q", rec.Body.String())
+ }
+}
+
+func TestWriteJSONSlice(t *testing.T) {
+ rec := httptest.NewRecorder()
+ writeJSON(rec, http.StatusOK, []int{1, 2})
+
+ var out []int
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if len(out) != 2 {
+ t.Fatalf("expected 2 items, got %v", out)
+ }
+}
+
+func TestWriteErrorLogsClientError(t *testing.T) {
+ var buf bytes.Buffer
+ prev := slog.Default()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})))
+ defer slog.SetDefault(prev)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/settings/notify/test", nil)
+ rec := httptest.NewRecorder()
+ writeError(req, rec, http.StatusBadRequest, "请填写 SMTP 主机")
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rec.Code)
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("请填写 SMTP 主机")) {
+ t.Fatalf("expected log message, got %s", buf.String())
+ }
+ if !bytes.Contains(buf.Bytes(), []byte("/api/settings/notify/test")) {
+ t.Fatalf("expected log path, got %s", buf.String())
+ }
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..a78095d
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,227 @@
+package api
+
+import (
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/auth"
+)
+
+func NewRouter(deps Deps) http.Handler {
+ logsHandler := NewLogsHandler(deps.Config)
+ r := &Router{
+ authHandler: NewAuthHandler(deps.Auth, deps.Notify),
+ proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
+ nginxHandler: NewNginxHandler(deps.Proxy),
+ statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
+ ddnsHandler: NewDDNSHandler(deps.DDNS),
+ certHandler: NewCertHandler(deps.ACME),
+ settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
+ chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
+ logsHandler: logsHandler,
+ backupHandler: NewBackupHandler(deps.Backup),
+ discoveryHandler: NewDiscoveryHandler(deps.Discovery),
+ frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
+ authSvc: deps.Auth,
+ staticFS: deps.StaticFS,
+ }
+
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("GET /api/version", Version(deps.Config))
+ mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
+ mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
+ mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
+
+ protect := func(pattern string, handler http.HandlerFunc) {
+ mux.Handle(pattern, SessionMiddleware(deps.Auth)(RequireAuth(deps.Auth)(http.HandlerFunc(handler))))
+ }
+
+ protect("POST /api/auth/password", r.authHandler.ChangePassword)
+ protect("GET /api/status", r.statusHandler.Get)
+ protect("GET /api/proxies", r.proxyHandler.List)
+ protect("GET /api/proxies/traffic", r.proxyHandler.Traffic)
+ protect("POST /api/proxies", r.proxyHandler.Create)
+ protect("PUT /api/proxies/reorder", r.proxyHandler.Reorder)
+ protect("PUT /api/proxies/{id}", r.proxyHandler.Update)
+ protect("DELETE /api/proxies/{id}", r.proxyHandler.Delete)
+ protect("GET /api/proxies/{id}/logs/stream", r.proxyHandler.StreamLogs)
+ protect("GET /api/proxies/{id}/clients", r.proxyHandler.Clients)
+ protect("GET /api/proxies/{id}/nginx", r.nginxHandler.GetRule)
+ protect("PUT /api/proxies/{id}/nginx", r.nginxHandler.PutRule)
+ protect("POST /api/proxies/{id}/nginx/rollback", r.nginxHandler.RollbackRule)
+ protect("GET /api/settings/nginx/global", r.nginxHandler.GetGlobal)
+ protect("PUT /api/settings/nginx/global", r.nginxHandler.PutGlobal)
+ protect("POST /api/settings/nginx/global/rollback", r.nginxHandler.RollbackGlobal)
+ protect("GET /api/ddns", r.ddnsHandler.List)
+ protect("POST /api/ddns", r.ddnsHandler.Create)
+ protect("PUT /api/ddns/{id}", r.ddnsHandler.Update)
+ protect("DELETE /api/ddns/{id}", r.ddnsHandler.Delete)
+ protect("POST /api/ddns/test", r.ddnsHandler.Test)
+ protect("POST /api/ddns/update", r.ddnsHandler.UpdateAll)
+ protect("POST /api/ddns/{id}/update", r.ddnsHandler.UpdateOne)
+ protect("GET /api/certificates", r.certHandler.List)
+ protect("GET /api/certificates/ca-options", r.certHandler.Options)
+ protect("POST /api/certificates/apply", r.certHandler.Apply)
+ protect("GET /api/certificates/jobs/{id}/stream", r.certHandler.ApplyJobStream)
+ protect("POST /api/certificates/import", r.certHandler.Import)
+ protect("POST /api/certificates/renew", r.certHandler.Renew)
+ protect("GET /api/certificates/{domain}/download", r.certHandler.Download)
+ protect("DELETE /api/certificates/{domain}", r.certHandler.Delete)
+ notifyHandler := NewNotifyHandler(deps.Notify)
+ protect("GET /api/settings", r.settingsHandler.Get)
+ protect("PUT /api/settings", r.settingsHandler.Put)
+ protect("POST /api/settings/notify/test", notifyHandler.Test)
+ if deps.ChinaCIDR != nil {
+ protect("GET /api/settings/china-cidr", r.chinaCIDRHandler.Status)
+ protect("POST /api/settings/china-cidr/refresh", r.chinaCIDRHandler.Refresh)
+ }
+ protect("GET /api/logs/access", r.logsHandler.Access)
+ protect("GET /api/logs/error", r.logsHandler.Error)
+ protect("GET /api/logs/system", r.logsHandler.System)
+ protect("GET /api/logs/stream", r.logsHandler.Stream)
+ protect("GET /api/backup/export", r.backupHandler.Export)
+ protect("POST /api/backup/restore", r.backupHandler.Restore)
+ protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
+ if deps.FRPMulti != nil {
+ frpHandler := r.frpmultiHandler
+ protect("GET /api/frpmulti/status", frpHandler.Status)
+ protect("GET /api/frpmulti/runtime", frpHandler.Runtime)
+ protect("PUT /api/frpmulti/runtime/download-proxy", frpHandler.SetDownloadProxy)
+ protect("GET /api/frpmulti/releases", frpHandler.Releases)
+ protect("GET /api/frpmulti/binaries", frpHandler.Binaries)
+ protect("GET /api/frpmulti/download", frpHandler.DownloadStatus)
+ protect("POST /api/frpmulti/releases/{version}/download", frpHandler.DownloadRelease)
+ protect("POST /api/frpmulti/binaries/{version}/activate", frpHandler.ActivateBinary)
+ protect("DELETE /api/frpmulti/binaries/{version}", frpHandler.DeleteBinary)
+ protect("GET /api/frpmulti/servers", frpHandler.ListServers)
+ protect("GET /api/frpmulti/servers/{id}/detail", frpHandler.ServerDetail)
+ protect("GET /api/frpmulti/servers/{id}/diagnostics", frpHandler.ServerDiagnostics)
+ protect("GET /api/frpmulti/servers/{id}/frps-config", frpHandler.FRPSServerConfig)
+ protect("POST /api/frpmulti/servers/{id}/start", frpHandler.StartServer)
+ protect("POST /api/frpmulti/servers/{id}/stop", frpHandler.StopServer)
+ protect("POST /api/frpmulti/servers", frpHandler.CreateServer)
+ protect("PUT /api/frpmulti/servers/{id}", frpHandler.UpdateServer)
+ protect("DELETE /api/frpmulti/servers/{id}", frpHandler.DeleteServer)
+ protect("POST /api/frpmulti/servers/{id}/test", frpHandler.TestServer)
+ protect("POST /api/frpmulti/servers/{id}/agent/probe", frpHandler.AgentProbe)
+ protect("POST /api/frpmulti/servers/{id}/agent/install", frpHandler.AgentInstall)
+ protect("POST /api/frpmulti/servers/{id}/agent/uninstall", frpHandler.AgentUninstall)
+ protect("POST /api/frpmulti/servers/{id}/agent/ssh-diagnose", frpHandler.AgentSSHDiagnose)
+ protect("POST /api/frpmulti/servers/{id}/agent/test", frpHandler.AgentTest)
+ protect("GET /api/frpmulti/servers/{id}/agent/status", frpHandler.AgentStatus)
+ protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
+ protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
+ protect("POST /api/frpmulti/servers/{id}/agent/nginx/install", frpHandler.AgentInstallNginx)
+ protect("GET /api/frpmulti/servers/{id}/agent/routes/{proxyID}/conf", frpHandler.AgentRouteConf)
+ protect("GET /api/frpmulti/servers/{id}/agent/route-candidates", frpHandler.AgentRouteCandidates)
+ protect("POST /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.DeployAgentRoute)
+ protect("DELETE /api/frpmulti/servers/{id}/agent/routes/{proxyID}", frpHandler.RemoveAgentRoute)
+ protect("POST /api/frpmulti/servers/{id}/agent/frps-install", frpHandler.InstallFRPSOnAgent)
+ protect("POST /api/frpmulti/servers/{id}/agent/frps-action", frpHandler.AgentFRPSAction)
+ protect("POST /api/frpmulti/servers/{id}/agent/frps-config", frpHandler.PushFRPSConfigToAgent)
+ protect("GET /api/frpmulti/proxies", frpHandler.ListProxies)
+ protect("GET /api/frpmulti/proxies/traffic", frpHandler.ListProxiesTraffic)
+ protect("POST /api/frpmulti/proxies", frpHandler.CreateProxy)
+ protect("PUT /api/frpmulti/proxies/{id}", frpHandler.UpdateProxy)
+ protect("DELETE /api/frpmulti/proxies/{id}", frpHandler.DeleteProxy)
+ protect("GET /api/frpmulti/proxies/{id}/logs", frpHandler.ProxyLogs)
+ protect("POST /api/frpmulti/start", frpHandler.Start)
+ protect("POST /api/frpmulti/stop", frpHandler.Stop)
+ protect("POST /api/frpmulti/reload", frpHandler.Reload)
+ protect("GET /api/frpmulti/logs", frpHandler.Logs)
+ protect("GET /api/frpmulti/config/export", frpHandler.ExportConfig)
+ }
+
+ if deps.StaticFS != nil {
+ mux.Handle("/", r.spaHandler())
+ }
+
+ logger := deps.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return loggingMiddleware(logger, SessionMiddleware(deps.Auth)(mux))
+}
+
+type Router struct {
+ authHandler *AuthHandler
+ proxyHandler *ProxyHandler
+ nginxHandler *NginxHandler
+ statusHandler *StatusHandler
+ ddnsHandler *DDNSHandler
+ certHandler *CertHandler
+ settingsHandler *SettingsHandler
+ chinaCIDRHandler *ChinaCIDRHandler
+ logsHandler *LogsHandler
+ backupHandler *BackupHandler
+ discoveryHandler *DiscoveryHandler
+ frpmultiHandler *FRPMultiHandler
+ authSvc *auth.Service
+ staticFS fs.FS
+}
+
+func (r *Router) spaHandler() http.Handler {
+ fileServer := http.FileServer(http.FS(r.staticFS))
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ if strings.HasPrefix(req.URL.Path, "/api/") {
+ http.NotFound(w, req)
+ return
+ }
+
+ path := strings.TrimPrefix(req.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ if _, err := fs.Stat(r.staticFS, path); err != nil {
+ // SPA fallback: unknown routes serve index.html
+ req.URL.Path = "/index.html"
+ }
+ fileServer.ServeHTTP(w, req)
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (w *statusRecorder) WriteHeader(code int) {
+ w.status = code
+ w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *statusRecorder) Flush() {
+ if f, ok := w.ResponseWriter.(http.Flusher); ok {
+ f.Flush()
+ }
+}
+
+func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rec, r)
+ if strings.HasPrefix(r.URL.Path, "/api/") &&
+ !strings.Contains(r.URL.Path, "/logs/stream") &&
+ !strings.Contains(r.URL.Path, "/certificates/jobs/") &&
+ rec.status >= 400 {
+ level := slog.LevelWarn
+ msg := "api request completed with client error"
+ if rec.status >= 500 {
+ level = slog.LevelError
+ msg = "api request failed"
+ }
+ logger.Log(r.Context(), level, msg,
+ "module", "HTTP",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rec.status,
+ "duration_ms", time.Since(start).Milliseconds(),
+ )
+ }
+ })
+}
diff --git a/internal/api/settings_handler.go b/internal/api/settings_handler.go
new file mode 100644
index 0000000..bca490b
--- /dev/null
+++ b/internal/api/settings_handler.go
@@ -0,0 +1,204 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+ "github.com/fonu/fonu/internal/validate"
+)
+
+type SettingsHandler struct {
+ store *settings.Store
+ proxy *service.ProxyService
+ notify *notify.Service
+}
+
+func NewSettingsHandler(store *settings.Store, proxySvc *service.ProxyService, notifySvc *notify.Service) *SettingsHandler {
+ return &SettingsHandler{store: store, proxy: proxySvc, notify: notifySvc}
+}
+
+func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
+ values, err := h.store.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
+ return
+ }
+ notify.SanitizeSettings(values)
+ writeJSON(w, http.StatusOK, values)
+}
+
+func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
+ var req map[string]string
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(r, w, http.StatusBadRequest, "请求格式无效")
+ return
+ }
+ allowed := map[string]bool{
+ settings.KeyTheme: true,
+ settings.KeyTimezone: true,
+ settings.KeyLogRetentionDays: true,
+ settings.KeyDDNSCheckInterval: true,
+ settings.KeyCertRenewThreshold: true,
+ settings.KeyRootDomain: true,
+ settings.KeyACMEEmail: true,
+ settings.KeyACMECA: true,
+ settings.KeyZeroSSLAPIKey: true,
+ settings.KeyNotifyWebhookURL: true,
+ settings.KeyNotifyType: true,
+ settings.KeyNotifyEmailJSON: true,
+ settings.KeyNotifySMTPPassword: true,
+ settings.KeyNotifyWebhookJSON: true,
+ settings.KeyNotifyWebhookSecret: true,
+ settings.KeyNotifyTelegramJSON: true,
+ settings.KeyNotifyTelegramToken: true,
+ settings.KeyNotifyOnDDNSIPChange: true,
+ settings.KeyNotifyOnDDNSFailure: true,
+ settings.KeyNotifyOnCertExpiry: true,
+ settings.KeyNotifyOnCertRenewSuccess: true,
+ settings.KeyNotifyOnIPFrequentAccess: true,
+ settings.KeyNotifyOnCertRenewFailure: true,
+ settings.KeyNotifyOnLoginFailure: true,
+ settings.KeyNotifyOnNginxReloadFailure: true,
+ settings.KeyNotifyIPFrequentThreshold: true,
+ settings.KeyNotifyIPFrequentWindowSec: true,
+ settings.KeyNotifyLoginFailureThreshold: true,
+ settings.KeyNotifyLoginFailureWindowSec: true,
+ settings.KeyTrustedProxy: true,
+ settings.KeyGlobalIPBlacklist: true,
+ settings.KeyGlobalIPWhitelist: true,
+ settings.KeyChinaCIDRUpdateHours: true,
+ settings.KeyChinaCIDRSourceV4: true,
+ settings.KeyChinaCIDRSourceV6: true,
+ }
+ filtered := map[string]string{}
+ for key, value := range req {
+ if !allowed[key] {
+ continue
+ }
+ if err := h.validateSetting(key, value); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ filtered[key] = value
+ }
+ if h.notify != nil && h.notify.Store() != nil {
+ if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
+ if err := h.notify.Store().Save(r.Context(), in); err != nil {
+ writeError(r, w, http.StatusBadRequest, err.Error())
+ return
+ }
+ for key := range filtered {
+ if isNotifySettingKey(key) {
+ delete(filtered, key)
+ }
+ }
+ }
+ }
+ if err := h.store.SetMany(r.Context(), filtered); err != nil {
+ writeError(r, w, http.StatusInternalServerError, "保存设置失败", err)
+ return
+ }
+ if h.proxy != nil && (filtered[settings.KeyTrustedProxy] != "" || filtered[settings.KeyGlobalIPBlacklist] != "" || filtered[settings.KeyGlobalIPWhitelist] != "") {
+ if err := h.proxy.ReloadAll(r.Context()); err != nil {
+ writeError(r, w, http.StatusInternalServerError, "设置已保存,但 Nginx 重载失败:"+err.Error(), err)
+ return
+ }
+ }
+ values, err := h.store.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取设置失败", err)
+ return
+ }
+ notify.SanitizeSettings(values)
+ writeJSON(w, http.StatusOK, values)
+}
+
+func isNotifySettingKey(key string) bool {
+ switch key {
+ case settings.KeyNotifyType,
+ settings.KeyNotifyEmailJSON,
+ settings.KeyNotifySMTPPassword,
+ settings.KeyNotifyWebhookJSON,
+ settings.KeyNotifyWebhookSecret,
+ settings.KeyNotifyTelegramJSON,
+ settings.KeyNotifyTelegramToken,
+ settings.KeyNotifyOnDDNSIPChange,
+ settings.KeyNotifyOnDDNSFailure,
+ settings.KeyNotifyOnCertExpiry,
+ settings.KeyNotifyOnCertRenewSuccess,
+ settings.KeyNotifyOnIPFrequentAccess,
+ settings.KeyNotifyOnCertRenewFailure,
+ settings.KeyNotifyOnLoginFailure,
+ settings.KeyNotifyOnNginxReloadFailure,
+ settings.KeyNotifyIPFrequentThreshold,
+ settings.KeyNotifyIPFrequentWindowSec,
+ settings.KeyNotifyLoginFailureThreshold,
+ settings.KeyNotifyLoginFailureWindowSec,
+ settings.KeyNotifyWebhookURL:
+ return true
+ default:
+ return false
+ }
+}
+
+func (h *SettingsHandler) validateSetting(key, value string) error {
+ switch key {
+ case settings.KeyNotifyEmailJSON, settings.KeyNotifyWebhookJSON, settings.KeyNotifyTelegramJSON:
+ if value != "" {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(value), &raw); err != nil {
+ return err
+ }
+ }
+ case settings.KeyNotifyType:
+ switch value {
+ case "", "email", "webhook", "telegram":
+ default:
+ return fmt.Errorf("无效的通知类型")
+ }
+ case settings.KeyNotifyIPFrequentThreshold:
+ if value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 10 || n > 10000 {
+ return fmt.Errorf("频繁访问阈值需在 10–10000 之间")
+ }
+ }
+ case settings.KeyNotifyIPFrequentWindowSec, settings.KeyNotifyLoginFailureWindowSec:
+ if value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 10 || n > 3600 {
+ return fmt.Errorf("统计窗口需在 10–3600 秒之间")
+ }
+ }
+ case settings.KeyNotifyLoginFailureThreshold:
+ if value != "" {
+ n, err := strconv.Atoi(value)
+ if err != nil || n < 3 || n > 100 {
+ return fmt.Errorf("登录失败阈值需在 3–100 之间")
+ }
+ }
+ case settings.KeyGlobalIPBlacklist, settings.KeyGlobalIPWhitelist:
+ var list []string
+ if value != "" && value != "[]" {
+ if err := json.Unmarshal([]byte(value), &list); err != nil {
+ return err
+ }
+ if err := validate.IPOrCIDRList(list); err != nil {
+ return err
+ }
+ }
+ case settings.KeyTrustedProxy:
+ if value != "" && value != "{}" {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(value), &raw); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/api/status_handler.go b/internal/api/status_handler.go
new file mode 100644
index 0000000..bdf5fd8
--- /dev/null
+++ b/internal/api/status_handler.go
@@ -0,0 +1,183 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/acme"
+ certstore "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/ddns"
+ "github.com/fonu/fonu/internal/logstore"
+ "github.com/fonu/fonu/internal/publicip"
+ "github.com/fonu/fonu/internal/service"
+)
+
+type StatusHandler struct {
+ cfg config.Config
+ proxySvc *service.ProxyService
+ ddnsSvc *ddns.Service
+ acmeSvc *acme.Service
+ startedAt string
+ nginxPIDFile string
+}
+
+func NewStatusHandler(cfg config.Config, proxySvc *service.ProxyService, ddnsSvc *ddns.Service, acmeSvc *acme.Service, startedAt, nginxPIDFile string) *StatusHandler {
+ return &StatusHandler{
+ cfg: cfg,
+ proxySvc: proxySvc,
+ ddnsSvc: ddnsSvc,
+ acmeSvc: acmeSvc,
+ startedAt: startedAt,
+ nginxPIDFile: nginxPIDFile,
+ }
+}
+
+type statusResponse struct {
+ PublicIPv4 string `json:"public_ipv4"`
+ PublicIPv6 string `json:"public_ipv6"`
+ PublicIPSource string `json:"public_ip_source"`
+ DDNSStatus string `json:"ddns_status"`
+ DDNSCount int `json:"ddns_count"`
+ DDNSLastUpdated string `json:"ddns_last_updated,omitempty"`
+ CertificateStatus string `json:"certificate_status"`
+ CertificateDays int `json:"certificate_days"`
+ CertificateCount int `json:"certificate_count"`
+ CertificateSummary string `json:"certificate_summary,omitempty"`
+ ProxyCount int `json:"proxy_count"`
+ NginxStatus string `json:"nginx_status"`
+ RequestToday int `json:"request_today"`
+ RequestYesterday int `json:"request_yesterday"`
+ RequestTrend *int `json:"request_trend,omitempty"`
+ RequestsHourly []int `json:"requests_hourly"`
+ RequestsHourlyLabels []string `json:"requests_hourly_labels"`
+ ErrorToday int `json:"error_today"`
+ AvgResponseMs float64 `json:"avg_response_ms"`
+ StartedAt string `json:"started_at"`
+ UptimeSeconds int64 `json:"uptime_seconds"`
+}
+
+func (h *StatusHandler) Get(w http.ResponseWriter, r *http.Request) {
+ rules, err := h.proxySvc.List(r.Context())
+ if err != nil {
+ writeError(r, w, http.StatusInternalServerError, "读取状态失败")
+ return
+ }
+
+ nginxStatus := "unknown"
+ if _, err := os.Stat(h.nginxPIDFile); err == nil {
+ nginxStatus = "running"
+ } else if os.IsNotExist(err) {
+ nginxStatus = "stopped"
+ }
+
+ ddnsStatus, ddnsLastUpdated, ddnsCount := h.ddnsSvc.Summary(r.Context())
+ ipv4, ipv6, ipSource := h.resolvePublicIPs(r.Context())
+
+ records, _ := h.acmeSvc.List(r.Context())
+ certStatus, certDays, certCount, certSummary := summarizeCertificates(records)
+
+ accessLog := filepath.Join(h.cfg.LogsDir(), "access.log")
+ total, errors, avgMs := logstore.CountTodayAccess(accessLog)
+ yesterday, _, _ := logstore.CountAccessForDay(accessLog, time.Now().AddDate(0, 0, -1))
+ hourly, hourlyLabels := logstore.HourlyAccessCounts(accessLog)
+
+ started, _ := time.Parse(time.RFC3339, h.startedAt)
+ uptime := time.Since(started).Seconds()
+
+ writeJSON(w, http.StatusOK, statusResponse{
+ PublicIPv4: ipv4,
+ PublicIPv6: ipv6,
+ PublicIPSource: ipSource,
+ DDNSStatus: ddnsStatus,
+ DDNSCount: ddnsCount,
+ DDNSLastUpdated: ddnsLastUpdated,
+ CertificateStatus: certStatus,
+ CertificateDays: certDays,
+ CertificateCount: certCount,
+ CertificateSummary: certSummary,
+ ProxyCount: len(rules),
+ NginxStatus: nginxStatus,
+ RequestToday: total,
+ RequestYesterday: yesterday,
+ RequestTrend: requestTrendPercent(total, yesterday),
+ RequestsHourly: hourly,
+ RequestsHourlyLabels: hourlyLabels,
+ ErrorToday: errors,
+ AvgResponseMs: avgMs,
+ StartedAt: h.startedAt,
+ UptimeSeconds: int64(uptime),
+ })
+}
+
+func (h *StatusHandler) resolvePublicIPs(ctx context.Context) (ipv4, ipv6, source string) {
+ if ipv4, ipv6, ok := h.ddnsSvc.PublicIPs(ctx); ok {
+ return ipv4, ipv6, "ddns"
+ }
+ if ipv4, ipv6, ok := h.ddnsSvc.LastKnownPublicIPs(ctx); ok {
+ return ipv4, ipv6, "ddns"
+ }
+ detectCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ ipv4, ipv6, err := publicip.Detect(detectCtx)
+ if err != nil {
+ return "", ipv6, "none"
+ }
+ return ipv4, ipv6, "detect"
+}
+
+func summarizeCertificates(records []certstore.Record) (status string, days int, count int, summary string) {
+ if len(records) == 0 {
+ return "none", 0, 0, ""
+ }
+ count = len(records)
+ status = "ok"
+ days = records[0].DaysLeft
+ for _, rec := range records {
+ if rec.DaysLeft < days {
+ days = rec.DaysLeft
+ }
+ switch {
+ case rec.Status == "error" || rec.DaysLeft <= 0:
+ status = "error"
+ case rec.DaysLeft <= 30 && status != "error":
+ status = "warning"
+ }
+ }
+ if count == 1 {
+ summary = certificateSummaryName(records[0])
+ } else {
+ summary = fmt.Sprintf("%d 张证书", count)
+ }
+ return status, days, count, summary
+}
+
+func requestTrendPercent(today, yesterday int) *int {
+ if yesterday <= 0 {
+ if today <= 0 {
+ return nil
+ }
+ v := 100
+ return &v
+ }
+ v := int(math.Round(float64(today-yesterday) / float64(yesterday) * 100))
+ return &v
+}
+
+func certificateSummaryName(rec certstore.Record) string {
+ for _, domain := range rec.Domains {
+ if strings.HasPrefix(domain, "*.") {
+ return domain
+ }
+ }
+ if rec.Wildcard {
+ return "*." + rec.Domain
+ }
+ return rec.Domain
+}
diff --git a/internal/api/status_handler_test.go b/internal/api/status_handler_test.go
new file mode 100644
index 0000000..de1f6b5
--- /dev/null
+++ b/internal/api/status_handler_test.go
@@ -0,0 +1,18 @@
+package api
+
+import "testing"
+
+func TestRequestTrendPercent(t *testing.T) {
+ if got := requestTrendPercent(0, 0); got != nil {
+ t.Fatalf("expected nil trend, got %v", *got)
+ }
+ if got := requestTrendPercent(10, 0); got == nil || *got != 100 {
+ t.Fatalf("expected 100 for new traffic, got %v", got)
+ }
+ if got := requestTrendPercent(150, 100); got == nil || *got != 50 {
+ t.Fatalf("expected 50, got %v", got)
+ }
+ if got := requestTrendPercent(50, 100); got == nil || *got != -50 {
+ t.Fatalf("expected -50, got %v", got)
+ }
+}
diff --git a/internal/api/version_handler.go b/internal/api/version_handler.go
new file mode 100644
index 0000000..fb4cbd8
--- /dev/null
+++ b/internal/api/version_handler.go
@@ -0,0 +1,24 @@
+package api
+
+import (
+ "net/http"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/version"
+)
+
+type versionResponse struct {
+ Version string `json:"version"`
+ NginxHTTPPort int `json:"nginx_http_port"`
+ NginxHTTPSPort int `json:"nginx_https_port"`
+}
+
+func Version(cfg config.Config) http.HandlerFunc {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, versionResponse{
+ Version: version.Version,
+ NginxHTTPPort: cfg.NginxDefaultHTTPPort,
+ NginxHTTPSPort: cfg.NginxDefaultHTTPSPort,
+ })
+ }
+}
diff --git a/internal/api/version_handler_test.go b/internal/api/version_handler_test.go
new file mode 100644
index 0000000..cc022f6
--- /dev/null
+++ b/internal/api/version_handler_test.go
@@ -0,0 +1,36 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/version"
+)
+
+func TestVersionEndpoint(t *testing.T) {
+ version.Version = "0.1.6"
+ cfg := config.Config{NginxDefaultHTTPPort: 18080, NginxDefaultHTTPSPort: 9443}
+ req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
+ rec := httptest.NewRecorder()
+ Version(cfg)(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ var body map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["version"] != "0.1.6" {
+ t.Fatalf("version %q", body["version"])
+ }
+ if body["nginx_http_port"] != float64(18080) {
+ t.Fatalf("nginx_http_port %v", body["nginx_http_port"])
+ }
+ if body["nginx_https_port"] != float64(9443) {
+ t.Fatalf("nginx_https_port %v", body["nginx_https_port"])
+ }
+}
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 0000000..8dd20a3
--- /dev/null
+++ b/internal/app/app.go
@@ -0,0 +1,229 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/fonu/fonu/internal/acme"
+ "github.com/fonu/fonu/internal/api"
+ "github.com/fonu/fonu/internal/auth"
+ "github.com/fonu/fonu/internal/backup"
+ "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/chinacidr"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/db"
+ "github.com/fonu/fonu/internal/ddns"
+ "github.com/fonu/fonu/internal/discovery"
+ "github.com/fonu/fonu/internal/frp"
+ "github.com/fonu/fonu/internal/logstore"
+ "github.com/fonu/fonu/internal/nginx"
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/scheduler"
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/service"
+ "github.com/fonu/fonu/internal/settings"
+ "github.com/fonu/fonu/internal/traffic"
+)
+
+type App struct {
+ cfg config.Config
+ logger *slog.Logger
+ server *http.Server
+ nginx *nginx.Manager
+ frpMulti *frp.Service
+ scheduler *scheduler.Scheduler
+ shutdownFn context.CancelFunc
+ db interface{ Close() error }
+}
+
+func New(cfg config.Config, staticFS fs.FS, migrationsDir string) (*App, error) {
+ appLogWriter := logstore.NewAppLogWriter(cfg.LogsDir())
+ logger := slog.New(slog.NewJSONHandler(io.MultiWriter(os.Stdout, appLogWriter), &slog.HandlerOptions{Level: slog.LevelInfo}))
+ slog.SetDefault(logger)
+
+ for _, dir := range []string{cfg.DataDir, cfg.NginxDir(), cfg.LogsDir(), cfg.CertsDir(), cfg.FrpDir()} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+ }
+
+ conn, err := db.Open(cfg.DBPath())
+ if err != nil {
+ return nil, err
+ }
+ if err := db.Migrate(context.Background(), conn, migrationsDir); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ secretBox, err := secret.NewBox(cfg.SessionSecret)
+ if err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ authSvc := auth.New(conn)
+ if err := authSvc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("bootstrap admin: %w", err)
+ }
+ proxyStore := proxy.NewStore(conn)
+ settingsStore := settings.NewStore(conn)
+ ddnsStore := ddns.NewStore(conn)
+ certStore := certificate.NewStore(conn)
+ nginxMgr := nginx.NewManager(cfg, logger.With("module", "NGINX"))
+ if err := nginxMgr.EnsureDirs(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+
+ proxySvc := service.NewProxyService(cfg, conn, proxyStore, certStore, settingsStore, nginxMgr)
+ chinaCIDRSvc := chinacidr.New(cfg, settingsStore, nginxMgr, proxyStore, logger.With("module", "CHINA_CIDR"))
+ notifySvc := notify.New(settingsStore, secretBox, logger)
+ proxySvc.SetNotify(notifySvc)
+ ddnsSvc := ddns.NewService(ddnsStore, settingsStore, secretBox, logger, notifySvc)
+ acmeSvc := acme.NewService(cfg, certStore, ddnsSvc, settingsStore, proxySvc, logger, notifySvc)
+ backupSvc := backup.New(cfg.DataDir)
+ discoverySvc := discovery.New()
+ // 新增:多服务端 FRP 模块(接管原网关模式)
+ frpMultiSvc := frp.NewService(cfg, frp.NewStore(conn), secretBox, logger, certStore)
+ if err := frpMultiSvc.EnsureDirs(); err != nil {
+ conn.Close()
+ return nil, err
+ }
+ startedAt := time.Now().UTC().Format(time.RFC3339)
+ trafficCollector := traffic.NewCollector(conn, filepath.Join(cfg.LogsDir(), "access.log"), notifySvc)
+
+ handler := api.NewRouter(api.Deps{
+ Config: cfg,
+ Logger: logger,
+ Auth: authSvc,
+ Proxy: proxySvc,
+ DDNS: ddnsSvc,
+ ACME: acmeSvc,
+ Settings: settingsStore,
+ Notify: notifySvc,
+ ChinaCIDR: chinaCIDRSvc,
+ Backup: backupSvc,
+ Discovery: discoverySvc,
+ FRPMulti: frpMultiSvc,
+ Traffic: trafficCollector,
+ StaticFS: staticFS,
+ StartedAt: startedAt,
+ })
+ server := &http.Server{
+ Addr: cfg.ListenAddr,
+ Handler: handler,
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ intervalMinutes, _ := settingsStore.GetInt(ctx, settings.KeyDDNSCheckInterval)
+ if intervalMinutes <= 0 {
+ intervalMinutes = 5
+ }
+ chinaHours, _ := settingsStore.GetInt(ctx, settings.KeyChinaCIDRUpdateHours)
+ if chinaHours <= 0 {
+ chinaHours = 24
+ }
+ sched := scheduler.New(
+ scheduler.Job{
+ Name: "ddns",
+ Interval: time.Duration(intervalMinutes) * time.Minute,
+ Run: ddnsSvc.Tick,
+ },
+ scheduler.Job{
+ Name: "acme",
+ Interval: 24 * time.Hour,
+ Run: acmeSvc.Tick,
+ },
+ scheduler.Job{
+ Name: "china_cidr",
+ Interval: time.Duration(chinaHours) * time.Hour,
+ Run: chinaCIDRSvc.Tick,
+ },
+ )
+ sched.Start(ctx)
+ trafficCollector.Start(ctx)
+
+ app := &App{
+ cfg: cfg,
+ logger: logger,
+ server: server,
+ nginx: nginxMgr,
+ frpMulti: frpMultiSvc,
+ scheduler: sched,
+ shutdownFn: cancel,
+ db: conn,
+ }
+
+ if err := app.bootstrapNginx(context.Background(), proxySvc); err != nil {
+ cancel()
+ conn.Close()
+ return nil, err
+ }
+ if err := frpMultiSvc.ReconcileRuntime(context.Background()); err != nil {
+ logger.Warn("initial frp runtime reconcile skipped", "module", "FRP", "error", err.Error())
+ }
+ if err := frpMultiSvc.AutoStart(context.Background()); err != nil {
+ logger.Warn("initial frp autostart skipped", "module", "FRP", "error", err.Error())
+ }
+
+ logger.Info("application started", "module", "SYSTEM", "listen", cfg.ListenAddr)
+ return app, nil
+}
+
+func (a *App) bootstrapNginx(ctx context.Context, proxySvc *service.ProxyService) error {
+ if err := proxySvc.ReloadAll(ctx); err != nil {
+ a.logger.Warn("initial nginx apply skipped", "module", "NGINX", "error", err.Error())
+ }
+ return nil
+}
+
+func (a *App) Run() error {
+ return a.server.ListenAndServe()
+}
+
+func (a *App) Shutdown(ctx context.Context) error {
+ a.logger.Info("application shutting down", "module", "SYSTEM")
+ if a.shutdownFn != nil {
+ a.shutdownFn()
+ }
+ if err := a.server.Shutdown(ctx); err != nil {
+ return err
+ }
+ if err := a.nginx.Stop(ctx); err != nil {
+ a.logger.Error("nginx stop failed", "module", "NGINX", "error", err.Error())
+ }
+ if a.frpMulti != nil {
+ if err := a.frpMulti.Stop(ctx); err != nil {
+ a.logger.Error("frpc stop failed", "module", "FRP", "error", err.Error())
+ }
+ }
+ if a.db != nil {
+ return a.db.Close()
+ }
+ return nil
+}
+
+func ResolveMigrationsDir() (string, error) {
+ candidates := []string{
+ "migrations",
+ filepath.Join("..", "migrations"),
+ filepath.Join("..", "..", "migrations"),
+ }
+ for _, c := range candidates {
+ if info, err := os.Stat(c); err == nil && info.IsDir() {
+ return c, nil
+ }
+ }
+ return "", fmt.Errorf("migrations directory not found")
+}
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
new file mode 100644
index 0000000..fd86b0e
--- /dev/null
+++ b/internal/auth/auth.go
@@ -0,0 +1,204 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/fonu/fonu/internal/validate"
+)
+
+const sessionTTL = 7 * 24 * time.Hour
+
+var ErrUnauthorized = errors.New("unauthorized")
+var ErrNotInitialized = errors.New("not initialized")
+
+type Service struct {
+ db *sql.DB
+}
+
+func New(db *sql.DB) *Service {
+ return &Service{db: db}
+}
+
+func (s *Service) IsInitialized(ctx context.Context) (bool, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM admins`).Scan(&count)
+ return count > 0, err
+}
+
+const defaultAdminUsername = "admin"
+
+func (s *Service) EnsureDefaultAdmin(ctx context.Context, log *slog.Logger) error {
+ initialized, err := s.IsInitialized(ctx)
+ if err != nil {
+ return err
+ }
+ if initialized {
+ return nil
+ }
+
+ password, err := generatePassword(16)
+ if err != nil {
+ return err
+ }
+ if err := s.Setup(ctx, defaultAdminUsername, password); err != nil {
+ return err
+ }
+
+ log.Info(
+ "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+ "module", "SYSTEM",
+ "username", defaultAdminUsername,
+ "password", password,
+ )
+ return nil
+}
+
+func (s *Service) Setup(ctx context.Context, username, password string) error {
+ if err := validate.Username(username); err != nil {
+ return err
+ }
+ if err := validate.Password(password); err != nil {
+ return err
+ }
+
+ initialized, err := s.IsInitialized(ctx)
+ if err != nil {
+ return err
+ }
+ if initialized {
+ return fmt.Errorf("管理员已初始化")
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+
+ _, err = s.db.ExecContext(ctx, `INSERT INTO admins(username, password_hash) VALUES (?, ?)`, username, string(hash))
+ return err
+}
+
+func (s *Service) Login(ctx context.Context, username, password string) (string, error) {
+ var adminID int
+ var hash string
+ err := s.db.QueryRowContext(ctx, `SELECT id, password_hash FROM admins WHERE username = ?`, username).Scan(&adminID, &hash)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", ErrUnauthorized
+ }
+ return "", err
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
+ return "", ErrUnauthorized
+ }
+ return s.createSession(ctx, adminID)
+}
+
+func (s *Service) AdminIDForSession(ctx context.Context, sessionID string) (int, error) {
+ if sessionID == "" {
+ return 0, ErrUnauthorized
+ }
+ var adminID int
+ err := s.db.QueryRowContext(ctx, `SELECT admin_id FROM sessions WHERE id = ?`, sessionID).Scan(&adminID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, ErrUnauthorized
+ }
+ return 0, err
+ }
+ return adminID, nil
+}
+
+func (s *Service) ChangePassword(ctx context.Context, adminID int, oldPassword, newPassword string) error {
+ if err := validate.Password(newPassword); err != nil {
+ return err
+ }
+ var hash string
+ err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM admins WHERE id = ?`, adminID).Scan(&hash)
+ if err != nil {
+ return err
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(oldPassword)); err != nil {
+ return fmt.Errorf("当前密码不正确")
+ }
+ newHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+ if err != nil {
+ return err
+ }
+ _, err = s.db.ExecContext(ctx, `UPDATE admins SET password_hash = ? WHERE id = ?`, string(newHash), adminID)
+ return err
+}
+
+func (s *Service) Logout(ctx context.Context, sessionID string) error {
+ _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
+ return err
+}
+
+func (s *Service) ValidateSession(ctx context.Context, sessionID string) error {
+ if sessionID == "" {
+ return ErrUnauthorized
+ }
+ var expiresAt string
+ err := s.db.QueryRowContext(ctx, `SELECT expires_at FROM sessions WHERE id = ?`, sessionID).Scan(&expiresAt)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrUnauthorized
+ }
+ return err
+ }
+ expires, err := time.Parse(time.RFC3339, expiresAt)
+ if err != nil {
+ return ErrUnauthorized
+ }
+ if time.Now().After(expires) {
+ _, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id = ?`, sessionID)
+ return ErrUnauthorized
+ }
+ return nil
+}
+
+func (s *Service) createSession(ctx context.Context, adminID int) (string, error) {
+ id, err := randomToken(32)
+ if err != nil {
+ return "", err
+ }
+ expires := time.Now().Add(sessionTTL).UTC().Format(time.RFC3339)
+ _, err = s.db.ExecContext(ctx, `INSERT INTO sessions(id, admin_id, expires_at) VALUES (?, ?, ?)`, id, adminID, expires)
+ if err != nil {
+ return "", err
+ }
+ return id, nil
+}
+
+func randomToken(n int) (string, error) {
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+func generatePassword(length int) (string, error) {
+ const letters = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+ if length < 8 {
+ length = 8
+ }
+ out := make([]byte, length)
+ randBytes := make([]byte, length)
+ if _, err := rand.Read(randBytes); err != nil {
+ return "", err
+ }
+ for i := range out {
+ out[i] = letters[int(randBytes[i])%len(letters)]
+ }
+ return string(out), nil
+}
diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go
new file mode 100644
index 0000000..78a322a
--- /dev/null
+++ b/internal/auth/auth_test.go
@@ -0,0 +1,41 @@
+package auth
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/db"
+)
+
+func TestEnsureDefaultAdminCreatesOnce(t *testing.T) {
+ conn, err := db.Open(filepath.Join(t.TempDir(), "fonu.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer conn.Close()
+
+ if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+ t.Fatalf("migrate: %v", err)
+ }
+
+ svc := New(conn)
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+ if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+ t.Fatalf("first bootstrap: %v", err)
+ }
+ initialized, err := svc.IsInitialized(context.Background())
+ if err != nil || !initialized {
+ t.Fatalf("expected initialized admin, got initialized=%v err=%v", initialized, err)
+ }
+
+ if err := svc.EnsureDefaultAdmin(context.Background(), logger); err != nil {
+ t.Fatalf("second bootstrap: %v", err)
+ }
+ if _, err := svc.Login(context.Background(), "admin", "wrong-password"); err != ErrUnauthorized {
+ t.Fatalf("expected unauthorized for wrong password, got %v", err)
+ }
+}
diff --git a/internal/backup/service.go b/internal/backup/service.go
new file mode 100644
index 0000000..78e2b7f
--- /dev/null
+++ b/internal/backup/service.go
@@ -0,0 +1,170 @@
+package backup
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type Service struct {
+ dataDir string
+}
+
+func New(dataDir string) *Service {
+ return &Service{dataDir: dataDir}
+}
+
+func (s *Service) Export(ctx context.Context) (string, error) {
+ tmpDir := filepath.Join(s.dataDir, "backups")
+ if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+ return "", err
+ }
+ name := fmt.Sprintf("fonu-backup-%s.tar.gz", time.Now().Format("20060102-His"))
+ outPath := filepath.Join(tmpDir, name)
+ file, err := os.Create(outPath)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ gw := gzip.NewWriter(file)
+ tw := tar.NewWriter(gw)
+
+ entries := []string{
+ "fonu.db",
+ "certs",
+ "nginx",
+ }
+ for _, entry := range entries {
+ if err := addToArchive(ctx, tw, s.dataDir, entry); err != nil {
+ _ = tw.Close()
+ _ = gw.Close()
+ _ = os.Remove(outPath)
+ return "", err
+ }
+ }
+
+ if err := tw.Close(); err != nil {
+ return "", err
+ }
+ if err := gw.Close(); err != nil {
+ return "", err
+ }
+ return outPath, nil
+}
+
+func (s *Service) Restore(ctx context.Context, archivePath string) error {
+ file, err := os.Open(archivePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ gr, err := gzip.NewReader(file)
+ if err != nil {
+ return fmt.Errorf("无效的备份文件")
+ }
+ defer gr.Close()
+
+ tr := tar.NewReader(gr)
+ for {
+ header, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeDir {
+ continue
+ }
+ target := filepath.Join(s.dataDir, header.Name)
+ if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
+ return fmt.Errorf("备份条目非法")
+ }
+ switch header.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, tr); err != nil {
+ out.Close()
+ return err
+ }
+ out.Close()
+ }
+ }
+ return nil
+}
+
+func addToArchive(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
+ path := filepath.Join(baseDir, rel)
+ info, err := os.Stat(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ if info.IsDir() {
+ return addDir(ctx, tw, baseDir, rel)
+ }
+ return addFile(tw, baseDir, rel, info)
+}
+
+func addDir(ctx context.Context, tw *tar.Writer, baseDir, rel string) error {
+ root := filepath.Join(baseDir, rel)
+ return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if strings.Contains(path, string(os.PathSeparator)+"backups"+string(os.PathSeparator)) {
+ return nil
+ }
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+ relPath, err := filepath.Rel(baseDir, path)
+ if err != nil {
+ return err
+ }
+ return addFile(tw, baseDir, relPath, info)
+ })
+}
+
+func addFile(tw *tar.Writer, baseDir, rel string, info os.FileInfo) error {
+ path := filepath.Join(baseDir, rel)
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ header, err := tar.FileInfoHeader(info, "")
+ if err != nil {
+ return err
+ }
+ header.Name = filepath.ToSlash(rel)
+ if err := tw.WriteHeader(header); err != nil {
+ return err
+ }
+ _, err = io.Copy(tw, file)
+ return err
+}
diff --git a/internal/certificate/import.go b/internal/certificate/import.go
new file mode 100644
index 0000000..7091497
--- /dev/null
+++ b/internal/certificate/import.go
@@ -0,0 +1,214 @@
+package certificate
+
+import (
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "time"
+)
+
+type ImportInfo struct {
+ Primary string
+ Domains []string
+ Wildcard bool
+ ExpiresAt time.Time
+}
+
+func InspectImport(certPEM, keyPEM []byte) (ImportInfo, error) {
+ cert, err := parseCertificate(certPEM)
+ if err != nil {
+ return ImportInfo{}, err
+ }
+ key, err := parsePrivateKey(keyPEM)
+ if err != nil {
+ return ImportInfo{}, err
+ }
+ if !publicKeysMatch(cert.PublicKey, key) {
+ return ImportInfo{}, fmt.Errorf("证书与私钥不匹配")
+ }
+ domains, err := ExtractCertDomains(cert)
+ if err != nil {
+ return ImportInfo{}, err
+ }
+ return ImportInfo{
+ Primary: primaryDomain(domains),
+ Domains: domains,
+ Wildcard: hasWildcardDomain(domains),
+ ExpiresAt: cert.NotAfter.UTC(),
+ }, nil
+}
+
+func ValidatePEM(certPEM, keyPEM []byte) (time.Time, error) {
+ info, err := InspectImport(certPEM, keyPEM)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return info.ExpiresAt, nil
+}
+
+func ExtractCertDomains(cert *x509.Certificate) ([]string, error) {
+ seen := map[string]bool{}
+ var domains []string
+ add := func(name string) error {
+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
+ if name == "" || net.ParseIP(name) != nil {
+ return nil
+ }
+ if seen[name] {
+ return nil
+ }
+ if strings.HasPrefix(name, "*.") {
+ if err := validateDomainLabel(strings.TrimPrefix(name, "*.")); err != nil {
+ return err
+ }
+ } else if err := validateDomainLabel(name); err != nil {
+ return err
+ }
+ seen[name] = true
+ domains = append(domains, name)
+ return nil
+ }
+
+ for _, name := range cert.DNSNames {
+ if err := add(name); err != nil {
+ return nil, err
+ }
+ }
+ if err := add(cert.Subject.CommonName); err != nil {
+ return nil, err
+ }
+ if len(domains) == 0 {
+ return nil, fmt.Errorf("证书中未包含有效域名")
+ }
+ return domains, nil
+}
+
+func primaryDomain(domains []string) string {
+ for _, domain := range domains {
+ if !strings.HasPrefix(domain, "*.") {
+ return domain
+ }
+ }
+ return strings.TrimPrefix(domains[0], "*.")
+}
+
+func hasWildcardDomain(domains []string) bool {
+ for _, domain := range domains {
+ if strings.HasPrefix(domain, "*.") {
+ return true
+ }
+ }
+ return false
+}
+
+func validateDomainLabel(domain string) error {
+ if domain == "" {
+ return fmt.Errorf("域名格式无效")
+ }
+ parts := strings.Split(domain, ".")
+ for _, part := range parts {
+ if part == "" || len(part) > 63 {
+ return fmt.Errorf("域名格式无效")
+ }
+ }
+ return nil
+}
+
+func parseCertificate(certPEM []byte) (*x509.Certificate, error) {
+ rest := certPEM
+ for {
+ block, remaining := pem.Decode(rest)
+ if block == nil {
+ return nil, fmt.Errorf("证书 PEM 格式无效")
+ }
+ if block.Type == "CERTIFICATE" {
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("解析证书失败:%w", err)
+ }
+ return cert, nil
+ }
+ rest = remaining
+ if len(rest) == 0 {
+ break
+ }
+ }
+ return nil, fmt.Errorf("证书 PEM 中未找到有效证书")
+}
+
+func parsePrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
+ block, _ := pem.Decode(keyPEM)
+ if block == nil {
+ return nil, fmt.Errorf("私钥 PEM 格式无效")
+ }
+ switch block.Type {
+ case "RSA PRIVATE KEY":
+ return x509.ParsePKCS1PrivateKey(block.Bytes)
+ case "EC PRIVATE KEY":
+ return x509.ParseECPrivateKey(block.Bytes)
+ case "PRIVATE KEY":
+ return x509.ParsePKCS8PrivateKey(block.Bytes)
+ default:
+ return nil, fmt.Errorf("不支持的私钥类型:%s", block.Type)
+ }
+}
+
+func publicKeysMatch(certKey crypto.PublicKey, privateKey crypto.PrivateKey) bool {
+ switch pub := certKey.(type) {
+ case *rsa.PublicKey:
+ priv, ok := privateKey.(*rsa.PrivateKey)
+ return ok && pub.N.Cmp(priv.N) == 0
+ case *ecdsa.PublicKey:
+ priv, ok := privateKey.(*ecdsa.PrivateKey)
+ return ok && pub.X.Cmp(priv.X) == 0 && pub.Y.Cmp(priv.Y) == 0
+ default:
+ return false
+ }
+}
+
+func NormalizePEM(v string) []byte {
+ return []byte(strings.TrimSpace(v))
+}
+
+func ResolvePEMSource(content, path string) ([]byte, error) {
+ path = strings.TrimSpace(path)
+ if path != "" {
+ return ReadPEMFile(path)
+ }
+ data := NormalizePEM(content)
+ if len(data) == 0 {
+ return nil, fmt.Errorf("内容不能为空")
+ }
+ return data, nil
+}
+
+func ReadPEMFile(path string) ([]byte, error) {
+ path = strings.TrimSpace(path)
+ if path == "" {
+ return nil, fmt.Errorf("文件路径不能为空")
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return nil, fmt.Errorf("无法读取文件 %s:%w", path, err)
+ }
+ if info.IsDir() {
+ return nil, fmt.Errorf("%s 是目录,不是文件", path)
+ }
+ if info.Size() > 1<<20 {
+ return nil, fmt.Errorf("文件过大(最大 1MB)")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("读取文件失败:%w", err)
+ }
+ if len(strings.TrimSpace(string(data))) == 0 {
+ return nil, fmt.Errorf("文件内容为空")
+ }
+ return data, nil
+}
diff --git a/internal/certificate/import_test.go b/internal/certificate/import_test.go
new file mode 100644
index 0000000..801b0a1
--- /dev/null
+++ b/internal/certificate/import_test.go
@@ -0,0 +1,118 @@
+package certificate
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestReadPEMFile(t *testing.T) {
+ certPEM, keyPEM := generateTestCert("example.com")
+ certPath := t.TempDir() + "/cert.pem"
+ keyPath := t.TempDir() + "/key.pem"
+ if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ readCert, err := ReadPEMFile(certPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(readCert) != string(certPEM) {
+ t.Fatal("cert content mismatch")
+ }
+
+ got, err := ResolvePEMSource("", certPath)
+ if err != nil || string(got) != string(certPEM) {
+ t.Fatalf("ResolvePEMSource path: %v", err)
+ }
+}
+
+func TestInspectImport(t *testing.T) {
+ certPEM, keyPEM := generateTestCertWithSANs([]string{"example.com", "*.example.com", "nas.example.com"})
+ info, err := InspectImport(certPEM, keyPEM)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Primary != "example.com" {
+ t.Fatalf("primary: %s", info.Primary)
+ }
+ if !info.Wildcard {
+ t.Fatal("expected wildcard")
+ }
+ if len(info.Domains) != 3 {
+ t.Fatalf("domains: %v", info.Domains)
+ }
+}
+
+func TestValidatePEM(t *testing.T) {
+ certPEM, keyPEM := generateTestCert("example.com")
+
+ expiresAt, err := ValidatePEM(certPEM, keyPEM)
+ if err != nil {
+ t.Fatalf("ValidatePEM: %v", err)
+ }
+ if expiresAt.Before(time.Now()) {
+ t.Fatalf("expected future expiry, got %v", expiresAt)
+ }
+
+ _, err = ValidatePEM(certPEM, []byte("not a key"))
+ if err == nil {
+ t.Fatal("expected error for invalid key")
+ }
+
+ _, wrongKeyPEM := generateTestCert("other.example.com")
+ _, err = ValidatePEM(certPEM, wrongKeyPEM)
+ if err == nil {
+ t.Fatal("expected mismatch error")
+ }
+}
+
+func generateTestCert(domain string) ([]byte, []byte) {
+ return generateTestCertWithSANs([]string{domain})
+}
+
+func generateTestCertWithSANs(domains []string) ([]byte, []byte) {
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ panic(err)
+ }
+ cn := domains[0]
+ if strings.HasPrefix(cn, "*.") {
+ cn = strings.TrimPrefix(cn, "*.")
+ }
+
+ template := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: cn},
+ DNSNames: domains,
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+ if err != nil {
+ panic(err)
+ }
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+ keyDER, err := x509.MarshalECPrivateKey(key)
+ if err != nil {
+ panic(err)
+ }
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+ return certPEM, keyPEM
+}
diff --git a/internal/certificate/store.go b/internal/certificate/store.go
new file mode 100644
index 0000000..890111e
--- /dev/null
+++ b/internal/certificate/store.go
@@ -0,0 +1,168 @@
+package certificate
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+type Record struct {
+ ID int64 `json:"id"`
+ Domain string `json:"domain"`
+ Domains []string `json:"domains,omitempty"`
+ Wildcard bool `json:"wildcard"`
+ ACMECA string `json:"acme_ca,omitempty"`
+ CertPath string `json:"cert_path,omitempty"`
+ KeyPath string `json:"key_path,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ LastRenewAt *time.Time `json:"last_renew_at,omitempty"`
+ Status string `json:"status"`
+ LastError string `json:"last_error,omitempty"`
+ DaysLeft int `json:"days_left"`
+}
+
+type Store struct {
+ db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) List(ctx context.Context) ([]Record, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
+ expires_at, last_renew_at, status, COALESCE(last_error, '')
+ FROM certificates ORDER BY id ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []Record
+ for rows.Next() {
+ rec, err := scanRecord(rows)
+ if err != nil {
+ return nil, err
+ }
+ records = append(records, rec)
+ }
+ return records, rows.Err()
+}
+
+func (s *Store) Upsert(ctx context.Context, domain string, wildcard bool, domainsJSON, acmeCA, certPath, keyPath string, expiresAt time.Time, status string, lastError string) (Record, error) {
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO certificates(domain, domains, wildcard, acme_ca, cert_path, key_path, expires_at, last_renew_at, status, last_error, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, datetime('now'))
+ ON CONFLICT(domain) DO UPDATE SET
+ domains = excluded.domains,
+ wildcard = excluded.wildcard,
+ acme_ca = excluded.acme_ca,
+ cert_path = excluded.cert_path,
+ key_path = excluded.key_path,
+ expires_at = excluded.expires_at,
+ last_renew_at = datetime('now'),
+ status = excluded.status,
+ last_error = excluded.last_error,
+ updated_at = datetime('now')
+ `, domain, domainsJSON, boolInt(wildcard), acmeCA, certPath, keyPath, expiresAt.UTC().Format(time.RFC3339), status, lastError)
+ if err != nil {
+ return Record{}, err
+ }
+ return s.GetByDomain(ctx, domain)
+}
+
+func (s *Store) UpdateStatus(ctx context.Context, domain, status, lastError string) error {
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE certificates SET status = ?, last_error = ?, updated_at = datetime('now') WHERE domain = ?
+ `, status, lastError, domain)
+ return err
+}
+
+func (s *Store) Delete(ctx context.Context, domain string) error {
+ res, err := s.db.ExecContext(ctx, `DELETE FROM certificates WHERE domain = ?`, domain)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("证书不存在")
+ }
+ return nil
+}
+
+func (s *Store) GetByDomain(ctx context.Context, domain string) (Record, error) {
+ row := s.db.QueryRowContext(ctx, `
+ SELECT id, domain, COALESCE(domains, ''), wildcard, COALESCE(acme_ca, ''), COALESCE(cert_path, ''), COALESCE(key_path, ''),
+ expires_at, last_renew_at, status, COALESCE(last_error, '')
+ FROM certificates WHERE domain = ?
+ `, domain)
+ rec, err := scanRecord(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Record{}, fmt.Errorf("证书不存在")
+ }
+ return rec, err
+}
+
+func scanRecord(row interface{ Scan(dest ...any) error }) (Record, error) {
+ var rec Record
+ var wildcard int
+ var domainsJSON string
+ var expiresAt, lastRenewAt sql.NullString
+ if err := row.Scan(&rec.ID, &rec.Domain, &domainsJSON, &wildcard, &rec.ACMECA, &rec.CertPath, &rec.KeyPath, &expiresAt, &lastRenewAt, &rec.Status, &rec.LastError); err != nil {
+ return Record{}, err
+ }
+ rec.Wildcard = wildcard == 1
+ rec.Domains = decodeDomains(domainsJSON, rec.Domain, rec.Wildcard)
+ if expiresAt.Valid {
+ t := parseTime(expiresAt.String)
+ rec.ExpiresAt = &t
+ rec.DaysLeft = int(time.Until(t).Hours() / 24)
+ }
+ if lastRenewAt.Valid {
+ t := parseTime(lastRenewAt.String)
+ rec.LastRenewAt = &t
+ }
+ return rec, nil
+}
+
+func boolInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
+
+func decodeDomains(raw string, domain string, wildcard bool) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ if wildcard {
+ return []string{domain, "*." + domain}
+ }
+ return []string{domain}
+ }
+ var domains []string
+ if err := json.Unmarshal([]byte(raw), &domains); err != nil || len(domains) == 0 {
+ if wildcard {
+ return []string{domain, "*." + domain}
+ }
+ return []string{domain}
+ }
+ return domains
+}
+
+func parseTime(v string) time.Time {
+ t, err := time.Parse(time.RFC3339, v)
+ if err != nil {
+ t, _ = time.Parse("2006-01-02 15:04:05", v)
+ }
+ return t.UTC()
+}
diff --git a/internal/chinacidr/download_test.go b/internal/chinacidr/download_test.go
new file mode 100644
index 0000000..367976b
--- /dev/null
+++ b/internal/chinacidr/download_test.go
@@ -0,0 +1,29 @@
+package chinacidr
+
+import (
+ "context"
+ "testing"
+)
+
+func TestDownloadDefaultV6Sources(t *testing.T) {
+ ctx := context.Background()
+ lines, err := downloadFromSources(ctx, defaultV6URLs...)
+ if err != nil {
+ t.Fatalf("download v6: %v", err)
+ }
+ if len(lines) < 10 {
+ t.Fatalf("expected at least 10 v6 entries, got %d", len(lines))
+ }
+}
+
+func TestGhfastProxyURL(t *testing.T) {
+ raw := "https://raw.githubusercontent.com/foo/bar.txt"
+ got := ghfastProxyURL(raw)
+ want := ghfastProxyPrefix + raw
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+ if ghfastProxyURL(want) != "" {
+ t.Fatal("expected no double proxy")
+ }
+}
diff --git a/internal/chinacidr/service.go b/internal/chinacidr/service.go
new file mode 100644
index 0000000..0af1c85
--- /dev/null
+++ b/internal/chinacidr/service.go
@@ -0,0 +1,332 @@
+package chinacidr
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/nginx"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+const (
+ defaultV4URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
+ ghfastProxyPrefix = "https://ghfast.top/"
+)
+
+// metowolf/iplist 已下线 CN/ipv6.txt;按优先级尝试可用源。
+var defaultV6URLs = []string{
+ "https://gaoyifan.github.io/china-operator-ip/china6.txt",
+ "https://raw.githubusercontent.com/gaoyifan/china-operator-ip/china6.txt",
+ "https://raw.githubusercontent.com/carrnot/china-ip-list/release/ipv6.txt",
+}
+
+type Service struct {
+ cfg config.Config
+ settings *settings.Store
+ nginxMgr *nginx.Manager
+ proxyStore *proxy.Store
+ logger *slog.Logger
+ updating atomic.Bool
+}
+
+type Status struct {
+ UpdatedAt string `json:"updated_at,omitempty"`
+ EntryCountV4 int `json:"entry_count_v4"`
+ EntryCountV6 int `json:"entry_count_v6"`
+ LastError string `json:"last_error,omitempty"`
+ Updating bool `json:"updating"`
+ Ready bool `json:"ready"`
+}
+
+func New(cfg config.Config, settingsStore *settings.Store, nginxMgr *nginx.Manager, proxyStore *proxy.Store, logger *slog.Logger) *Service {
+ return &Service{cfg: cfg, settings: settingsStore, nginxMgr: nginxMgr, proxyStore: proxyStore, logger: logger}
+}
+
+func (s *Service) Tick(ctx context.Context) {
+ if err := s.Update(ctx); err != nil {
+ s.logger.Warn("china cidr update failed", "module", "CHINA_CIDR", "error", err.Error())
+ s.RecordLastError(ctx, err)
+ }
+}
+
+func (s *Service) RecordLastError(ctx context.Context, err error) {
+ if err == nil {
+ return
+ }
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, err.Error())
+}
+
+func (s *Service) Update(ctx context.Context) error {
+ if !s.updating.CompareAndSwap(false, true) {
+ return fmt.Errorf("正在更新中,请稍后再试")
+ }
+ defer s.updating.Store(false)
+
+ v4URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV4)
+ customV6URL, _ := s.settings.Get(ctx, settings.KeyChinaCIDRSourceV6)
+ if isDeprecatedChinaCIDRSource(customV6URL) {
+ customV6URL = ""
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRSourceV6, "")
+ }
+
+ v4Lines, err := downloadFromSources(ctx, v4URL, defaultV4URL)
+ if err != nil {
+ return fmt.Errorf("下载 IPv4 CIDR 失败: %w", err)
+ }
+ v6Sources := append([]string{customV6URL}, defaultV6URLs...)
+ v6Lines, err := downloadFromSources(ctx, v6Sources...)
+ if err != nil {
+ return fmt.Errorf("下载 IPv6 CIDR 失败: %w", err)
+ }
+
+ body, v4Count, v6Count, err := buildCIDRFile(v4Lines, v6Lines)
+ if err != nil {
+ return err
+ }
+
+ tmpPath := s.cfg.ChinaCIDRTempPath()
+ if err := os.WriteFile(tmpPath, []byte(body), 0o644); err != nil {
+ return err
+ }
+
+ rules, err := s.proxyStore.ListEnabled(ctx)
+ if err != nil {
+ return err
+ }
+ opts := s.loadGenerateOptions(ctx)
+ validateOpts := opts
+ validateOpts.ChinaCIDRPathOverride = tmpPath
+ validateOpts.ChinaCIDRAvailable = true
+ if err := s.nginxMgr.ValidateOnlyWithOptions(ctx, rules, nil, validateOpts); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("nginx 校验失败: %w", err)
+ }
+
+ finalPath := s.cfg.ChinaCIDRPath()
+ if err := os.Rename(tmpPath, finalPath); err != nil {
+ return err
+ }
+
+ if _, err := s.nginxMgr.Apply(ctx, rules, nil); err != nil {
+ return fmt.Errorf("nginx 重载失败: %w", err)
+ }
+
+ now := time.Now().UTC().Format(time.RFC3339)
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRUpdatedAt, now)
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV4, fmt.Sprintf("%d", v4Count))
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRCountV6, fmt.Sprintf("%d", v6Count))
+ _ = s.settings.Set(ctx, settings.KeyChinaCIDRLastError, "")
+
+ s.logger.Info("china cidr updated", "module", "CHINA_CIDR", "v4", v4Count, "v6", v6Count)
+ return nil
+}
+
+func (s *Service) Status(ctx context.Context) Status {
+ updatedAt, _ := s.settings.Get(ctx, settings.KeyChinaCIDRUpdatedAt)
+ v4, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV4)
+ v6, _ := s.settings.GetInt(ctx, settings.KeyChinaCIDRCountV6)
+ lastErr, _ := s.settings.Get(ctx, settings.KeyChinaCIDRLastError)
+ ready := updatedAt != "" && lastErr == "" && v4 > 0 && v6 > 0
+ return Status{
+ UpdatedAt: updatedAt,
+ EntryCountV4: v4,
+ EntryCountV6: v6,
+ LastError: lastErr,
+ Updating: s.updating.Load(),
+ Ready: ready,
+ }
+}
+
+func isDeprecatedChinaCIDRSource(url string) bool {
+ url = strings.TrimSpace(url)
+ if url == "" {
+ return false
+ }
+ return strings.Contains(url, "metowolf/iplist") || strings.Contains(url, "iplist/meta")
+}
+
+func ghfastProxyURL(origin string) string {
+ origin = strings.TrimSpace(origin)
+ if origin == "" || strings.HasPrefix(origin, ghfastProxyPrefix) {
+ return ""
+ }
+ if !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
+ return ""
+ }
+ return ghfastProxyPrefix + origin
+}
+
+func expandDownloadSources(sources ...string) []string {
+ seen := make(map[string]struct{})
+ var out []string
+ add := func(url string) {
+ url = strings.TrimSpace(url)
+ if url == "" || isDeprecatedChinaCIDRSource(url) {
+ return
+ }
+ if _, ok := seen[url]; ok {
+ return
+ }
+ seen[url] = struct{}{}
+ out = append(out, url)
+ }
+ for _, raw := range sources {
+ add(raw)
+ if proxy := ghfastProxyURL(raw); proxy != "" {
+ add(proxy)
+ }
+ }
+ return out
+}
+
+func downloadFromSources(ctx context.Context, sources ...string) ([]string, error) {
+ var tried []string
+ var errs []string
+ for _, url := range expandDownloadSources(sources...) {
+ tried = append(tried, url)
+ lines, err := downloadLines(ctx, url)
+ if err == nil {
+ return lines, nil
+ }
+ errs = append(errs, fmt.Sprintf("%s (%v)", shortSourceURL(url), err))
+ }
+ if len(tried) == 0 {
+ return nil, fmt.Errorf("无可用的下载源")
+ }
+ return nil, fmt.Errorf("已尝试 %d 个源均失败: %s", len(tried), strings.Join(errs, "; "))
+}
+
+func shortSourceURL(url string) string {
+ if len(url) <= 72 {
+ return url
+ }
+ return url[:69] + "..."
+}
+
+func downloadLinesWithFallback(ctx context.Context, primary string, fallbacks ...string) ([]string, error) {
+ return downloadFromSources(ctx, append([]string{primary}, fallbacks...)...)
+}
+
+func downloadLines(ctx context.Context, url string) ([]string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
+ if err != nil {
+ return nil, err
+ }
+ var lines []string
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ lines = append(lines, line)
+ }
+ return lines, nil
+}
+
+func buildCIDRFile(v4Lines, v6Lines []string) (string, int, int, error) {
+ var b strings.Builder
+ v4Count := 0
+ v6Count := 0
+ for _, line := range v4Lines {
+ cidr, err := normalizeCIDR(line)
+ if err != nil {
+ continue
+ }
+ if cidrTo4(cidr) == nil {
+ continue
+ }
+ b.WriteString(cidr + " 1;\n")
+ v4Count++
+ }
+ for _, line := range v6Lines {
+ cidr, err := normalizeCIDR(line)
+ if err != nil {
+ continue
+ }
+ if cidrTo4(cidr) != nil {
+ continue
+ }
+ b.WriteString(cidr + " 1;\n")
+ v6Count++
+ }
+ if v4Count < 100 {
+ return "", 0, 0, fmt.Errorf("IPv4 条目过少 (%d),拒绝更新", v4Count)
+ }
+ if v6Count < 10 {
+ return "", 0, 0, fmt.Errorf("IPv6 条目过少 (%d),拒绝更新", v6Count)
+ }
+ return b.String(), v4Count, v6Count, nil
+}
+
+func normalizeCIDR(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if !strings.Contains(raw, "/") {
+ if ip := net.ParseIP(raw); ip != nil {
+ if v4 := ip.To4(); v4 != nil {
+ return v4.String() + "/32", nil
+ }
+ return ip.String() + "/128", nil
+ }
+ return "", fmt.Errorf("invalid")
+ }
+ _, ipNet, err := net.ParseCIDR(raw)
+ if err != nil {
+ return "", err
+ }
+ return ipNet.String(), nil
+}
+
+func cidrTo4(cidr string) net.IP {
+ ip, _, err := net.ParseCIDR(cidr)
+ if err != nil {
+ return nil
+ }
+ return ip.To4()
+}
+
+func (s *Service) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
+ opts := nginx.GenerateOptions{}
+ raw, _ := s.settings.Get(ctx, settings.KeyTrustedProxy)
+ opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
+ blRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
+ opts.GlobalIPBlacklist = ParseGlobalIPList(blRaw)
+ wlRaw, _ := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
+ opts.GlobalIPWhitelist = ParseGlobalIPList(wlRaw)
+ return opts
+}
+
+func ParseGlobalIPList(raw string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return nil
+ }
+ var list []string
+ if err := json.Unmarshal([]byte(raw), &list); err != nil {
+ return nil
+ }
+ return list
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..5279847
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,161 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+)
+
+type Config struct {
+ ListenAddr string
+ DataDir string
+ SessionSecret string
+ NginxBin string
+ NginxPIDFile string
+ NginxMimeTypes string
+ NginxDefaultHTTPPort int
+ NginxDefaultHTTPSPort int
+ FrpcBin string
+ FrpPIDFile string
+}
+
+func Load() Config {
+ dataDir := envOr("FONU_DATA_DIR", defaultDataDir())
+ if abs, err := filepath.Abs(dataDir); err == nil {
+ dataDir = abs
+ }
+ return Config{
+ ListenAddr: envOr("FONU_LISTEN", ":6893"),
+ DataDir: dataDir,
+ SessionSecret: envOr("FONU_SESSION_SECRET", "change-me-in-production"),
+ NginxBin: envOr("FONU_NGINX_BIN", "nginx"),
+ NginxPIDFile: envOr("FONU_NGINX_PID", dataDir+"/nginx/nginx.pid"),
+ NginxMimeTypes: envOr("FONU_NGINX_MIME_TYPES", defaultMimeTypes()),
+ NginxDefaultHTTPPort: envIntOr("FONU_NGINX_HTTP_PORT", 80),
+ NginxDefaultHTTPSPort: envIntOr("FONU_NGINX_HTTPS_PORT", 443),
+ FrpcBin: envOr("FONU_FRPC_BIN", "frpc"),
+ FrpPIDFile: envOr("FONU_FRP_PID", dataDir+"/frp/frpc.pid"),
+ }
+}
+
+func (c Config) DefaultListenPort(httpsEnabled bool) int {
+ if httpsEnabled {
+ return c.NginxDefaultHTTPSPort
+ }
+ return c.NginxDefaultHTTPPort
+}
+
+func (c Config) DBPath() string {
+ return c.DataDir + "/fonu.db"
+}
+
+func (c Config) NginxDir() string {
+ return c.DataDir + "/nginx"
+}
+
+func (c Config) NginxConfigPath() string {
+ return c.NginxDir() + "/nginx.conf"
+}
+
+func (c Config) NginxCustomDir() string {
+ return c.NginxDir() + "/custom"
+}
+
+func (c Config) NginxGlobalCustomPath() string {
+ return c.NginxCustomDir() + "/global.conf"
+}
+
+func (c Config) NginxRulesDir() string {
+ return c.NginxDir() + "/rules"
+}
+
+func (c Config) NginxRuleCustomPath(ruleID int64) string {
+ return filepath.Join(c.NginxRulesDir(), fmt.Sprintf("rule_%d.conf", ruleID))
+}
+
+func (c Config) NginxBackupsDir() string {
+ return c.NginxDir() + "/backups"
+}
+
+func (c Config) NginxGlobalBackupsDir() string {
+ return c.NginxBackupsDir() + "/global"
+}
+
+func (c Config) NginxRuleBackupsDir(ruleID int64) string {
+ return filepath.Join(c.NginxBackupsDir(), "rules", fmt.Sprintf("%d", ruleID))
+}
+
+func (c Config) LogsDir() string {
+ return c.DataDir + "/logs"
+}
+
+func (c Config) CertsDir() string {
+ return c.DataDir + "/certs"
+}
+
+func (c Config) ErrorsDir() string {
+ return c.NginxDir() + "/errors"
+}
+
+func (c Config) ChinaCIDRPath() string {
+ return c.NginxDir() + "/china_cidr.conf"
+}
+
+func (c Config) ChinaCIDRTempPath() string {
+ return c.NginxDir() + "/china_cidr.conf.tmp"
+}
+
+func (c Config) FrpDir() string {
+ return c.DataDir + "/frp"
+}
+
+func (c Config) FrpConfigPath() string {
+ return c.FrpDir() + "/frpc.toml"
+}
+
+func (c Config) FrpLogPath() string {
+ return filepath.Join(c.LogsDir(), "frpc.log")
+}
+
+func defaultDataDir() string {
+ if _, err := os.Stat("/data"); err == nil {
+ return "/data"
+ }
+ if cwd, err := os.Getwd(); err == nil {
+ return filepath.Join(cwd, ".data")
+ }
+ return "/data"
+}
+
+func envOr(key, fallback string) string {
+ if v := os.Getenv(key); v != "" {
+ return v
+ }
+ return fallback
+}
+
+func defaultMimeTypes() string {
+ candidates := []string{
+ "/etc/nginx/mime.types",
+ "deploy/nginx/mime.types",
+ }
+ for _, path := range candidates {
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ }
+ return "/etc/nginx/mime.types"
+}
+
+func envIntOr(key string, fallback int) int {
+ v := os.Getenv(key)
+ if v == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ return fallback
+ }
+ return n
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..54e38f7
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+ "os"
+ "testing"
+)
+
+func TestLoadNginxDefaultPorts(t *testing.T) {
+ t.Setenv("FONU_NGINX_HTTP_PORT", "18080")
+ t.Setenv("FONU_NGINX_HTTPS_PORT", "9443")
+
+ cfg := Load()
+ if cfg.NginxDefaultHTTPPort != 18080 {
+ t.Fatalf("http port %d", cfg.NginxDefaultHTTPPort)
+ }
+ if cfg.NginxDefaultHTTPSPort != 9443 {
+ t.Fatalf("https port %d", cfg.NginxDefaultHTTPSPort)
+ }
+ if cfg.DefaultListenPort(false) != 18080 {
+ t.Fatalf("default http listen %d", cfg.DefaultListenPort(false))
+ }
+ if cfg.DefaultListenPort(true) != 9443 {
+ t.Fatalf("default https listen %d", cfg.DefaultListenPort(true))
+ }
+}
+
+func TestLoadNginxDefaultPortsFallback(t *testing.T) {
+ os.Unsetenv("FONU_NGINX_HTTP_PORT")
+ os.Unsetenv("FONU_NGINX_HTTPS_PORT")
+
+ cfg := Load()
+ if cfg.NginxDefaultHTTPPort != 80 || cfg.NginxDefaultHTTPSPort != 443 {
+ t.Fatalf("ports %d %d", cfg.NginxDefaultHTTPPort, cfg.NginxDefaultHTTPSPort)
+ }
+}
diff --git a/internal/db/db.go b/internal/db/db.go
new file mode 100644
index 0000000..775beae
--- /dev/null
+++ b/internal/db/db.go
@@ -0,0 +1,113 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ _ "modernc.org/sqlite"
+)
+
+func Open(path string) (*sql.DB, error) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return nil, err
+ }
+
+ dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
+ conn, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, err
+ }
+ conn.SetMaxOpenConns(1)
+ return conn, nil
+}
+
+func Migrate(ctx context.Context, conn *sql.DB, migrationsDir string) error {
+ if err := ensureMigrationsTable(ctx, conn); err != nil {
+ return err
+ }
+
+ entries, err := os.ReadDir(migrationsDir)
+ if err != nil {
+ return err
+ }
+
+ var files []string
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
+ continue
+ }
+ files = append(files, e.Name())
+ }
+ sort.Strings(files)
+
+ for _, name := range files {
+ version, err := parseVersion(name)
+ if err != nil {
+ return err
+ }
+ applied, err := isApplied(ctx, conn, version)
+ if err != nil {
+ return err
+ }
+ if applied {
+ continue
+ }
+
+ content, err := os.ReadFile(filepath.Join(migrationsDir, name))
+ if err != nil {
+ return err
+ }
+
+ tx, err := conn.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+
+ if _, err := tx.ExecContext(ctx, string(content)); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("migration %s failed: %w", name, err)
+ }
+ if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES (?)`, version); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("record migration %s failed: %w", name, err)
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func ensureMigrationsTable(ctx context.Context, conn *sql.DB) error {
+ _, err := conn.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `)
+ return err
+}
+
+func parseVersion(name string) (int, error) {
+ parts := strings.SplitN(name, "_", 2)
+ if len(parts) == 0 {
+ return 0, fmt.Errorf("invalid migration filename: %s", name)
+ }
+ var version int
+ if _, err := fmt.Sscanf(parts[0], "%d", &version); err != nil {
+ return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
+ }
+ return version, nil
+}
+
+func isApplied(ctx context.Context, conn *sql.DB, version int) (bool, error) {
+ var count int
+ err := conn.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count)
+ return count > 0, err
+}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
new file mode 100644
index 0000000..2ac2197
--- /dev/null
+++ b/internal/db/db_test.go
@@ -0,0 +1,24 @@
+package db
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+)
+
+func TestMigrate(t *testing.T) {
+ dir := t.TempDir()
+ conn, err := Open(filepath.Join(dir, "fonu.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ defer conn.Close()
+
+ migrationsDir := filepath.Join("..", "..", "migrations")
+ if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
+ t.Fatalf("migrate failed: %v", err)
+ }
+ if err := Migrate(context.Background(), conn, migrationsDir); err != nil {
+ t.Fatalf("second migrate failed: %v", err)
+ }
+}
diff --git a/internal/ddns/alidns.go b/internal/ddns/alidns.go
new file mode 100644
index 0000000..76a89d3
--- /dev/null
+++ b/internal/ddns/alidns.go
@@ -0,0 +1,244 @@
+package ddns
+
+import (
+ "context"
+ "crypto/hmac"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+)
+
+const alidnsEndpoint = "https://alidns.aliyuncs.com/"
+
+type AliDNS struct {
+ client *http.Client
+}
+
+func NewAliDNS() *AliDNS {
+ return &AliDNS{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (a *AliDNS) Name() string { return "alidns" }
+
+func (a *AliDNS) Verify(ctx context.Context, cred Credentials) error {
+ _, err := a.request(ctx, cred, map[string]string{
+ "Action": "DescribeDomains",
+ "PageNumber": "1",
+ "PageSize": "1",
+ })
+ return err
+}
+
+func (a *AliDNS) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+ body, err := a.request(ctx, cred, map[string]string{
+ "Action": "DescribeDomains",
+ "KeyWord": zone,
+ "PageNumber": "1",
+ "PageSize": "50",
+ })
+ if err != nil {
+ ok, lookupErr := zoneLookupOK(err)
+ return ok, lookupErr
+ }
+ var resp alidnsDomainsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return false, err
+ }
+ zone = strings.ToLower(strings.TrimSpace(zone))
+ for _, item := range resp.Domains.Domain {
+ if strings.EqualFold(item.DomainName, zone) {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+func (a *AliDNS) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+ subDomain := aliRR(recordName)
+ body, err := a.request(ctx, cred, map[string]string{
+ "Action": "DescribeDomainRecords",
+ "DomainName": rootDomain,
+ "RRKeyWord": subDomain,
+ "Type": recordType,
+ "PageNumber": "1",
+ "PageSize": "50",
+ })
+ if err != nil {
+ return "", err
+ }
+ var resp alidnsRecordsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", err
+ }
+ for _, rec := range resp.DomainRecords.Record {
+ if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
+ return rec.Value, nil
+ }
+ }
+ return "", nil
+}
+
+func (a *AliDNS) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+ subDomain := aliRR(recordName)
+ existingID, err := a.findRecordID(ctx, cred, rootDomain, subDomain, recordType)
+ if err != nil {
+ return err
+ }
+ if existingID == "" {
+ _, err = a.request(ctx, cred, map[string]string{
+ "Action": "AddDomainRecord",
+ "DomainName": rootDomain,
+ "RR": subDomain,
+ "Type": recordType,
+ "Value": ip,
+ "TTL": "600",
+ })
+ return err
+ }
+ _, err = a.request(ctx, cred, map[string]string{
+ "Action": "UpdateDomainRecord",
+ "RecordId": existingID,
+ "RR": subDomain,
+ "Type": recordType,
+ "Value": ip,
+ "TTL": "600",
+ })
+ return err
+}
+
+func (a *AliDNS) findRecordID(ctx context.Context, cred Credentials, rootDomain, subDomain, recordType string) (string, error) {
+ body, err := a.request(ctx, cred, map[string]string{
+ "Action": "DescribeDomainRecords",
+ "DomainName": rootDomain,
+ "RRKeyWord": subDomain,
+ "Type": recordType,
+ "PageNumber": "1",
+ "PageSize": "50",
+ })
+ if err != nil {
+ return "", err
+ }
+ var resp alidnsRecordsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", err
+ }
+ for _, rec := range resp.DomainRecords.Record {
+ if strings.EqualFold(rec.Type, recordType) && rec.RR == subDomain {
+ return rec.RecordID, nil
+ }
+ }
+ return "", nil
+}
+
+func aliRR(recordName string) string {
+ subDomain := subDomainForRecord(recordName)
+ if subDomain == "@" {
+ return ""
+ }
+ return subDomain
+}
+
+func (a *AliDNS) request(ctx context.Context, cred Credentials, actionParams map[string]string) ([]byte, error) {
+ if cred.Token == "" || cred.Secret == "" {
+ return nil, fmt.Errorf("阿里云 DNS 凭证未配置")
+ }
+ query := map[string]string{
+ "Format": "JSON",
+ "Version": "2015-01-09",
+ "AccessKeyId": cred.Token,
+ "SignatureMethod": "HMAC-SHA1",
+ "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
+ "SignatureVersion": "1.0",
+ "SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()),
+ }
+ for k, v := range actionParams {
+ query[k] = v
+ }
+ query["Signature"] = signAliyun(query, cred.Secret)
+
+ values := url.Values{}
+ for k, v := range query {
+ values.Set(k, v)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, alidnsEndpoint+"?"+values.Encode(), nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := a.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ var envelope alidnsEnvelope
+ if err := json.Unmarshal(body, &envelope); err == nil && envelope.Code != "" {
+ return nil, fmt.Errorf("阿里云 DNS API 错误:%s", envelope.Message)
+ }
+ return body, nil
+}
+
+func signAliyun(params map[string]string, secret string) string {
+ keys := make([]string, 0, len(params))
+ for k := range params {
+ if k == "Signature" {
+ continue
+ }
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var canonical strings.Builder
+ for i, k := range keys {
+ if i > 0 {
+ canonical.WriteByte('&')
+ }
+ canonical.WriteString(percentEncode(k))
+ canonical.WriteByte('=')
+ canonical.WriteString(percentEncode(params[k]))
+ }
+ stringToSign := "GET&%2F&" + percentEncode(canonical.String())
+ mac := hmac.New(sha1.New, []byte(secret+"&"))
+ mac.Write([]byte(stringToSign))
+ return base64.StdEncoding.EncodeToString(mac.Sum(nil))
+}
+
+func percentEncode(s string) string {
+ encoded := url.QueryEscape(s)
+ encoded = strings.ReplaceAll(encoded, "+", "%20")
+ encoded = strings.ReplaceAll(encoded, "*", "%2A")
+ encoded = strings.ReplaceAll(encoded, "%7E", "~")
+ return encoded
+}
+
+type alidnsEnvelope struct {
+ Code string `json:"Code"`
+ Message string `json:"Message"`
+}
+
+type alidnsRecordsResponse struct {
+ DomainRecords struct {
+ Record []struct {
+ RecordID string `json:"RecordId"`
+ RR string `json:"RR"`
+ Type string `json:"Type"`
+ Value string `json:"Value"`
+ } `json:"Record"`
+ } `json:"DomainRecords"`
+}
+
+type alidnsDomainsResponse struct {
+ Domains struct {
+ Domain []struct {
+ DomainName string `json:"DomainName"`
+ } `json:"Domain"`
+ } `json:"Domains"`
+}
diff --git a/internal/ddns/cloudflare.go b/internal/ddns/cloudflare.go
new file mode 100644
index 0000000..4333df2
--- /dev/null
+++ b/internal/ddns/cloudflare.go
@@ -0,0 +1,164 @@
+package ddns
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+type Cloudflare struct {
+ client *http.Client
+}
+
+func NewCloudflare() *Cloudflare {
+ return &Cloudflare{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (c *Cloudflare) Name() string { return "cloudflare" }
+
+func (c *Cloudflare) Verify(ctx context.Context, cred Credentials) error {
+ _, err := c.request(ctx, cred.Token, http.MethodGet, "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
+ return err
+}
+
+func (c *Cloudflare) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+ _, err := c.zoneID(ctx, cred.Token, zone)
+ return zoneLookupOK(err)
+}
+
+func (c *Cloudflare) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+ zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
+ if err != nil {
+ return "", err
+ }
+ fqdn := fqdnForRecord(rootDomain, recordName)
+ body, err := c.request(ctx, cred.Token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
+ if err != nil {
+ return "", err
+ }
+ var resp cfRecordsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", err
+ }
+ if len(resp.Result) == 0 {
+ return "", nil
+ }
+ return resp.Result[0].Content, nil
+}
+
+func (c *Cloudflare) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+ zoneID, err := c.zoneID(ctx, cred.Token, rootDomain)
+ if err != nil {
+ return err
+ }
+ fqdn := fqdnForRecord(rootDomain, recordName)
+ existingID, _, err := c.findRecord(ctx, cred.Token, zoneID, recordType, fqdn)
+ if err != nil {
+ return err
+ }
+ payload := map[string]any{
+ "type": recordType,
+ "name": fqdn,
+ "content": ip,
+ "ttl": 120,
+ "proxied": false,
+ }
+ if existingID == "" {
+ _, err = c.request(ctx, cred.Token, http.MethodPost, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), payload)
+ return err
+ }
+ _, err = c.request(ctx, cred.Token, http.MethodPut, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, existingID), payload)
+ return err
+}
+
+func (c *Cloudflare) zoneID(ctx context.Context, token, rootDomain string) (string, error) {
+ body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", rootDomain), nil)
+ if err != nil {
+ return "", err
+ }
+ var resp cfZonesResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", err
+ }
+ if len(resp.Result) == 0 {
+ return "", fmt.Errorf("未找到域名 %s 对应的 Cloudflare Zone", rootDomain)
+ }
+ return resp.Result[0].ID, nil
+}
+
+func (c *Cloudflare) findRecord(ctx context.Context, token, zoneID, recordType, fqdn string) (string, string, error) {
+ body, err := c.request(ctx, token, http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, fqdn), nil)
+ if err != nil {
+ return "", "", err
+ }
+ var resp cfRecordsResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return "", "", err
+ }
+ if len(resp.Result) == 0 {
+ return "", "", nil
+ }
+ return resp.Result[0].ID, resp.Result[0].Content, nil
+}
+
+func (c *Cloudflare) request(ctx context.Context, token, method, url string, payload any) ([]byte, error) {
+ var body io.Reader
+ if payload != nil {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ body = bytes.NewReader(raw)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, url, body)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, err
+ }
+ var envelope cfEnvelope
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ return nil, err
+ }
+ if !envelope.Success {
+ if len(envelope.Errors) > 0 {
+ return nil, fmt.Errorf("Cloudflare API 错误:%s", envelope.Errors[0].Message)
+ }
+ return nil, fmt.Errorf("Cloudflare API 请求失败")
+ }
+ return raw, nil
+}
+
+type cfEnvelope struct {
+ Success bool `json:"success"`
+ Errors []struct {
+ Message string `json:"message"`
+ } `json:"errors"`
+}
+
+type cfZonesResponse struct {
+ Result []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ } `json:"result"`
+}
+
+type cfRecordsResponse struct {
+ Result []struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ } `json:"result"`
+}
diff --git a/internal/ddns/credentials.go b/internal/ddns/credentials.go
new file mode 100644
index 0000000..759d2f5
--- /dev/null
+++ b/internal/ddns/credentials.go
@@ -0,0 +1,96 @@
+package ddns
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+type Credentials struct {
+ Provider string `json:"provider,omitempty"`
+ Token string `json:"token,omitempty"`
+ TokenID string `json:"token_id,omitempty"`
+ Secret string `json:"secret,omitempty"`
+}
+
+func CredentialsFromSave(in SaveInput) Credentials {
+ provider := strings.TrimSpace(in.Provider)
+ if provider == "" {
+ provider = "cloudflare"
+ }
+ switch provider {
+ case "dnspod":
+ return Credentials{Provider: "dnspod", TokenID: strings.TrimSpace(in.APITokenID), Token: strings.TrimSpace(in.APIToken)}
+ case "alidns":
+ return Credentials{Provider: "alidns", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+ case "tencentcloud":
+ return Credentials{Provider: "tencentcloud", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+ case "volcengine":
+ return Credentials{Provider: "volcengine", Token: strings.TrimSpace(in.APIToken), Secret: strings.TrimSpace(in.APISecret)}
+ default:
+ return Credentials{Provider: "cloudflare", Token: strings.TrimSpace(in.APIToken)}
+ }
+}
+
+func (c Credentials) HasValues() bool {
+ switch c.Provider {
+ case "dnspod":
+ return c.TokenID != "" && c.Token != ""
+ case "alidns", "tencentcloud", "volcengine":
+ return c.Token != "" && c.Secret != ""
+ default:
+ return c.Token != ""
+ }
+}
+
+func (c Credentials) Validate(provider string) error {
+ switch provider {
+ case "dnspod":
+ if c.TokenID == "" || c.Token == "" {
+ return fmt.Errorf("请填写 DNSPod ID 和 Token")
+ }
+ case "alidns":
+ if c.Token == "" || c.Secret == "" {
+ return fmt.Errorf("请填写阿里云 AccessKey ID 和 Secret")
+ }
+ case "tencentcloud":
+ if c.Token == "" || c.Secret == "" {
+ return fmt.Errorf("请填写腾讯云 SecretId 和 SecretKey")
+ }
+ case "volcengine":
+ if c.Token == "" || c.Secret == "" {
+ return fmt.Errorf("请填写火山引擎 AccessKey ID 和 Secret Access Key")
+ }
+ default:
+ if c.Token == "" {
+ return fmt.Errorf("请填写 API Token")
+ }
+ }
+ return nil
+}
+
+func (c Credentials) LoginToken() string {
+ return c.TokenID + "," + c.Token
+}
+
+func (c Credentials) Marshal() ([]byte, error) {
+ return json.Marshal(c)
+}
+
+func ParseCredentials(raw string) (Credentials, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return Credentials{}, fmt.Errorf("凭证未配置")
+ }
+ if strings.HasPrefix(raw, "{") {
+ var cred Credentials
+ if err := json.Unmarshal([]byte(raw), &cred); err != nil {
+ return Credentials{}, err
+ }
+ if cred.Provider == "" {
+ cred.Provider = "cloudflare"
+ }
+ return cred, nil
+ }
+ return Credentials{Provider: "cloudflare", Token: raw}, nil
+}
diff --git a/internal/ddns/credentials_test.go b/internal/ddns/credentials_test.go
new file mode 100644
index 0000000..0f92f87
--- /dev/null
+++ b/internal/ddns/credentials_test.go
@@ -0,0 +1,29 @@
+package ddns
+
+import "testing"
+
+func TestParseCredentialsLegacyToken(t *testing.T) {
+ cred, err := ParseCredentials("cf-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cred.Provider != "cloudflare" || cred.Token != "cf-token" {
+ t.Fatalf("unexpected cred: %+v", cred)
+ }
+}
+
+func TestCredentialsLoginToken(t *testing.T) {
+ cred := Credentials{TokenID: "123", Token: "abc"}
+ if cred.LoginToken() != "123,abc" {
+ t.Fatal("login token mismatch")
+ }
+}
+
+func TestFqdnForRecord(t *testing.T) {
+ if fqdnForRecord("example.com", "*") != "*.example.com" {
+ t.Fatal("wildcard fqdn mismatch")
+ }
+ if fqdnForRecord("example.com", "@") != "example.com" {
+ t.Fatal("apex fqdn mismatch")
+ }
+}
diff --git a/internal/ddns/dnspod.go b/internal/ddns/dnspod.go
new file mode 100644
index 0000000..e93ba39
--- /dev/null
+++ b/internal/ddns/dnspod.go
@@ -0,0 +1,105 @@
+package ddns
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/nrdcg/dnspod-go"
+)
+
+type DNSPod struct {
+ client *http.Client
+}
+
+func NewDNSPod() *DNSPod {
+ return &DNSPod{client: &http.Client{Timeout: 15 * time.Second}}
+}
+
+func (d *DNSPod) Name() string { return "dnspod" }
+
+func (d *DNSPod) Verify(ctx context.Context, cred Credentials) error {
+ client := d.apiClient(cred)
+ _, _, err := client.Domains.List()
+ return err
+}
+
+func (d *DNSPod) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+ _, err := d.domainID(d.apiClient(cred), zone)
+ return zoneLookupOK(err)
+}
+
+func (d *DNSPod) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+ client := d.apiClient(cred)
+ domainID, err := d.domainID(client, rootDomain)
+ if err != nil {
+ return "", err
+ }
+ subDomain := subDomainForRecord(recordName)
+ records, _, err := client.Records.List(domainID, subDomain)
+ if err != nil {
+ return "", err
+ }
+ for _, rec := range records {
+ if strings.EqualFold(rec.Type, recordType) {
+ return rec.Value, nil
+ }
+ }
+ return "", nil
+}
+
+func (d *DNSPod) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+ client := d.apiClient(cred)
+ domainID, err := d.domainID(client, rootDomain)
+ if err != nil {
+ return err
+ }
+ subDomain := subDomainForRecord(recordName)
+ records, _, err := client.Records.List(domainID, subDomain)
+ if err != nil {
+ return err
+ }
+ for _, rec := range records {
+ if strings.EqualFold(rec.Type, recordType) {
+ _, _, err := client.Records.Update(domainID, rec.ID, dnspod.Record{
+ Type: recordType,
+ Name: subDomain,
+ Value: ip,
+ Line: rec.Line,
+ TTL: rec.TTL,
+ })
+ return err
+ }
+ }
+ _, _, err = client.Records.Create(domainID, dnspod.Record{
+ Type: recordType,
+ Name: subDomain,
+ Value: ip,
+ Line: "默认",
+ TTL: strconv.Itoa(120),
+ })
+ return err
+}
+
+func (d *DNSPod) domainID(client *dnspod.Client, rootDomain string) (string, error) {
+ domains, _, err := client.Domains.List()
+ if err != nil {
+ return "", err
+ }
+ for _, domain := range domains {
+ if domain.Name == rootDomain {
+ return domain.ID.String(), nil
+ }
+ }
+ return "", fmt.Errorf("未找到域名 %s 对应的 DNSPod 域名", rootDomain)
+}
+
+func (d *DNSPod) apiClient(cred Credentials) *dnspod.Client {
+ params := dnspod.CommonParams{LoginToken: cred.LoginToken(), Format: "json"}
+ client := dnspod.NewClient(params)
+ client.HTTPClient = d.client
+ return client
+}
diff --git a/internal/ddns/dnsutil.go b/internal/ddns/dnsutil.go
new file mode 100644
index 0000000..a6089d7
--- /dev/null
+++ b/internal/ddns/dnsutil.go
@@ -0,0 +1,25 @@
+package ddns
+
+import "strings"
+
+func fqdnForRecord(rootDomain, recordName string) string {
+ recordName = strings.TrimSpace(recordName)
+ if recordName == "" || recordName == "@" {
+ return rootDomain
+ }
+ if strings.Contains(recordName, ".") {
+ return recordName
+ }
+ if recordName == "*" {
+ return "*." + rootDomain
+ }
+ return recordName + "." + rootDomain
+}
+
+func subDomainForRecord(recordName string) string {
+ recordName = strings.TrimSpace(recordName)
+ if recordName == "" || recordName == "@" {
+ return "@"
+ }
+ return recordName
+}
diff --git a/internal/ddns/domain_records.go b/internal/ddns/domain_records.go
new file mode 100644
index 0000000..8799144
--- /dev/null
+++ b/internal/ddns/domain_records.go
@@ -0,0 +1,63 @@
+package ddns
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+type DomainRecord struct {
+ Domain string `json:"domain"`
+ IPv4 string `json:"ipv4,omitempty"`
+ IPv6 string `json:"ipv6,omitempty"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+}
+
+func EncodeDomainRecords(records []DomainRecord) string {
+ if len(records) == 0 {
+ return "[]"
+ }
+ b, err := json.Marshal(records)
+ if err != nil {
+ return "[]"
+ }
+ return string(b)
+}
+
+func DecodeDomainRecords(raw string) []DomainRecord {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "[]" {
+ return nil
+ }
+ var records []DomainRecord
+ if err := json.Unmarshal([]byte(raw), &records); err != nil {
+ return nil
+ }
+ return records
+}
+
+func BuildDomainRecords(cfg Config) []DomainRecord {
+ stored := cfg.DomainRecords
+ byDomain := make(map[string]DomainRecord, len(stored))
+ for _, rec := range stored {
+ byDomain[strings.ToLower(rec.Domain)] = rec
+ }
+
+ lines := cfg.DomainLines()
+ out := make([]DomainRecord, 0, len(lines))
+ for _, domain := range lines {
+ key := strings.ToLower(domain)
+ if rec, ok := byDomain[key]; ok {
+ rec.Domain = domain
+ out = append(out, rec)
+ continue
+ }
+ rec := DomainRecord{Domain: domain, Status: "unknown"}
+ if cfg.LastStatus != "" && cfg.LastStatus != "unknown" {
+ rec.Status = cfg.LastStatus
+ rec.Message = cfg.LastError
+ }
+ out = append(out, rec)
+ }
+ return out
+}
diff --git a/internal/ddns/managed_zones_test.go b/internal/ddns/managed_zones_test.go
new file mode 100644
index 0000000..614c6b8
--- /dev/null
+++ b/internal/ddns/managed_zones_test.go
@@ -0,0 +1,20 @@
+package ddns
+
+import "testing"
+
+func TestManagedDNSZonesMultiRoot(t *testing.T) {
+ cfg := Config{
+ RootDomain: "roven.cc",
+ RecordNames: []string{"roven.cc", "chiak.cc"},
+ }
+ zones := cfg.ManagedDNSZones()
+ if len(zones) != 2 {
+ t.Fatalf("expected 2 zones, got %v", zones)
+ }
+ if !cfg.CoversDomain("chiak.cc") || !cfg.CoversDomain("*.chiak.cc") {
+ t.Fatal("expected chiak.cc coverage")
+ }
+ if cfg.CoversDomain("other.com") {
+ t.Fatal("unexpected coverage")
+ }
+}
diff --git a/internal/ddns/provider.go b/internal/ddns/provider.go
new file mode 100644
index 0000000..a03f942
--- /dev/null
+++ b/internal/ddns/provider.go
@@ -0,0 +1,11 @@
+package ddns
+
+import "context"
+
+type Provider interface {
+ Name() string
+ Verify(ctx context.Context, cred Credentials) error
+ HasZone(ctx context.Context, cred Credentials, zone string) (bool, error)
+ UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error
+ GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error)
+}
diff --git a/internal/ddns/records.go b/internal/ddns/records.go
new file mode 100644
index 0000000..f47fc00
--- /dev/null
+++ b/internal/ddns/records.go
@@ -0,0 +1,319 @@
+package ddns
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+func ValidateDomainFormat(domain string) error {
+ domain = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(domain, ".")))
+ if domain == "" {
+ return fmt.Errorf("域名不能为空")
+ }
+ if strings.HasPrefix(domain, "*.") {
+ domain = strings.TrimPrefix(domain, "*.")
+ }
+ parts := strings.Split(domain, ".")
+ if len(parts) < 2 {
+ return fmt.Errorf("请填写完整域名,例如 s.example.com")
+ }
+ for _, part := range parts {
+ if part == "" {
+ return fmt.Errorf("域名格式无效")
+ }
+ if len(part) > 63 {
+ return fmt.Errorf("域名标签过长")
+ }
+ for i := 0; i < len(part); i++ {
+ c := part[i]
+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
+ return fmt.Errorf("域名包含非法字符")
+ }
+ }
+ if part[0] == '-' || part[len(part)-1] == '-' {
+ return fmt.Errorf("域名标签不能以连字符开头或结尾")
+ }
+ }
+ return nil
+}
+
+func FQDNsFromSaveInput(in SaveInput) ([]string, error) {
+ var names []string
+ var err error
+ if len(in.Domains) > 0 {
+ _, names, err = ParseDomainLines(in.Domains)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ root := strings.ToLower(strings.TrimSpace(in.RootDomain))
+ if root == "" {
+ return nil, fmt.Errorf("至少需要一个域名")
+ }
+ recordNames, err := ParseRecordNames(in.RecordNames, in.RecordName)
+ if err != nil {
+ return nil, err
+ }
+ names = FormatDomainLines(root, recordNames)
+ }
+ for _, name := range names {
+ if err := ValidateDomainFormat(name); err != nil {
+ return nil, fmt.Errorf("%s:%s", name, err.Error())
+ }
+ }
+ return names, nil
+}
+
+func ParseDomainLines(lines []string) (rootDomain string, recordNames []string, err error) {
+ seen := make(map[string]struct{})
+ names := make([]string, 0, len(lines))
+ for _, line := range lines {
+ root, record, lineErr := parseDomainLine(line)
+ if lineErr != nil {
+ return "", nil, lineErr
+ }
+ if root == "" {
+ continue
+ }
+ fqdn := FormatDomainLine(root, record)
+ if _, ok := seen[fqdn]; ok {
+ return "", nil, fmt.Errorf("域名 %s 重复", fqdn)
+ }
+ seen[fqdn] = struct{}{}
+ if rootDomain == "" {
+ rootDomain = root
+ }
+ names = append(names, fqdn)
+ }
+ if len(names) == 0 {
+ return "", nil, fmt.Errorf("至少需要一个域名")
+ }
+ return rootDomain, names, nil
+}
+
+func ResolveDomainTarget(configRoot, name string) (rootDomain, recordName string) {
+ name = strings.TrimSpace(strings.ToLower(name))
+ if strings.Contains(name, ".") {
+ if root, record, err := parseDomainLine(name); err == nil {
+ return root, record
+ }
+ }
+ return strings.ToLower(strings.TrimSpace(configRoot)), name
+}
+
+func parseDomainLine(line string) (rootDomain, recordName string, err error) {
+ line = strings.TrimSpace(strings.ToLower(line))
+ if line == "" {
+ return "", "", nil
+ }
+ if idx := strings.Index(line, ":"); idx > 0 {
+ host := line[:idx]
+ if strings.Count(host, ":") <= 1 {
+ line = host
+ }
+ }
+ line = strings.TrimSuffix(line, ".")
+ if strings.HasPrefix(line, "*.") {
+ root := strings.TrimPrefix(line, "*.")
+ if root == "" || !strings.Contains(root, ".") {
+ return "", "", fmt.Errorf("域名格式无效: %s", line)
+ }
+ return root, "*", nil
+ }
+ parts := strings.Split(line, ".")
+ if len(parts) < 2 {
+ return "", "", fmt.Errorf("域名格式无效: %s", line)
+ }
+ rootDomain = parts[len(parts)-2] + "." + parts[len(parts)-1]
+ if len(parts) == 2 {
+ return rootDomain, "@", nil
+ }
+ recordName = strings.Join(parts[:len(parts)-2], ".")
+ return rootDomain, recordName, nil
+}
+
+func FormatDomainLine(rootDomain, recordName string) string {
+ recordName = strings.TrimSpace(strings.ToLower(recordName))
+ rootDomain = strings.TrimSpace(strings.ToLower(rootDomain))
+ if strings.Contains(recordName, ".") {
+ parts := strings.Split(recordName, ".")
+ if len(parts) >= 3 {
+ if root, record, err := parseDomainLine(recordName); err == nil {
+ return joinDomainLine(root, record)
+ }
+ }
+ if len(parts) == 2 {
+ if root, record, err := parseDomainLine(recordName); err == nil && record == "@" && recordName == root {
+ if rootDomain == "" || rootDomain == root {
+ return recordName
+ }
+ }
+ }
+ }
+ return joinDomainLine(rootDomain, recordName)
+}
+
+// canonicalFQDN returns name when it is already a complete FQDN (e.g. api.nas.example.com).
+func canonicalFQDN(name string) string {
+ name = strings.TrimSpace(strings.ToLower(strings.TrimSuffix(name, ".")))
+ if name == "" || name == "@" || !strings.Contains(name, ".") {
+ return ""
+ }
+ root, record, err := parseDomainLine(name)
+ if err != nil {
+ return name
+ }
+ fqdn := joinDomainLine(root, record)
+ if fqdn == name {
+ return name
+ }
+ return ""
+}
+
+func joinDomainLine(rootDomain, recordName string) string {
+ if recordName == "" || recordName == "@" {
+ return rootDomain
+ }
+ if recordName == "*" {
+ return "*." + rootDomain
+ }
+ return recordName + "." + rootDomain
+}
+
+func FormatDomainLines(rootDomain string, recordNames []string) []string {
+ out := make([]string, 0, len(recordNames))
+ for _, name := range recordNames {
+ if fqdn := canonicalFQDN(name); fqdn != "" {
+ out = append(out, fqdn)
+ continue
+ }
+ root, record := ResolveDomainTarget(rootDomain, name)
+ out = append(out, FormatDomainLine(root, record))
+ }
+ return out
+}
+
+func ParseRecordNames(recordNames []string, legacy string) ([]string, error) {
+ raw := recordNames
+ if len(raw) == 0 && strings.TrimSpace(legacy) != "" {
+ raw = strings.Split(legacy, "\n")
+ }
+ if len(raw) == 0 {
+ return nil, fmt.Errorf("至少需要一个子域名")
+ }
+
+ seen := make(map[string]struct{})
+ out := make([]string, 0, len(raw))
+ for _, item := range raw {
+ name := normalizeRecordName(item)
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ return nil, fmt.Errorf("子域名 %s 重复", name)
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ if len(out) == 0 {
+ return nil, fmt.Errorf("至少需要一个子域名")
+ }
+ return out, nil
+}
+
+func normalizeRecordName(raw string) string {
+ name := strings.TrimSpace(strings.ToLower(raw))
+ name = strings.TrimSuffix(name, ".")
+ if name == "" || name == "@" {
+ return "@"
+ }
+ if strings.HasPrefix(name, "*.") {
+ root := strings.TrimPrefix(name, "*.")
+ if root != "" {
+ return FormatDomainLine(root, "*")
+ }
+ return "*"
+ }
+ if strings.Contains(name, ".") {
+ root, record, err := parseDomainLine(name)
+ if err == nil {
+ return FormatDomainLine(root, record)
+ }
+ }
+ return name
+}
+
+func EncodeRecordNames(names []string) string {
+ if len(names) == 0 {
+ return "[]"
+ }
+ b, err := json.Marshal(names)
+ if err != nil {
+ return "[]"
+ }
+ return string(b)
+}
+
+func DecodeRecordNames(raw string, fallback string) []string {
+ raw = strings.TrimSpace(raw)
+ if raw != "" && raw != "[]" {
+ var names []string
+ if err := json.Unmarshal([]byte(raw), &names); err == nil && len(names) > 0 {
+ return names
+ }
+ }
+ if strings.TrimSpace(fallback) != "" {
+ return []string{normalizeRecordName(fallback)}
+ }
+ return nil
+}
+
+func (c Config) RecordNamesList() []string {
+ if len(c.RecordNames) > 0 {
+ return c.RecordNames
+ }
+ if names := DecodeRecordNames("", c.RecordName); len(names) > 0 {
+ return names
+ }
+ return []string{"@"}
+}
+
+func (c Config) DomainLines() []string {
+ return FormatDomainLines(c.RootDomain, c.RecordNamesList())
+}
+
+// ManagedDNSZones returns apex zones this DDNS credential can manage (for ACME DNS-01).
+func (c Config) ManagedDNSZones() []string {
+ seen := map[string]bool{}
+ var zones []string
+ add := func(zone string) {
+ zone = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(zone, "*.")))
+ if zone == "" || zone == "@" || seen[zone] {
+ return
+ }
+ seen[zone] = true
+ zones = append(zones, zone)
+ }
+ add(c.RootDomain)
+ for _, name := range c.RecordNamesList() {
+ fqdn := FQDNFromRecord(c, name)
+ add(fqdn)
+ root, record := ResolveDomainTarget(c.RootDomain, name)
+ add(root)
+ if record != "@" && record != "*" {
+ add(FormatDomainLine(root, record))
+ }
+ }
+ return zones
+}
+
+func (c Config) CoversDomain(domain string) bool {
+ domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+ for _, zone := range c.ManagedDNSZones() {
+ if domain == zone || strings.HasSuffix(domain, "."+zone) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/ddns/records_test.go b/internal/ddns/records_test.go
new file mode 100644
index 0000000..012ab76
--- /dev/null
+++ b/internal/ddns/records_test.go
@@ -0,0 +1,131 @@
+package ddns
+
+import "testing"
+
+func TestParseDomainLinesMultiple(t *testing.T) {
+ root, names, err := ParseDomainLines([]string{"s.example.com", "www.example.com", "example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if root != "example.com" || len(names) != 3 || names[0] != "s.example.com" || names[1] != "www.example.com" || names[2] != "example.com" {
+ t.Fatalf("unexpected: root=%s names=%v", root, names)
+ }
+}
+
+func TestParseDomainLinesAllowsMixedRoots(t *testing.T) {
+ root, names, err := ParseDomainLines([]string{"s.example.com", "api.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if root != "example.com" || len(names) != 2 || names[1] != "api.example.com" {
+ t.Fatalf("unexpected: root=%s names=%v", root, names)
+ }
+}
+
+func TestParseDomainLinesWildcard(t *testing.T) {
+ root, names, err := ParseDomainLines([]string{"*.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if root != "example.com" || names[0] != "*.example.com" {
+ t.Fatalf("unexpected: root=%s names=%v", root, names)
+ }
+}
+
+func TestFormatDomainLine(t *testing.T) {
+ if FormatDomainLine("example.com", "s") != "s.example.com" {
+ t.Fatal("subdomain format failed")
+ }
+ if FormatDomainLine("example.com", "@") != "example.com" {
+ t.Fatal("apex format failed")
+ }
+}
+
+func TestParseRecordNamesRejectsDuplicate(t *testing.T) {
+ _, err := ParseRecordNames([]string{"s", "s"}, "")
+ if err == nil {
+ t.Fatal("expected duplicate error")
+ }
+}
+
+func TestParseRecordNamesKeepsDelegatedSubdomainFQDN(t *testing.T) {
+ names, err := ParseRecordNames([]string{"s", "www.sub.example.com"}, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names[0] != "s" || names[1] != "www.sub.example.com" {
+ t.Fatalf("unexpected names: %v", names)
+ }
+}
+
+func TestDomainLinesMixedRootDomains(t *testing.T) {
+ cfg := Config{
+ RootDomain: "example.com",
+ RecordNames: []string{"s", "www.sub.example.com"},
+ }
+ lines := cfg.DomainLines()
+ if len(lines) != 2 || lines[0] != "s.example.com" || lines[1] != "www.sub.example.com" {
+ t.Fatalf("unexpected lines: %v", lines)
+ }
+}
+
+func TestFQDNFromRecordDelegatedSubdomainHost(t *testing.T) {
+ cfg := Config{RootDomain: "example.com"}
+ if got := FQDNFromRecord(cfg, "www.sub.example.com"); got != "www.sub.example.com" {
+ t.Fatalf("expected www.sub.example.com, got %s", got)
+ }
+}
+
+func TestFormatDomainLineMultiLabelHost(t *testing.T) {
+ if got := FormatDomainLine("example.com", "api.nas"); got != "api.nas.example.com" {
+ t.Fatalf("expected api.nas.example.com, got %s", got)
+ }
+}
+
+func TestParseDomainLinesDeepSubdomain(t *testing.T) {
+ _, names, err := ParseDomainLines([]string{"api.nas.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if names[0] != "api.nas.example.com" {
+ t.Fatalf("expected full fqdn, got %s", names[0])
+ }
+}
+
+func TestDomainLinesPreservesDeepSubdomain(t *testing.T) {
+ cfg := Config{
+ RootDomain: "example.com",
+ RecordNames: []string{"s.example.com", "api.nas.example.com"},
+ }
+ lines := cfg.DomainLines()
+ if len(lines) != 2 || lines[1] != "api.nas.example.com" {
+ t.Fatalf("unexpected lines: %v", lines)
+ }
+}
+
+func TestValidateDomainFormat(t *testing.T) {
+ if err := ValidateDomainFormat("s.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateDomainFormat("www.sub.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if ValidateDomainFormat("s") == nil {
+ t.Fatal("expected error for short domain")
+ }
+ if ValidateDomainFormat("bad_label-.com") == nil {
+ t.Fatal("expected error for invalid label")
+ }
+}
+
+func TestFQDNsFromSaveInput(t *testing.T) {
+ names, err := FQDNsFromSaveInput(SaveInput{
+ Domains: []string{"s.example.com", "www.sub.example.com"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) != 2 || names[1] != "www.sub.example.com" {
+ t.Fatalf("unexpected names: %v", names)
+ }
+}
diff --git a/internal/ddns/service.go b/internal/ddns/service.go
new file mode 100644
index 0000000..c044981
--- /dev/null
+++ b/internal/ddns/service.go
@@ -0,0 +1,709 @@
+package ddns
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/publicip"
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+ store *Store
+ settings *settings.Store
+ secretBox *secret.Box
+ providers map[string]Provider
+ logger *slog.Logger
+ notify *notify.Service
+}
+
+func NewService(store *Store, settings *settings.Store, secretBox *secret.Box, logger *slog.Logger, notifySvc *notify.Service) *Service {
+ return &Service{
+ store: store,
+ settings: settings,
+ secretBox: secretBox,
+ providers: map[string]Provider{
+ "cloudflare": NewCloudflare(),
+ "dnspod": NewDNSPod(),
+ "alidns": NewAliDNS(),
+ "tencentcloud": NewTencentCloud(),
+ "volcengine": NewVolcengine(),
+ },
+ logger: logger.With("module", "DDNS"),
+ notify: notifySvc,
+ }
+}
+
+func (s *Service) List(ctx context.Context) ([]Config, error) {
+ configs, err := s.store.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for i := range configs {
+ s.enrichDomainRecords(ctx, &configs[i])
+ sanitizeConfig(&configs[i])
+ }
+ return configs, nil
+}
+
+func (s *Service) ListLite(ctx context.Context) ([]Config, error) {
+ configs, err := s.store.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for i := range configs {
+ if len(configs[i].DomainRecords) == 0 {
+ configs[i].DomainRecords = BuildDomainRecords(configs[i])
+ }
+ sanitizeConfig(&configs[i])
+ }
+ return configs, nil
+}
+
+func (s *Service) Get(ctx context.Context, id int64) (Config, error) {
+ cfg, err := s.store.GetByID(ctx, id)
+ if err != nil {
+ return Config{}, err
+ }
+ s.enrichDomainRecords(ctx, &cfg)
+ sanitizeConfig(&cfg)
+ return cfg, nil
+}
+
+func (s *Service) CredentialsForDomain(ctx context.Context, rootDomain string) (string, Credentials, error) {
+ cfg, err := s.store.GetByRootDomain(ctx, rootDomain)
+ if err != nil {
+ return "", Credentials{}, err
+ }
+ return s.credentialsForConfig(ctx, cfg)
+}
+
+func (s *Service) CredentialsForConfigID(ctx context.Context, id int64) (Config, string, Credentials, error) {
+ cfg, err := s.Get(ctx, id)
+ if err != nil {
+ return Config{}, "", Credentials{}, fmt.Errorf("未找到所选 DNS 配置")
+ }
+ provider, cred, err := s.credentialsForConfig(ctx, cfg)
+ if err != nil {
+ return Config{}, "", Credentials{}, err
+ }
+ return cfg, provider, cred, nil
+}
+
+func (s *Service) ConfigForDNSZone(ctx context.Context, dnsZone string) (Config, error) {
+ cfg, err := s.store.GetByRootDomain(ctx, dnsZone)
+ if err != nil {
+ return Config{}, err
+ }
+ if err := requireEnabled(cfg); err != nil {
+ return Config{}, err
+ }
+ return cfg, nil
+}
+
+func (s *Service) ConfigForAnyDomain(ctx context.Context, domain string) (Config, error) {
+ domain = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(domain, "*.")))
+ configs, err := s.store.List(ctx)
+ if err != nil {
+ return Config{}, err
+ }
+ for _, cfg := range configs {
+ if !cfg.Enabled {
+ continue
+ }
+ if cfg.CoversDomain(domain) {
+ return cfg, nil
+ }
+ }
+ return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", domain)
+}
+
+func (s *Service) CredentialsForAnyDomain(ctx context.Context, domain string) (string, Credentials, error) {
+ cfg, err := s.ConfigForAnyDomain(ctx, domain)
+ if err != nil {
+ return "", Credentials{}, fmt.Errorf("请先在 DDNS 页面配置域名 %s 的 DNS 凭证", strings.TrimPrefix(domain, "*."))
+ }
+ return s.credentialsForConfig(ctx, cfg)
+}
+
+func requireEnabled(cfg Config) error {
+ if !cfg.Enabled {
+ return fmt.Errorf("DNS 任务 %s 未启用,请先在 DDNS 页面开启", cfg.RootDomain)
+ }
+ return nil
+}
+
+func (s *Service) credentialsForConfig(ctx context.Context, cfg Config) (string, Credentials, error) {
+ if err := requireEnabled(cfg); err != nil {
+ return cfg.Provider, Credentials{}, err
+ }
+ cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+ if err != nil {
+ return cfg.Provider, Credentials{}, err
+ }
+ cred.Provider = cfg.Provider
+ return cfg.Provider, cred, nil
+}
+
+func (s *Service) Create(ctx context.Context, in SaveInput) (Config, error) {
+ if err := validateSaveInput(in); err != nil {
+ return Config{}, err
+ }
+ if !in.HasCredentialUpdate() {
+ return Config{}, fmt.Errorf("请填写 DNS API 凭证")
+ }
+ tokenEnc, err := s.encryptCredentials(in)
+ if err != nil {
+ return Config{}, err
+ }
+ return s.store.Create(ctx, in, tokenEnc)
+}
+
+func (s *Service) Update(ctx context.Context, id int64, in SaveInput) (Config, error) {
+ if err := validateSaveInput(in); err != nil {
+ return Config{}, err
+ }
+ existing, err := s.store.GetByID(ctx, id)
+ if err != nil {
+ return Config{}, err
+ }
+ tokenEnc := ""
+ updateToken := in.HasCredentialUpdate()
+ if updateToken {
+ tokenEnc, err = s.encryptCredentials(in)
+ if err != nil {
+ return Config{}, err
+ }
+ } else if !existing.HasToken {
+ return Config{}, fmt.Errorf("请填写 DNS API 凭证")
+ }
+ return s.store.Update(ctx, id, in, tokenEnc, updateToken)
+}
+
+func (s *Service) Delete(ctx context.Context, id int64) error {
+ return s.store.Delete(ctx, id)
+}
+
+type TestInput struct {
+ ConfigID int64
+ Provider string
+ APIToken string
+ APITokenID string
+ APISecret string
+}
+
+func (s *Service) Test(ctx context.Context, in TestInput) error {
+ providerName := strings.TrimSpace(in.Provider)
+ cred := CredentialsFromSave(SaveInput{
+ Provider: providerName,
+ APIToken: in.APIToken,
+ APITokenID: in.APITokenID,
+ APISecret: in.APISecret,
+ })
+
+ if cred.HasValues() {
+ if providerName == "" {
+ providerName = "cloudflare"
+ }
+ cred.Provider = providerName
+ return s.providerFor(providerName).Verify(ctx, cred)
+ }
+
+ if in.ConfigID > 0 {
+ cfg, err := s.store.GetByID(ctx, in.ConfigID)
+ if err != nil {
+ return err
+ }
+ cred, err = s.loadCredentialsByID(ctx, in.ConfigID)
+ if err != nil {
+ return err
+ }
+ cred.Provider = cfg.Provider
+ return s.providerFor(cfg.Provider).Verify(ctx, cred)
+ }
+
+ return fmt.Errorf("请填写 DNS API 凭证")
+}
+
+func (s *Service) UpdateNow(ctx context.Context, id int64) (Config, error) {
+ cfg, err := s.store.GetByID(ctx, id)
+ if err != nil {
+ return Config{}, err
+ }
+ if !cfg.Enabled {
+ return Config{}, fmt.Errorf("该 DDNS 配置未启用")
+ }
+ if err := s.runUpdate(ctx, cfg); err != nil {
+ return Config{}, err
+ }
+ return s.Get(ctx, id)
+}
+
+func (s *Service) UpdateAll(ctx context.Context) ([]Config, error) {
+ configs, err := s.store.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var lastErr error
+ var failCount int
+ var runCount int
+ for _, cfg := range configs {
+ if !cfg.Enabled {
+ continue
+ }
+ runCount++
+ if err := s.runUpdate(ctx, cfg); err != nil {
+ lastErr = err
+ failCount++
+ s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
+ }
+ }
+ list, listErr := s.List(ctx)
+ if listErr != nil {
+ return nil, listErr
+ }
+ if failCount > 0 && failCount == runCount {
+ return list, lastErr
+ }
+ return list, nil
+}
+
+func (s *Service) Tick(ctx context.Context) {
+ configs, err := s.store.List(ctx)
+ if err != nil {
+ return
+ }
+ for _, cfg := range configs {
+ if !cfg.Enabled {
+ continue
+ }
+ if err := s.runUpdate(ctx, cfg); err != nil {
+ s.logger.Error("ddns update failed", "domain", cfg.RootDomain, "error", err.Error())
+ if s.notify != nil {
+ s.notify.Alert(ctx, notify.EventDDNSFailure, "DDNS 更新失败", cfg.RootDomain+": "+err.Error())
+ }
+ }
+ }
+}
+
+// PublicIPs returns the most recently synced public IPs from enabled DDNS configs.
+func (s *Service) PublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
+ return s.latestStoredPublicIPs(ctx, true)
+}
+
+// LastKnownPublicIPs returns stored public IPs from the most recently updated config,
+// including paused tasks. Used to avoid live IP detection when DDNS is temporarily disabled.
+func (s *Service) LastKnownPublicIPs(ctx context.Context) (ipv4, ipv6 string, ok bool) {
+ return s.latestStoredPublicIPs(ctx, false)
+}
+
+func (s *Service) latestStoredPublicIPs(ctx context.Context, enabledOnly bool) (ipv4, ipv6 string, ok bool) {
+ configs, err := s.store.List(ctx)
+ if err != nil || len(configs) == 0 {
+ return "", "", false
+ }
+ var latest *Config
+ for _, cfg := range configs {
+ if enabledOnly && !cfg.Enabled {
+ continue
+ }
+ if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
+ latest = &cfg
+ }
+ }
+ if latest == nil {
+ return "", "", false
+ }
+ if latest.LastIPv4 == "" && latest.LastIPv6 == "" {
+ return "", "", false
+ }
+ return latest.LastIPv4, latest.LastIPv6, true
+}
+
+func (s *Service) Summary(ctx context.Context) (status string, lastUpdated string, count int) {
+ configs, err := s.store.List(ctx)
+ if err != nil || len(configs) == 0 {
+ return "disabled", "", 0
+ }
+ count = len(configs)
+ status = "ok"
+ hasEnabled := false
+ var latest *Config
+ for _, cfg := range configs {
+ if cfg.Enabled {
+ hasEnabled = true
+ }
+ if latest == nil || (cfg.LastUpdatedAt != nil && (latest.LastUpdatedAt == nil || cfg.LastUpdatedAt.After(*latest.LastUpdatedAt))) {
+ latest = &cfg
+ }
+ if cfg.LastStatus == "error" {
+ status = "error"
+ }
+ }
+ if !hasEnabled {
+ return "disabled", "", count
+ }
+ if latest != nil && latest.LastUpdatedAt != nil {
+ lastUpdated = latest.LastUpdatedAt.UTC().Format(time.RFC3339)
+ }
+ if status != "error" && latest != nil && latest.LastStatus != "" {
+ status = latest.LastStatus
+ }
+ return status, lastUpdated, count
+}
+
+func sanitizeConfig(cfg *Config) {
+ if !publicip.IsPublicIPv4(cfg.LastIPv4) {
+ cfg.LastIPv4 = ""
+ }
+ if !publicip.IsPublicIPv6(cfg.LastIPv6) {
+ cfg.LastIPv6 = ""
+ }
+}
+
+func (s *Service) enrichDomainRecords(ctx context.Context, cfg *Config) {
+ records := BuildDomainRecords(*cfg)
+ if !cfg.HasToken || len(records) == 0 {
+ cfg.DomainRecords = records
+ return
+ }
+ cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+ if err != nil {
+ cfg.DomainRecords = records
+ return
+ }
+ cred.Provider = cfg.Provider
+ provider := s.providerFor(cfg.Provider)
+ zoneCache := make(map[string]zoneHost)
+
+ for i := range records {
+ prevIPv4 := records[i].IPv4
+ prevIPv6 := records[i].IPv6
+ prevStatus := records[i].Status
+ prevMessage := records[i].Message
+
+ fqdn := FQDNFromRecord(*cfg, records[i].Domain)
+ zone, record, err := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
+ if err != nil {
+ records[i].Domain = fqdn
+ records[i].Status = "error"
+ records[i].Message = err.Error()
+ continue
+ }
+ records[i].Domain = FormatDomainLine(zone, record)
+
+ if cfg.IPv4Enabled {
+ ip, err := provider.GetRecordIP(ctx, cred, zone, record, "A")
+ switch {
+ case err != nil:
+ records[i].IPv4 = prevIPv4
+ if prevStatus == "" || prevStatus == "unknown" {
+ records[i].Status = "error"
+ records[i].Message = err.Error()
+ }
+ case ip != "":
+ records[i].IPv4 = ip
+ if prevStatus == "" || prevStatus == "unknown" {
+ records[i].Status = "unchanged"
+ records[i].Message = "记录未变化"
+ } else {
+ records[i].Status = prevStatus
+ records[i].Message = prevMessage
+ }
+ default:
+ records[i].IPv4 = prevIPv4
+ }
+ }
+ if cfg.IPv6Enabled {
+ ip, err := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
+ switch {
+ case err != nil:
+ records[i].IPv6 = prevIPv6
+ if records[i].Status == "" || records[i].Status == "unknown" {
+ records[i].Status = "error"
+ records[i].Message = err.Error()
+ }
+ case ip != "":
+ records[i].IPv6 = ip
+ default:
+ records[i].IPv6 = prevIPv6
+ }
+ }
+ if records[i].Status == "" {
+ records[i].Status = "unknown"
+ }
+ }
+ cfg.DomainRecords = records
+}
+
+func (s *Service) runUpdate(ctx context.Context, cfg Config) error {
+ prevIPv4 := cfg.LastIPv4
+ prevIPv6 := cfg.LastIPv6
+
+ cred, err := s.loadCredentialsByID(ctx, cfg.ID)
+ if err != nil {
+ _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", err.Error())
+ return err
+ }
+ cred.Provider = cfg.Provider
+ provider := s.providerFor(cfg.Provider)
+
+ detectedIPv4, detectedIPv6, _ := publicip.Detect(ctx)
+ if detectedIPv4 != "" && !publicip.IsPublicIPv4(detectedIPv4) {
+ detectedIPv4 = ""
+ }
+ if detectedIPv6 != "" && !publicip.IsPublicIPv6(detectedIPv6) {
+ detectedIPv6 = ""
+ }
+ // 自定义 IP 优先:填写后直接作为更新目标(如公网服务器 IP),不再依赖本机出口 IP 检测
+ customIPv4 := strings.TrimSpace(cfg.CustomIPv4)
+ customIPv6 := strings.TrimSpace(cfg.CustomIPv6)
+ if customIPv4 != "" && !publicip.IsPublicIPv4(customIPv4) {
+ msg := "自定义 IPv4 不是公网地址,已拒绝更新"
+ _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
+ return fmt.Errorf(msg)
+ }
+ if customIPv6 != "" && !publicip.IsPublicIPv6(customIPv6) {
+ msg := "自定义 IPv6 不是公网地址,已拒绝更新"
+ _ = s.store.UpdateStatus(ctx, cfg.ID, cfg.LastIPv4, cfg.LastIPv6, "error", msg)
+ return fmt.Errorf(msg)
+ }
+ if customIPv4 != "" {
+ detectedIPv4 = customIPv4
+ }
+ if customIPv6 != "" {
+ detectedIPv6 = customIPv6
+ }
+ storeIPv4 := cfg.LastIPv4
+ storeIPv6 := cfg.LastIPv6
+ if !publicip.IsPublicIPv4(storeIPv4) {
+ storeIPv4 = ""
+ }
+ if !publicip.IsPublicIPv6(storeIPv6) {
+ storeIPv6 = ""
+ }
+ if cfg.IPv4Enabled && detectedIPv4 != "" {
+ storeIPv4 = detectedIPv4
+ }
+ if cfg.IPv6Enabled && detectedIPv6 != "" {
+ storeIPv6 = detectedIPv6
+ }
+
+ if cfg.IPv4Enabled && detectedIPv4 == "" {
+ msg := "无法获取公网 IPv4,已跳过更新(不会写入内网 IP)"
+ _ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
+ return fmt.Errorf(msg)
+ }
+ if cfg.IPv6Enabled && detectedIPv6 == "" {
+ msg := "无法获取公网 IPv6,已跳过更新"
+ _ = s.store.UpdateStatus(ctx, cfg.ID, storeIPv4, storeIPv6, "error", msg)
+ return fmt.Errorf(msg)
+ }
+
+ recordNames := cfg.RecordNamesList()
+ domainRecords := make([]DomainRecord, 0, len(recordNames))
+ var lastErr error
+ var errCount, okCount int
+ changed := false
+ zoneCache := make(map[string]zoneHost)
+
+ for _, name := range recordNames {
+ fqdn := FQDNFromRecord(cfg, name)
+ zone, record, zoneErr := s.resolveZone(ctx, provider, cred, fqdn, zoneCache)
+ if zoneErr != nil {
+ dr := DomainRecord{Domain: fqdn, Status: "error", Message: zoneErr.Error()}
+ domainRecords = append(domainRecords, dr)
+ lastErr = zoneErr
+ errCount++
+ continue
+ }
+ fqdn = FormatDomainLine(zone, record)
+ dr := DomainRecord{Domain: fqdn}
+
+ if cfg.IPv4Enabled && detectedIPv4 != "" {
+ current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "A")
+ if getErr != nil {
+ dr.Status = "error"
+ dr.Message = getErr.Error()
+ lastErr = getErr
+ domainRecords = append(domainRecords, dr)
+ continue
+ }
+ dr.IPv4 = current
+ if current == detectedIPv4 {
+ dr.Status = "unchanged"
+ dr.Message = "记录未变化"
+ } else if !publicip.IsPublicIPv4(detectedIPv4) {
+ dr.Status = "error"
+ dr.Message = "检测到内网 IPv4,拒绝更新"
+ lastErr = fmt.Errorf("检测到内网 IPv4,拒绝更新")
+ } else if err := provider.UpdateRecord(ctx, cred, zone, record, "A", detectedIPv4); err != nil {
+ dr.Status = "error"
+ dr.Message = err.Error()
+ lastErr = err
+ } else {
+ dr.IPv4 = detectedIPv4
+ dr.Status = "ok"
+ dr.Message = "已更新"
+ changed = true
+ s.logger.Info("ddns ipv4 updated", "domain", fqdn, "ip", detectedIPv4)
+ }
+ }
+
+ if cfg.IPv6Enabled && detectedIPv6 != "" && dr.Status != "error" {
+ current, getErr := provider.GetRecordIP(ctx, cred, zone, record, "AAAA")
+ if getErr != nil {
+ dr.Status = "error"
+ dr.Message = getErr.Error()
+ lastErr = getErr
+ } else {
+ dr.IPv6 = current
+ if current == detectedIPv6 {
+ if dr.Status == "" {
+ dr.Status = "unchanged"
+ dr.Message = "记录未变化"
+ }
+ } else if !publicip.IsPublicIPv6(detectedIPv6) {
+ dr.Status = "error"
+ dr.Message = "检测到内网 IPv6,拒绝更新"
+ lastErr = fmt.Errorf("检测到内网 IPv6,拒绝更新")
+ } else if err := provider.UpdateRecord(ctx, cred, zone, record, "AAAA", detectedIPv6); err != nil {
+ dr.Status = "error"
+ dr.Message = err.Error()
+ lastErr = err
+ } else {
+ dr.IPv6 = detectedIPv6
+ dr.Status = "ok"
+ dr.Message = "已更新"
+ changed = true
+ s.logger.Info("ddns ipv6 updated", "domain", fqdn, "ip", detectedIPv6)
+ }
+ }
+ }
+
+ if dr.Status == "" {
+ dr.Status = "unchanged"
+ dr.Message = "记录未变化"
+ }
+ if dr.Status == "error" {
+ errCount++
+ } else {
+ okCount++
+ }
+ domainRecords = append(domainRecords, dr)
+ }
+
+ overallStatus := "ok"
+ overallError := ""
+ switch {
+ case errCount > 0 && okCount == 0:
+ overallStatus = "error"
+ if lastErr != nil {
+ overallError = lastErr.Error()
+ }
+ case errCount > 0:
+ overallStatus = "warning"
+ overallError = fmt.Sprintf("%d 个域名同步失败,其余正常", errCount)
+ lastErr = nil
+ }
+ if changed {
+ s.logger.Info("ddns records updated", "provider", cfg.Provider)
+ if s.notify != nil {
+ msg := formatDDNSIPChangeMessage(cfg.RootDomain, prevIPv4, prevIPv6, storeIPv4, storeIPv6)
+ s.notify.Alert(ctx, notify.EventDDNSIPChange, "DDNS IP 已变更", msg)
+ }
+ }
+ _ = s.store.UpdateSyncResult(ctx, cfg.ID, storeIPv4, storeIPv6, overallStatus, overallError, domainRecords)
+ return lastErr
+}
+
+func formatDDNSIPChangeMessage(domain, prevIPv4, prevIPv6, newIPv4, newIPv6 string) string {
+ var parts []string
+ if newIPv4 != "" && newIPv4 != prevIPv4 {
+ if prevIPv4 != "" {
+ parts = append(parts, fmt.Sprintf("IPv4: %s → %s", prevIPv4, newIPv4))
+ } else {
+ parts = append(parts, fmt.Sprintf("IPv4: %s", newIPv4))
+ }
+ }
+ if newIPv6 != "" && newIPv6 != prevIPv6 {
+ if prevIPv6 != "" {
+ parts = append(parts, fmt.Sprintf("IPv6: %s → %s", prevIPv6, newIPv6))
+ } else {
+ parts = append(parts, fmt.Sprintf("IPv6: %s", newIPv6))
+ }
+ }
+ if len(parts) == 0 {
+ return domain
+ }
+ return domain + "\n" + strings.Join(parts, "\n")
+}
+
+func (s *Service) encryptCredentials(in SaveInput) (string, error) {
+ cred := CredentialsFromSave(in)
+ if err := cred.Validate(in.Provider); err != nil {
+ return "", err
+ }
+ raw, err := cred.Marshal()
+ if err != nil {
+ return "", err
+ }
+ return s.secretBox.Encrypt(string(raw))
+}
+
+func (s *Service) loadCredentialsByID(ctx context.Context, id int64) (Credentials, error) {
+ enc, err := s.store.GetTokenByID(ctx, id)
+ if err != nil || enc == "" {
+ return Credentials{}, fmt.Errorf("DNS API 凭证未配置")
+ }
+ raw, err := s.secretBox.Decrypt(enc)
+ if err != nil {
+ return Credentials{}, secret.DecryptHint(err)
+ }
+ return ParseCredentials(raw)
+}
+
+func (s *Service) providerFor(name string) Provider {
+ if p, ok := s.providers[name]; ok {
+ return p
+ }
+ return s.providers["cloudflare"]
+}
+
+func (s *Service) resolveZone(ctx context.Context, provider Provider, cred Credentials, fqdn string, cache map[string]zoneHost) (zone, host string, err error) {
+ if cache != nil {
+ if item, ok := cache[fqdn]; ok {
+ return item.zone, item.host, item.err
+ }
+ }
+ zone, host, err = ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+ return provider.HasZone(ctx, cred, candidate)
+ }, fqdn)
+ if cache != nil {
+ cache[fqdn] = zoneHost{zone: zone, host: host, err: err}
+ }
+ return zone, host, err
+}
+
+type zoneHost struct {
+ zone string
+ host string
+ err error
+}
+
+func validateSaveInput(in SaveInput) error {
+ if _, err := FQDNsFromSaveInput(in); err != nil {
+ return err
+ }
+ switch strings.TrimSpace(in.Provider) {
+ case "dnspod", "cloudflare", "alidns", "tencentcloud", "volcengine", "":
+ default:
+ return fmt.Errorf("不支持的 DNS Provider")
+ }
+ return nil
+}
diff --git a/internal/ddns/store.go b/internal/ddns/store.go
new file mode 100644
index 0000000..7379746
--- /dev/null
+++ b/internal/ddns/store.go
@@ -0,0 +1,281 @@
+package ddns
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+type Config struct {
+ ID int64 `json:"id"`
+ Provider string `json:"provider"`
+ RootDomain string `json:"root_domain"`
+ RecordName string `json:"record_name"`
+ RecordNames []string `json:"record_names"`
+ IPv4Enabled bool `json:"ipv4_enabled"`
+ IPv6Enabled bool `json:"ipv6_enabled"`
+ Enabled bool `json:"enabled"`
+ HasToken bool `json:"has_token"`
+ CustomIPv4 string `json:"custom_ipv4,omitempty"`
+ CustomIPv6 string `json:"custom_ipv6,omitempty"`
+ LastIPv4 string `json:"last_ipv4"`
+ LastIPv6 string `json:"last_ipv6"`
+ LastStatus string `json:"last_status"`
+ LastError string `json:"last_error,omitempty"`
+ DomainRecords []DomainRecord `json:"domain_records"`
+ LastUpdatedAt *time.Time `json:"last_updated_at,omitempty"`
+}
+
+type SaveInput struct {
+ Provider string
+ RootDomain string
+ RecordName string
+ RecordNames []string
+ Domains []string
+ IPv4Enabled bool
+ IPv6Enabled bool
+ Enabled bool
+ CustomIPv4 string
+ CustomIPv6 string
+ APIToken string
+ APITokenID string
+ APISecret string
+}
+
+func (in SaveInput) HasCredentialUpdate() bool {
+ return strings.TrimSpace(in.APIToken) != "" ||
+ strings.TrimSpace(in.APITokenID) != "" ||
+ strings.TrimSpace(in.APISecret) != ""
+}
+
+type Store struct {
+ db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) List(ctx context.Context) ([]Config, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+ CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+ COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+ COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+ FROM ddns_configs ORDER BY id ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var configs []Config
+ for rows.Next() {
+ cfg, err := scanConfig(rows)
+ if err != nil {
+ return nil, err
+ }
+ configs = append(configs, cfg)
+ }
+ return configs, rows.Err()
+}
+
+func (s *Store) GetByID(ctx context.Context, id int64) (Config, error) {
+ row := s.db.QueryRowContext(ctx, `
+ SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+ CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+ COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+ COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+ FROM ddns_configs WHERE id = ?
+ `, id)
+ cfg, err := scanConfig(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Config{}, fmt.Errorf("DDNS 配置不存在")
+ }
+ return cfg, err
+}
+
+func (s *Store) GetByRootDomain(ctx context.Context, rootDomain string) (Config, error) {
+ rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+ row := s.db.QueryRowContext(ctx, `
+ SELECT id, provider, root_domain, record_name, COALESCE(record_names, '[]'), ipv4_enabled, ipv6_enabled, enabled,
+ CASE WHEN api_token_enc IS NOT NULL AND api_token_enc != '' THEN 1 ELSE 0 END,
+ COALESCE(last_ipv4, ''), COALESCE(last_ipv6, ''), last_status, COALESCE(last_error, ''),
+ COALESCE(domain_records, '[]'), last_updated_at, COALESCE(custom_ipv4, ''), COALESCE(custom_ipv6, '')
+ FROM ddns_configs WHERE root_domain = ? ORDER BY id LIMIT 1
+ `, rootDomain)
+ cfg, err := scanConfig(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Config{}, fmt.Errorf("未找到域名 %s 的 DDNS 配置", rootDomain)
+ }
+ return cfg, err
+}
+
+func (s *Store) GetTokenByID(ctx context.Context, id int64) (string, error) {
+ var enc string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(api_token_enc, '') FROM ddns_configs WHERE id = ?`, id).Scan(&enc)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("DDNS 配置不存在")
+ }
+ return "", err
+ }
+ return enc, nil
+}
+
+func (s *Store) Create(ctx context.Context, in SaveInput, tokenEnc string) (Config, error) {
+ rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
+ if err != nil {
+ return Config{}, err
+ }
+ res, err := s.db.ExecContext(ctx, `
+ INSERT INTO ddns_configs(provider, root_domain, record_name, record_names, ipv4_enabled, ipv6_enabled, enabled, api_token_enc, custom_ipv4, custom_ipv6, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ `, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6)
+ if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+ }
+ return Config{}, err
+ }
+ id, _ := res.LastInsertId()
+ return s.GetByID(ctx, id)
+}
+
+func (s *Store) Update(ctx context.Context, id int64, in SaveInput, tokenEnc string, updateToken bool) (Config, error) {
+ rootDomain, recordName, recordNamesJSON, provider, err := normalizeInput(in)
+ if err != nil {
+ return Config{}, err
+ }
+ if updateToken {
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE ddns_configs
+ SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
+ api_token_enc = ?, custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
+ WHERE id = ?
+ `, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), tokenEnc, in.CustomIPv4, in.CustomIPv6, id)
+ if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+ }
+ return Config{}, err
+ }
+ } else {
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE ddns_configs
+ SET provider = ?, root_domain = ?, record_name = ?, record_names = ?, ipv4_enabled = ?, ipv6_enabled = ?, enabled = ?,
+ custom_ipv4 = ?, custom_ipv6 = ?, updated_at = datetime('now')
+ WHERE id = ?
+ `, provider, rootDomain, recordName, recordNamesJSON, boolInt(in.IPv4Enabled), boolInt(in.IPv6Enabled), boolInt(in.Enabled), in.CustomIPv4, in.CustomIPv6, id)
+ if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return Config{}, fmt.Errorf("该域名与子域名组合已存在")
+ }
+ return Config{}, err
+ }
+ }
+ return s.GetByID(ctx, id)
+}
+
+func (s *Store) Delete(ctx context.Context, id int64) error {
+ res, err := s.db.ExecContext(ctx, `DELETE FROM ddns_configs WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return fmt.Errorf("DDNS 配置不存在")
+ }
+ return nil
+}
+
+func (s *Store) UpdateStatus(ctx context.Context, id int64, ipv4, ipv6, status, lastError string) error {
+ return s.UpdateSyncResult(ctx, id, ipv4, ipv6, status, lastError, nil)
+}
+
+func (s *Store) UpdateSyncResult(ctx context.Context, id int64, ipv4, ipv6, status, lastError string, records []DomainRecord) error {
+ domainRecordsJSON := ""
+ if records != nil {
+ domainRecordsJSON = EncodeDomainRecords(records)
+ }
+ if domainRecordsJSON != "" {
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE ddns_configs
+ SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, domain_records = ?,
+ last_updated_at = datetime('now'), updated_at = datetime('now')
+ WHERE id = ?
+ `, ipv4, ipv6, status, lastError, domainRecordsJSON, id)
+ return err
+ }
+ _, err := s.db.ExecContext(ctx, `
+ UPDATE ddns_configs
+ SET last_ipv4 = ?, last_ipv6 = ?, last_status = ?, last_error = ?, last_updated_at = datetime('now'), updated_at = datetime('now')
+ WHERE id = ?
+ `, ipv4, ipv6, status, lastError, id)
+ return err
+}
+
+func normalizeInput(in SaveInput) (rootDomain, recordName, recordNamesJSON, provider string, err error) {
+ provider = strings.TrimSpace(in.Provider)
+ if provider == "" {
+ provider = "cloudflare"
+ }
+
+ var names []string
+ if len(in.Domains) > 0 {
+ rootDomain, names, err = ParseDomainLines(in.Domains)
+ if err != nil {
+ return "", "", "", "", err
+ }
+ } else {
+ rootDomain = strings.ToLower(strings.TrimSpace(in.RootDomain))
+ if rootDomain == "" {
+ return "", "", "", "", fmt.Errorf("至少需要一个域名")
+ }
+ names, err = ParseRecordNames(in.RecordNames, in.RecordName)
+ if err != nil {
+ return "", "", "", "", err
+ }
+ }
+ return rootDomain, names[0], EncodeRecordNames(names), provider, nil
+}
+
+func scanConfig(row interface{ Scan(dest ...any) error }) (Config, error) {
+ var cfg Config
+ var ipv4Enabled, ipv6Enabled, enabled, hasToken int
+ var recordNamesRaw, domainRecordsRaw string
+ var lastUpdated sql.NullString
+ if err := row.Scan(&cfg.ID, &cfg.Provider, &cfg.RootDomain, &cfg.RecordName, &recordNamesRaw, &ipv4Enabled, &ipv6Enabled, &enabled, &hasToken, &cfg.LastIPv4, &cfg.LastIPv6, &cfg.LastStatus, &cfg.LastError, &domainRecordsRaw, &lastUpdated, &cfg.CustomIPv4, &cfg.CustomIPv6); err != nil {
+ return Config{}, err
+ }
+ cfg.RecordNames = DecodeRecordNames(recordNamesRaw, cfg.RecordName)
+ cfg.DomainRecords = DecodeDomainRecords(domainRecordsRaw)
+ cfg.DomainRecords = BuildDomainRecords(cfg)
+ cfg.IPv4Enabled = ipv4Enabled == 1
+ cfg.IPv6Enabled = ipv6Enabled == 1
+ cfg.Enabled = enabled == 1
+ cfg.HasToken = hasToken == 1
+ if lastUpdated.Valid {
+ t := parseTime(lastUpdated.String)
+ cfg.LastUpdatedAt = &t
+ }
+ return cfg, nil
+}
+
+func boolInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
+
+func parseTime(v string) time.Time {
+ t, err := time.Parse("2006-01-02 15:04:05", v)
+ if err != nil {
+ t, _ = time.Parse(time.RFC3339, v)
+ }
+ return t.UTC()
+}
diff --git a/internal/ddns/tencentcloud.go b/internal/ddns/tencentcloud.go
new file mode 100644
index 0000000..9ba6ed8
--- /dev/null
+++ b/internal/ddns/tencentcloud.go
@@ -0,0 +1,186 @@
+package ddns
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
+ errorsdk "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
+ "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
+ dnspod "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod/v20210323"
+)
+
+type TencentCloud struct{}
+
+func NewTencentCloud() *TencentCloud { return &TencentCloud{} }
+
+func (t *TencentCloud) Name() string { return "tencentcloud" }
+
+func (t *TencentCloud) Verify(ctx context.Context, cred Credentials) error {
+ client, err := t.client(cred)
+ if err != nil {
+ return err
+ }
+ req := dnspod.NewDescribeDomainListRequest()
+ req.Limit = common.Int64Ptr(1)
+ _, err = client.DescribeDomainList(req)
+ return wrapTencentErr(err)
+}
+
+func (t *TencentCloud) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+ client, err := t.client(cred)
+ if err != nil {
+ return false, err
+ }
+ _, err = t.domainInfo(client, zone)
+ return zoneLookupOK(wrapTencentErr(err))
+}
+
+func (t *TencentCloud) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+ client, err := t.client(cred)
+ if err != nil {
+ return "", err
+ }
+ zone, err := t.domainInfo(client, rootDomain)
+ if err != nil {
+ return "", err
+ }
+ records, err := t.listRecords(client, zone, tencentSubdomain(recordName), recordType)
+ if err != nil {
+ return "", err
+ }
+ for _, rec := range records {
+ if rec.Value != nil {
+ return *rec.Value, nil
+ }
+ }
+ return "", nil
+}
+
+func (t *TencentCloud) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+ client, err := t.client(cred)
+ if err != nil {
+ return err
+ }
+ zone, err := t.domainInfo(client, rootDomain)
+ if err != nil {
+ return err
+ }
+ subDomain := tencentSubdomain(recordName)
+ records, err := t.listRecords(client, zone, subDomain, recordType)
+ if err != nil {
+ return err
+ }
+ if len(records) > 0 && records[0].RecordId != nil {
+ req := dnspod.NewModifyRecordRequest()
+ req.Domain = zone.Name
+ req.DomainId = zone.DomainId
+ req.RecordId = records[0].RecordId
+ req.SubDomain = common.StringPtr(subDomain)
+ req.RecordType = common.StringPtr(recordType)
+ req.RecordLine = common.StringPtr(tencentRecordLine(records[0]))
+ req.Value = common.StringPtr(ip)
+ req.TTL = common.Uint64Ptr(600)
+ _, err = client.ModifyRecord(req)
+ return wrapTencentErr(err)
+ }
+
+ req := dnspod.NewCreateRecordRequest()
+ req.Domain = zone.Name
+ req.DomainId = zone.DomainId
+ req.SubDomain = common.StringPtr(subDomain)
+ req.RecordType = common.StringPtr(recordType)
+ req.RecordLine = common.StringPtr("默认")
+ req.Value = common.StringPtr(ip)
+ req.TTL = common.Uint64Ptr(600)
+ _, err = client.CreateRecord(req)
+ return wrapTencentErr(err)
+}
+
+func (t *TencentCloud) client(cred Credentials) (*dnspod.Client, error) {
+ if cred.Token == "" || cred.Secret == "" {
+ return nil, fmt.Errorf("腾讯云 DNS 凭证未配置")
+ }
+ credential := common.NewCredential(cred.Token, cred.Secret)
+ cpf := profile.NewClientProfile()
+ cpf.HttpProfile.Endpoint = "dnspod.tencentcloudapi.com"
+ return dnspod.NewClient(credential, "", cpf)
+}
+
+func (t *TencentCloud) domainInfo(client *dnspod.Client, rootDomain string) (*dnspod.DomainListItem, error) {
+ rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+ req := dnspod.NewDescribeDomainListRequest()
+ var offset int64
+ for {
+ req.Offset = common.Int64Ptr(offset)
+ resp, err := client.DescribeDomainList(req)
+ if err != nil {
+ return nil, wrapTencentErr(err)
+ }
+ for _, zone := range resp.Response.DomainList {
+ if zone.Name != nil && strings.EqualFold(*zone.Name, rootDomain) {
+ return zone, nil
+ }
+ if zone.Punycode != nil && strings.EqualFold(*zone.Punycode, rootDomain) {
+ return zone, nil
+ }
+ }
+ total := int64(0)
+ if resp.Response.DomainCountInfo != nil && resp.Response.DomainCountInfo.AllTotal != nil {
+ total = int64(*resp.Response.DomainCountInfo.AllTotal)
+ }
+ offset += int64(len(resp.Response.DomainList))
+ if offset >= total || len(resp.Response.DomainList) == 0 {
+ break
+ }
+ }
+ return nil, fmt.Errorf("未找到域名 %s 对应的腾讯云 DNS 域名", rootDomain)
+}
+
+func (t *TencentCloud) listRecords(client *dnspod.Client, zone *dnspod.DomainListItem, subDomain, recordType string) ([]*dnspod.RecordListItem, error) {
+ req := dnspod.NewDescribeRecordListRequest()
+ req.Domain = zone.Name
+ req.DomainId = zone.DomainId
+ req.Subdomain = common.StringPtr(subDomain)
+ req.RecordType = common.StringPtr(recordType)
+ resp, err := client.DescribeRecordList(req)
+ if err != nil {
+ var sdkErr *errorsdk.TencentCloudSDKError
+ if errors.As(err, &sdkErr) && sdkErr.Code == dnspod.RESOURCENOTFOUND_NODATAOFRECORD {
+ return nil, nil
+ }
+ return nil, wrapTencentErr(err)
+ }
+ if resp.Response == nil {
+ return nil, nil
+ }
+ return resp.Response.RecordList, nil
+}
+
+func tencentSubdomain(recordName string) string {
+ sub := subDomainForRecord(recordName)
+ if sub == "@" {
+ return "@"
+ }
+ return sub
+}
+
+func tencentRecordLine(rec *dnspod.RecordListItem) string {
+ if rec != nil && rec.Line != nil && *rec.Line != "" {
+ return *rec.Line
+ }
+ return "默认"
+}
+
+func wrapTencentErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ var sdkErr *errorsdk.TencentCloudSDKError
+ if errors.As(err, &sdkErr) {
+ return fmt.Errorf("腾讯云 DNS API 错误:%s", sdkErr.Message)
+ }
+ return err
+}
diff --git a/internal/ddns/tencentcloud_test.go b/internal/ddns/tencentcloud_test.go
new file mode 100644
index 0000000..aa9b9fb
--- /dev/null
+++ b/internal/ddns/tencentcloud_test.go
@@ -0,0 +1,26 @@
+package ddns
+
+import "testing"
+
+func TestTencentSubdomain(t *testing.T) {
+ if tencentSubdomain("@") != "@" {
+ t.Fatal("expected @")
+ }
+ if tencentSubdomain("www") != "www" {
+ t.Fatal("expected www")
+ }
+}
+
+func TestCredentialsTencentCloud(t *testing.T) {
+ cred := CredentialsFromSave(SaveInput{
+ Provider: "tencentcloud",
+ APIToken: "AKIDxxx",
+ APISecret: "secret",
+ })
+ if cred.Provider != "tencentcloud" || !cred.HasValues() {
+ t.Fatal("expected tencentcloud credentials")
+ }
+ if err := cred.Validate("tencentcloud"); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/ddns/volcengine.go b/internal/ddns/volcengine.go
new file mode 100644
index 0000000..3a58a72
--- /dev/null
+++ b/internal/ddns/volcengine.go
@@ -0,0 +1,201 @@
+package ddns
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/volcengine/volcengine-go-sdk/service/dns"
+ "github.com/volcengine/volcengine-go-sdk/volcengine"
+ "github.com/volcengine/volcengine-go-sdk/volcengine/credentials"
+ "github.com/volcengine/volcengine-go-sdk/volcengine/session"
+ "github.com/volcengine/volcengine-go-sdk/volcengine/volcengineerr"
+)
+
+const volcengineDNSRegion = "cn-beijing"
+
+type Volcengine struct{}
+
+func NewVolcengine() *Volcengine { return &Volcengine{} }
+
+func (v *Volcengine) Name() string { return "volcengine" }
+
+func (v *Volcengine) Verify(ctx context.Context, cred Credentials) error {
+ client, err := v.client(cred)
+ if err != nil {
+ return err
+ }
+ _, err = client.ListZonesWithContext(ctx, &dns.ListZonesInput{
+ PageNumber: volcengine.Int32(1),
+ PageSize: volcengine.Int32(1),
+ })
+ return wrapVolcengineErr(err)
+}
+
+func (v *Volcengine) HasZone(ctx context.Context, cred Credentials, zone string) (bool, error) {
+ client, err := v.client(cred)
+ if err != nil {
+ return false, err
+ }
+ _, err = v.zoneID(ctx, client, zone)
+ return zoneLookupOK(err)
+}
+
+func (v *Volcengine) GetRecordIP(ctx context.Context, cred Credentials, rootDomain, recordName, recordType string) (string, error) {
+ client, err := v.client(cred)
+ if err != nil {
+ return "", err
+ }
+ zid, err := v.zoneID(ctx, client, rootDomain)
+ if err != nil {
+ return "", err
+ }
+ rec, err := v.findRecord(ctx, client, zid, volcengineHost(recordName), recordType)
+ if err != nil {
+ return "", err
+ }
+ if rec != nil && rec.Value != nil {
+ return *rec.Value, nil
+ }
+ return "", nil
+}
+
+func (v *Volcengine) UpdateRecord(ctx context.Context, cred Credentials, rootDomain, recordName, recordType, ip string) error {
+ client, err := v.client(cred)
+ if err != nil {
+ return err
+ }
+ zid, err := v.zoneID(ctx, client, rootDomain)
+ if err != nil {
+ return err
+ }
+ host := volcengineHost(recordName)
+ existing, err := v.findRecord(ctx, client, zid, host, recordType)
+ if err != nil {
+ return err
+ }
+ if existing != nil && existing.RecordID != nil {
+ line := volcengineLine(existing)
+ _, err = client.UpdateRecordWithContext(ctx, &dns.UpdateRecordInput{
+ RecordID: existing.RecordID,
+ Host: volcengine.String(host),
+ Line: volcengine.String(line),
+ Type: volcengine.String(recordType),
+ Value: volcengine.String(ip),
+ TTL: volcengine.Int32(600),
+ })
+ return wrapVolcengineErr(err)
+ }
+
+ _, err = client.CreateRecordWithContext(ctx, &dns.CreateRecordInput{
+ ZID: volcengine.Int64(zid),
+ Host: volcengine.String(host),
+ Type: volcengine.String(recordType),
+ Value: volcengine.String(ip),
+ Line: volcengine.String("default"),
+ TTL: volcengine.Int32(600),
+ })
+ return wrapVolcengineErr(err)
+}
+
+func (v *Volcengine) client(cred Credentials) (*dns.DNS, error) {
+ if cred.Token == "" || cred.Secret == "" {
+ return nil, fmt.Errorf("火山引擎 DNS 凭证未配置")
+ }
+ cfg := volcengine.NewConfig().
+ WithRegion(volcengineDNSRegion).
+ WithCredentials(credentials.NewStaticCredentials(cred.Token, cred.Secret, ""))
+ sess, err := session.NewSession(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return dns.New(sess), nil
+}
+
+func (v *Volcengine) zoneID(ctx context.Context, client *dns.DNS, rootDomain string) (int64, error) {
+ rootDomain = strings.ToLower(strings.TrimSpace(rootDomain))
+ var page int32 = 1
+ const pageSize int32 = 100
+ for {
+ out, err := client.ListZonesWithContext(ctx, &dns.ListZonesInput{
+ Key: volcengine.String(rootDomain),
+ PageNumber: volcengine.Int32(page),
+ PageSize: volcengine.Int32(pageSize),
+ })
+ if err != nil {
+ return 0, wrapVolcengineErr(err)
+ }
+ for _, zone := range out.Zones {
+ if zone.ZoneName != nil && strings.EqualFold(*zone.ZoneName, rootDomain) && zone.ZID != nil {
+ return int64(*zone.ZID), nil
+ }
+ }
+ total := int32(0)
+ if out.Total != nil {
+ total = *out.Total
+ }
+ if len(out.Zones) == 0 || page*pageSize >= total {
+ break
+ }
+ page++
+ }
+ return 0, fmt.Errorf("未找到域名 %s 对应的火山引擎 DNS 域名", rootDomain)
+}
+
+func (v *Volcengine) findRecord(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) (*dns.RecordForListRecordsOutput, error) {
+ var page int32 = 1
+ const pageSize int32 = 100
+ for {
+ out, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
+ ZID: volcengine.Int64(zid),
+ Host: volcengine.String(host),
+ Type: volcengine.String(recordType),
+ PageNumber: volcengine.Int32(page),
+ PageSize: volcengine.Int32(pageSize),
+ })
+ if err != nil {
+ return nil, wrapVolcengineErr(err)
+ }
+ for _, rec := range out.Records {
+ if rec.Host != nil && *rec.Host == host && rec.Type != nil && strings.EqualFold(*rec.Type, recordType) {
+ return rec, nil
+ }
+ }
+ total := int32(0)
+ if out.TotalCount != nil {
+ total = *out.TotalCount
+ }
+ if len(out.Records) == 0 || page*pageSize >= total {
+ break
+ }
+ page++
+ }
+ return nil, nil
+}
+
+func volcengineHost(recordName string) string {
+ return subDomainForRecord(recordName)
+}
+
+func volcengineLine(rec *dns.RecordForListRecordsOutput) string {
+ if rec != nil && rec.Line != nil && strings.TrimSpace(*rec.Line) != "" {
+ return *rec.Line
+ }
+ return "default"
+}
+
+func wrapVolcengineErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ var sdkErr volcengineerr.RequestFailure
+ if errors.As(err, &sdkErr) {
+ return fmt.Errorf("火山引擎 DNS API 错误:%s", sdkErr.Message())
+ }
+ var apiErr volcengineerr.Error
+ if errors.As(err, &apiErr) {
+ return fmt.Errorf("火山引擎 DNS API 错误:%s", apiErr.Message())
+ }
+ return err
+}
diff --git a/internal/ddns/volcengine_acme.go b/internal/ddns/volcengine_acme.go
new file mode 100644
index 0000000..d286751
--- /dev/null
+++ b/internal/ddns/volcengine_acme.go
@@ -0,0 +1,89 @@
+package ddns
+
+import (
+ "context"
+ "strings"
+
+ "github.com/volcengine/volcengine-go-sdk/service/dns"
+ "github.com/volcengine/volcengine-go-sdk/volcengine"
+)
+
+// UpsertTXTRecord creates or updates a TXT record for ACME DNS-01.
+func (v *Volcengine) UpsertTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
+ zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+ return v.HasZone(ctx, cred, candidate)
+ }, fqdn)
+ if err != nil {
+ return err
+ }
+ return v.UpdateRecord(ctx, cred, zone, host, "TXT", value)
+}
+
+// DeleteTXTRecord removes TXT records matching value at fqdn (ACME cleanup).
+func (v *Volcengine) DeleteTXTRecord(ctx context.Context, cred Credentials, fqdn, value string) error {
+ zone, host, err := ResolveZoneForFQDN(ctx, func(candidate string) (bool, error) {
+ return v.HasZone(ctx, cred, candidate)
+ }, fqdn)
+ if err != nil {
+ return err
+ }
+ return v.deleteTXTRecords(ctx, cred, zone, host, value)
+}
+
+func (v *Volcengine) deleteTXTRecords(ctx context.Context, cred Credentials, rootDomain, recordName, value string) error {
+ client, err := v.client(cred)
+ if err != nil {
+ return err
+ }
+ zid, err := v.zoneID(ctx, client, rootDomain)
+ if err != nil {
+ return err
+ }
+ host := volcengineHost(recordName)
+ records, err := v.listRecords(ctx, client, zid, host, "TXT")
+ if err != nil {
+ return err
+ }
+ for _, rec := range records {
+ if rec.Value == nil || strings.TrimSpace(*rec.Value) != strings.TrimSpace(value) {
+ continue
+ }
+ if rec.RecordID == nil {
+ continue
+ }
+ if _, err := client.DeleteRecordWithContext(ctx, &dns.DeleteRecordInput{
+ RecordID: rec.RecordID,
+ }); err != nil {
+ return wrapVolcengineErr(err)
+ }
+ }
+ return nil
+}
+
+func (v *Volcengine) listRecords(ctx context.Context, client *dns.DNS, zid int64, host, recordType string) ([]*dns.RecordForListRecordsOutput, error) {
+ var out []*dns.RecordForListRecordsOutput
+ var page int32 = 1
+ const pageSize int32 = 100
+ for {
+ resp, err := client.ListRecordsWithContext(ctx, &dns.ListRecordsInput{
+ ZID: volcengine.Int64(zid),
+ Host: volcengine.String(host),
+ Type: volcengine.String(recordType),
+ PageNumber: volcengine.Int32(page),
+ PageSize: volcengine.Int32(pageSize),
+ })
+ if err != nil {
+ return nil, wrapVolcengineErr(err)
+ }
+ out = append(out, resp.Records...)
+ total := int32(0)
+ if resp.TotalCount != nil {
+ total = *resp.TotalCount
+ }
+ if len(resp.Records) == 0 || page*pageSize >= total {
+ break
+ }
+ page++
+ }
+ return out, nil
+}
diff --git a/internal/ddns/volcengine_acme_test.go b/internal/ddns/volcengine_acme_test.go
new file mode 100644
index 0000000..6363053
--- /dev/null
+++ b/internal/ddns/volcengine_acme_test.go
@@ -0,0 +1,21 @@
+package ddns
+
+import (
+ "context"
+ "testing"
+)
+
+func TestResolveZoneForACMEChallenge(t *testing.T) {
+ zones := map[string]bool{
+ "example.com": true,
+ }
+ zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+ return zones[candidate], nil
+ }, "_acme-challenge.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if zone != "example.com" || host != "_acme-challenge" {
+ t.Fatalf("unexpected zone=%s host=%s", zone, host)
+ }
+}
diff --git a/internal/ddns/zone.go b/internal/ddns/zone.go
new file mode 100644
index 0000000..fb1a19b
--- /dev/null
+++ b/internal/ddns/zone.go
@@ -0,0 +1,87 @@
+package ddns
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+type zoneChecker func(zone string) (bool, error)
+
+// ResolveZoneForFQDN finds the longest DNS zone suffix that exists at the provider.
+// For delegated zones (e.g. sub.example.com hosted separately), it prefers sub.example.com over example.com.
+func ResolveZoneForFQDN(ctx context.Context, check zoneChecker, fqdn string) (zone, host string, err error) {
+ fqdn = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(fqdn, ".")))
+ if fqdn == "" {
+ return "", "", fmt.Errorf("域名不能为空")
+ }
+
+ if strings.HasPrefix(fqdn, "*.") {
+ zone, host, err := ResolveZoneForFQDN(ctx, check, strings.TrimPrefix(fqdn, "*."))
+ if err != nil {
+ return "", "", err
+ }
+ if host == "@" {
+ return zone, "*", nil
+ }
+ return zone, "*." + host, nil
+ }
+
+ parts := strings.Split(fqdn, ".")
+ if len(parts) < 2 {
+ return "", "", fmt.Errorf("域名格式无效: %s", fqdn)
+ }
+
+ for i := 0; i < len(parts)-1; i++ {
+ candidate := strings.Join(parts[i:], ".")
+ ok, err := check(candidate)
+ if err != nil {
+ return "", "", err
+ }
+ if !ok {
+ continue
+ }
+ host := "@"
+ if i > 0 {
+ host = strings.Join(parts[:i], ".")
+ }
+ return candidate, host, nil
+ }
+ return "", "", fmt.Errorf("未在 DNS 服务商中找到域名 %s 对应的 Zone", fqdn)
+}
+
+func FQDNFromRecord(cfg Config, name string) string {
+ name = strings.TrimSpace(strings.ToLower(name))
+ if name == "" {
+ return strings.ToLower(strings.TrimSpace(cfg.RootDomain))
+ }
+ if strings.Contains(name, ".") || strings.HasPrefix(name, "*.") {
+ root, record, err := parseDomainLine(name)
+ if err == nil {
+ return FormatDomainLine(root, record)
+ }
+ return strings.TrimSuffix(name, ".")
+ }
+ return FormatDomainLine(cfg.RootDomain, name)
+}
+
+func isZoneNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "未找到") ||
+ strings.Contains(msg, "not found") ||
+ strings.Contains(msg, "resourcenotfound") ||
+ strings.Contains(msg, "invaliddomain")
+}
+
+func zoneLookupOK(err error) (bool, error) {
+ if err == nil {
+ return true, nil
+ }
+ if isZoneNotFound(err) {
+ return false, nil
+ }
+ return false, err
+}
diff --git a/internal/ddns/zone_test.go b/internal/ddns/zone_test.go
new file mode 100644
index 0000000..4cc9216
--- /dev/null
+++ b/internal/ddns/zone_test.go
@@ -0,0 +1,74 @@
+package ddns
+
+import (
+ "context"
+ "testing"
+)
+
+func TestResolveZoneForFQDNDelegatedSubdomain(t *testing.T) {
+ zones := map[string]bool{
+ "sub.example.com": true,
+ }
+ zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+ return zones[candidate], nil
+ }, "sub.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if zone != "sub.example.com" || host != "@" {
+ t.Fatalf("unexpected zone=%s host=%s", zone, host)
+ }
+}
+
+func TestResolveZoneForFQDNPrefersLongestZone(t *testing.T) {
+ zones := map[string]bool{
+ "sub.example.com": true,
+ "example.com": true,
+ }
+ zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+ return zones[candidate], nil
+ }, "www.sub.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if zone != "sub.example.com" || host != "www" {
+ t.Fatalf("unexpected zone=%s host=%s", zone, host)
+ }
+}
+
+func TestResolveZoneForFQDNClassicSubdomain(t *testing.T) {
+ zones := map[string]bool{
+ "example.com": true,
+ }
+ zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+ return zones[candidate], nil
+ }, "s.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if zone != "example.com" || host != "s" {
+ t.Fatalf("unexpected zone=%s host=%s", zone, host)
+ }
+}
+
+func TestResolveZoneForFQDNWildcard(t *testing.T) {
+ zones := map[string]bool{
+ "sub.example.com": true,
+ }
+ zone, host, err := ResolveZoneForFQDN(context.Background(), func(candidate string) (bool, error) {
+ return zones[candidate], nil
+ }, "*.sub.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if zone != "sub.example.com" || host != "*" {
+ t.Fatalf("unexpected zone=%s host=%s", zone, host)
+ }
+}
+
+func TestFQDNFromRecordStoredFQDN(t *testing.T) {
+ cfg := Config{RootDomain: "example.com"}
+ if FQDNFromRecord(cfg, "sub.example.com") != "sub.example.com" {
+ t.Fatal("expected stored fqdn preserved")
+ }
+}
diff --git a/internal/discovery/service.go b/internal/discovery/service.go
new file mode 100644
index 0000000..fc165de
--- /dev/null
+++ b/internal/discovery/service.go
@@ -0,0 +1,127 @@
+package discovery
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type Service struct {
+ client *http.Client
+}
+
+type Result struct {
+ Name string `json:"name"`
+ Port int `json:"port"`
+ Host string `json:"host"`
+ Upstream string `json:"upstream"`
+ Detected bool `json:"detected"`
+ Platform string `json:"platform"`
+ Title string `json:"title,omitempty"`
+ Suggestion string `json:"suggestion,omitempty"`
+}
+
+type catalogEntry struct {
+ Name string
+ Port int
+ Platform string
+ Keywords []string
+}
+
+var catalog = []catalogEntry{
+ {Name: "飞牛 fnOS", Port: 5666, Platform: "fnos", Keywords: []string{"fnos", "飞牛", "fnos.cn"}},
+ {Name: "AList", Port: 5244, Platform: "alist", Keywords: []string{"alist"}},
+ {Name: "Jellyfin", Port: 8096, Platform: "jellyfin", Keywords: []string{"jellyfin"}},
+ {Name: "PhotoPrism", Port: 2342, Platform: "photoprism", Keywords: []string{"photoprism"}},
+ {Name: "qBittorrent", Port: 8080, Platform: "qbittorrent", Keywords: []string{"qbittorrent"}},
+ {Name: "Portainer", Port: 9000, Platform: "portainer", Keywords: []string{"portainer"}},
+}
+
+func New() *Service {
+ return &Service{
+ client: &http.Client{Timeout: 2 * time.Second},
+ }
+}
+
+func (s *Service) Scan(ctx context.Context, host string) []Result {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ host = "127.0.0.1"
+ }
+ out := make([]Result, 0, len(catalog))
+ for _, item := range catalog {
+ result := Result{
+ Name: item.Name,
+ Port: item.Port,
+ Host: host,
+ Upstream: fmt.Sprintf("http://%s:%d", host, item.Port),
+ Platform: item.Platform,
+ }
+ if !s.portOpen(ctx, host, item.Port) {
+ out = append(out, result)
+ continue
+ }
+ result.Detected = true
+ title, body := s.probeHTTP(ctx, host, item.Port)
+ result.Title = title
+ if s.matchPlatform(item, title, body) {
+ result.Platform = item.Platform
+ if item.Platform == "fnos" {
+ result.Suggestion = "检测到飞牛 fnOS 服务,可使用 nas.<域名> 创建反向代理"
+ }
+ }
+ out = append(out, result)
+ }
+ return out
+}
+
+func (s *Service) portOpen(ctx context.Context, host string, port int) bool {
+ dialer := net.Dialer{Timeout: 800 * time.Millisecond}
+ conn, err := dialer.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", host, port))
+ if err != nil {
+ return false
+ }
+ conn.Close()
+ return true
+}
+
+func (s *Service) probeHTTP(ctx context.Context, host string, port int) (string, string) {
+ url := fmt.Sprintf("http://%s:%d/", host, port)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return "", ""
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return "", ""
+ }
+ defer resp.Body.Close()
+ bodyBytes := make([]byte, 4096)
+ n, _ := resp.Body.Read(bodyBytes)
+ body := strings.ToLower(string(bodyBytes[:n]))
+ title := extractTitle(body)
+ return title, body
+}
+
+func (s *Service) matchPlatform(item catalogEntry, title, body string) bool {
+ content := strings.ToLower(title + " " + body)
+ for _, keyword := range item.Keywords {
+ if strings.Contains(content, strings.ToLower(keyword)) {
+ return true
+ }
+ }
+ return false
+}
+
+func extractTitle(body string) string {
+ lower := strings.ToLower(body)
+ start := strings.Index(lower, "")
+ end := strings.Index(lower, " ")
+ if start >= 0 && end > start {
+ return strings.TrimSpace(body[start+7 : end])
+ }
+ return ""
+}
diff --git a/internal/frp-1.0.2/config.go b/internal/frp-1.0.2/config.go
new file mode 100644
index 0000000..e6a731b
--- /dev/null
+++ b/internal/frp-1.0.2/config.go
@@ -0,0 +1,208 @@
+package frp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+const (
+ maskedToken = "********"
+)
+
+type Config struct {
+ Enabled bool `json:"enabled"`
+ ServerAddr string `json:"server_addr"`
+ ServerPort int `json:"server_port"`
+ AuthToken string `json:"auth_token"`
+ HasAuthToken bool `json:"has_auth_token"`
+ TLSEnabled bool `json:"tls_enabled"`
+ CustomDomains []string `json:"custom_domains"`
+}
+
+type SaveInput struct {
+ Enabled bool
+ ServerAddr string
+ ServerPort int
+ AuthToken string
+ TLSEnabled bool
+ CustomDomains []string
+}
+
+type Store struct {
+ settings *settings.Store
+ secretBox *secret.Box
+}
+
+func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
+ return &Store{settings: settingsStore, secretBox: secretBox}
+}
+
+func (s *Store) Load(ctx context.Context) (Config, error) {
+ enabled, _ := s.settings.GetBool(ctx, settings.KeyFRPEnabled)
+ serverAddr, _ := s.settings.Get(ctx, settings.KeyFRPServerAddr)
+ serverPort, _ := s.settings.GetInt(ctx, settings.KeyFRPServerPort)
+ if serverPort <= 0 {
+ serverPort = 7000
+ }
+ tlsEnabled, _ := s.settings.GetBool(ctx, settings.KeyFRPTLSEnabled)
+ domains, err := s.loadDomains(ctx)
+ if err != nil {
+ return Config{}, err
+ }
+ encToken, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
+ hasToken := strings.TrimSpace(encToken) != ""
+ token := ""
+ if hasToken {
+ token = maskedToken
+ }
+ return Config{
+ Enabled: enabled,
+ ServerAddr: serverAddr,
+ ServerPort: serverPort,
+ AuthToken: token,
+ HasAuthToken: hasToken,
+ TLSEnabled: tlsEnabled,
+ CustomDomains: domains,
+ }, nil
+}
+
+func (s *Store) LoadRuntime(ctx context.Context) (Config, string, error) {
+ cfg, err := s.Load(ctx)
+ if err != nil {
+ return Config{}, "", err
+ }
+ if !cfg.HasAuthToken {
+ return cfg, "", nil
+ }
+ enc, _ := s.settings.Get(ctx, settings.KeyFRPAuthToken)
+ plain, err := s.secretBox.Decrypt(enc)
+ if err != nil {
+ return cfg, "", secret.DecryptHint(err)
+ }
+ return cfg, plain, nil
+}
+
+func (s *Store) Save(ctx context.Context, in SaveInput, keepToken bool) error {
+ if err := validateSaveInput(in, keepToken); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyFRPEnabled, in.Enabled); err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyFRPServerAddr, normalizeServerAddr(in.ServerAddr)); err != nil {
+ return err
+ }
+ port := in.ServerPort
+ if port <= 0 {
+ port = 7000
+ }
+ if err := s.settings.SetInt(ctx, settings.KeyFRPServerPort, port); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyFRPTLSEnabled, in.TLSEnabled); err != nil {
+ return err
+ }
+ domains := normalizeDomains(in.CustomDomains)
+ raw, err := json.Marshal(domains)
+ if err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyFRPCustomDomains, string(raw)); err != nil {
+ return err
+ }
+ token := strings.TrimSpace(in.AuthToken)
+ if token != "" && token != maskedToken {
+ enc, err := s.secretBox.Encrypt(token)
+ if err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyFRPAuthToken, enc); err != nil {
+ return err
+ }
+ } else if in.Enabled && !keepToken {
+ return fmt.Errorf("请填写 FRP 认证 Token")
+ }
+ return nil
+}
+
+func (s *Store) SetLastError(ctx context.Context, msg string) error {
+ return s.settings.Set(ctx, settings.KeyFRPLastError, strings.TrimSpace(msg))
+}
+
+func (s *Store) LastError(ctx context.Context) string {
+ v, _ := s.settings.Get(ctx, settings.KeyFRPLastError)
+ return strings.TrimSpace(v)
+}
+
+func (s *Store) StartedAt(ctx context.Context) string {
+ v, _ := s.settings.Get(ctx, settings.KeyFRPStartedAt)
+ return strings.TrimSpace(v)
+}
+
+func (s *Store) SetStartedAt(ctx context.Context, value string) error {
+ return s.settings.Set(ctx, settings.KeyFRPStartedAt, strings.TrimSpace(value))
+}
+
+func (s *Store) loadDomains(ctx context.Context) ([]string, error) {
+ raw, _ := s.settings.Get(ctx, settings.KeyFRPCustomDomains)
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, nil
+ }
+ var domains []string
+ if err := json.Unmarshal([]byte(raw), &domains); err != nil {
+ return nil, fmt.Errorf("FRP 域名配置无效")
+ }
+ return normalizeDomains(domains), nil
+}
+
+func normalizeServerAddr(addr string) string {
+ addr = strings.TrimSpace(addr)
+ addr = strings.TrimPrefix(addr, "https://")
+ addr = strings.TrimPrefix(addr, "http://")
+ if idx := strings.IndexAny(addr, "/?#"); idx >= 0 {
+ addr = addr[:idx]
+ }
+ return strings.TrimSpace(addr)
+}
+
+func normalizeDomains(domains []string) []string {
+ seen := make(map[string]bool)
+ var out []string
+ for _, d := range domains {
+ d = strings.TrimSpace(d)
+ if d == "" || seen[strings.ToLower(d)] {
+ continue
+ }
+ seen[strings.ToLower(d)] = true
+ out = append(out, d)
+ }
+ return out
+}
+
+func validateSaveInput(in SaveInput, keepToken bool) error {
+ if !in.Enabled {
+ return nil
+ }
+ if strings.TrimSpace(in.ServerAddr) == "" {
+ return fmt.Errorf("请填写 FRP 服务器地址")
+ }
+ if in.ServerPort < 0 || in.ServerPort > 65535 {
+ return fmt.Errorf("FRP 服务器端口无效")
+ }
+ if len(normalizeDomains(in.CustomDomains)) == 0 {
+ return fmt.Errorf("请至少填写一个穿透域名")
+ }
+ token := strings.TrimSpace(in.AuthToken)
+ if token == "" || token == maskedToken {
+ if !keepToken {
+ return fmt.Errorf("请填写 FRP 认证 Token")
+ }
+ }
+ return nil
+}
diff --git a/internal/frp-1.0.2/config_test.go b/internal/frp-1.0.2/config_test.go
new file mode 100644
index 0000000..4be98f0
--- /dev/null
+++ b/internal/frp-1.0.2/config_test.go
@@ -0,0 +1,12 @@
+package frp
+
+import "testing"
+
+func TestNormalizeServerAddr(t *testing.T) {
+ if got := normalizeServerAddr("http://127.0.0.1"); got != "127.0.0.1" {
+ t.Fatalf("got %q", got)
+ }
+ if got := normalizeServerAddr("https://frp.example.com/path"); got != "frp.example.com" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/internal/frp-1.0.2/generate.go b/internal/frp-1.0.2/generate.go
new file mode 100644
index 0000000..d3736de
--- /dev/null
+++ b/internal/frp-1.0.2/generate.go
@@ -0,0 +1,70 @@
+package frp
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func Generate(cfg config.Config, frpCfg Config, authToken string, httpPort, httpsPort int) (string, error) {
+ if strings.TrimSpace(frpCfg.ServerAddr) == "" {
+ return "", fmt.Errorf("FRP 服务器地址未配置")
+ }
+ if strings.TrimSpace(authToken) == "" {
+ return "", fmt.Errorf("FRP 认证 Token 未配置")
+ }
+ domains := normalizeDomains(frpCfg.CustomDomains)
+ if len(domains) == 0 {
+ return "", fmt.Errorf("请至少填写一个穿透域名")
+ }
+ if httpPort <= 0 || httpsPort <= 0 {
+ return "", fmt.Errorf("Nginx 端口未配置")
+ }
+
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("serverAddr = %q\n", frpCfg.ServerAddr))
+ b.WriteString(fmt.Sprintf("serverPort = %d\n\n", frpCfg.ServerPort))
+
+ b.WriteString(fmt.Sprintf("log.to = %q\n", tomlPath(cfg.FrpLogPath())))
+ b.WriteString("log.level = \"info\"\n\n")
+
+ b.WriteString("auth.method = \"token\"\n")
+ b.WriteString(fmt.Sprintf("auth.token = %q\n\n", authToken))
+
+ if frpCfg.TLSEnabled {
+ b.WriteString("transport.tls.enable = true\n\n")
+ } else {
+ b.WriteString("transport.tls.enable = false\n\n")
+ }
+
+ b.WriteString("[[proxies]]\n")
+ b.WriteString("name = \"fonu-nginx-http\"\n")
+ b.WriteString("type = \"http\"\n")
+ b.WriteString("localIP = \"127.0.0.1\"\n")
+ b.WriteString(fmt.Sprintf("localPort = %d\n", httpPort))
+ b.WriteString(fmt.Sprintf("customDomains = %s\n\n", formatTOMLStringArray(domains)))
+
+ b.WriteString("[[proxies]]\n")
+ b.WriteString("name = \"fonu-nginx-https\"\n")
+ b.WriteString("type = \"https\"\n")
+ b.WriteString(fmt.Sprintf("customDomains = %s\n", formatTOMLStringArray(domains)))
+ b.WriteString("[proxies.plugin]\n")
+ b.WriteString("type = \"https2https\"\n")
+ b.WriteString(fmt.Sprintf("localAddr = \"127.0.0.1:%d\"\n", httpsPort))
+
+ return b.String(), nil
+}
+
+func tomlPath(path string) string {
+ return filepath.ToSlash(filepath.Clean(path))
+}
+
+func formatTOMLStringArray(items []string) string {
+ parts := make([]string, 0, len(items))
+ for _, item := range items {
+ parts = append(parts, fmt.Sprintf("%q", item))
+ }
+ return "[" + strings.Join(parts, ", ") + "]"
+}
diff --git a/internal/frp-1.0.2/generate_test.go b/internal/frp-1.0.2/generate_test.go
new file mode 100644
index 0000000..bab99a0
--- /dev/null
+++ b/internal/frp-1.0.2/generate_test.go
@@ -0,0 +1,58 @@
+package frp
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func TestGenerateNginxGateway(t *testing.T) {
+ cfg := config.Config{
+ DataDir: "/data",
+ NginxDefaultHTTPPort: 18080,
+ NginxDefaultHTTPSPort: 9443,
+ FrpPIDFile: "/data/frp/frpc.pid",
+ }
+ frpCfg := Config{
+ ServerAddr: "vps.example.com",
+ ServerPort: 7000,
+ TLSEnabled: true,
+ CustomDomains: []string{"nas.example.com", "*.example.com"},
+ }
+ out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, `serverAddr = "vps.example.com"`) {
+ t.Fatalf("missing server addr: %s", out)
+ }
+ if !strings.Contains(out, `localPort = 18080`) {
+ t.Fatalf("missing http local port: %s", out)
+ }
+ if !strings.Contains(out, `type = "https2https"`) {
+ t.Fatalf("missing https plugin: %s", out)
+ }
+ if !strings.Contains(out, `localAddr = "127.0.0.1:9443"`) {
+ t.Fatalf("missing https local addr: %s", out)
+ }
+ if !strings.Contains(out, `transport.tls.enable = true`) {
+ t.Fatalf("missing tls enable: %s", out)
+ }
+}
+
+func TestGenerateDisablesTLSByDefault(t *testing.T) {
+ cfg := config.Config{DataDir: "/data", FrpPIDFile: "/data/frp/frpc.pid"}
+ frpCfg := Config{
+ ServerAddr: "vps.example.com",
+ ServerPort: 7000,
+ CustomDomains: []string{"nas.example.com"},
+ }
+ out, err := Generate(cfg, frpCfg, "secret-token", 18080, 9443)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, `transport.tls.enable = false`) {
+ t.Fatalf("expected tls disabled: %s", out)
+ }
+}
diff --git a/internal/frp-1.0.2/logs.go b/internal/frp-1.0.2/logs.go
new file mode 100644
index 0000000..e7560b8
--- /dev/null
+++ b/internal/frp-1.0.2/logs.go
@@ -0,0 +1,27 @@
+package frp
+
+import (
+ "os"
+ "strings"
+)
+
+func ReadLogTail(path string, limit int) ([]string, error) {
+ if limit <= 0 {
+ limit = 100
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []string{}, nil
+ }
+ return nil, err
+ }
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") {
+ return []string{}, nil
+ }
+ if len(lines) > limit {
+ lines = lines[len(lines)-limit:]
+ }
+ return lines, nil
+}
diff --git a/internal/frp-1.0.2/manager.go b/internal/frp-1.0.2/manager.go
new file mode 100644
index 0000000..6744016
--- /dev/null
+++ b/internal/frp-1.0.2/manager.go
@@ -0,0 +1,421 @@
+package frp
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/nginx"
+)
+
+const (
+ frpcCmdTimeout = 8 * time.Second
+ frpcStartWait = 5 * time.Second
+ frpcTerminateWait = 3 * time.Second
+)
+
+type Manager struct {
+ cfg config.Config
+ store *Store
+ logger *slog.Logger
+}
+
+type ApplyResult struct {
+ Message string
+}
+
+func NewManager(cfg config.Config, store *Store, logger *slog.Logger) *Manager {
+ return &Manager{cfg: cfg, store: store, logger: logger.With("module", "FRP")}
+}
+
+func (m *Manager) EnsureDirs() error {
+ return os.MkdirAll(m.cfg.FrpDir(), 0o755)
+}
+
+func (m *Manager) Apply(ctx context.Context, in SaveInput) (ApplyResult, error) {
+ existing, err := m.store.Load(ctx)
+ if err != nil {
+ return ApplyResult{}, err
+ }
+ keepToken := existing.HasAuthToken && (strings.TrimSpace(in.AuthToken) == "" || in.AuthToken == maskedToken)
+ if err := m.store.Save(ctx, in, keepToken); err != nil {
+ return ApplyResult{}, err
+ }
+
+ cfg, err := m.store.Load(ctx)
+ if err != nil {
+ return ApplyResult{}, err
+ }
+
+ if !cfg.Enabled {
+ if err := m.Stop(ctx); err != nil {
+ return ApplyResult{}, err
+ }
+ m.clearStarted(ctx)
+ _ = m.store.SetLastError(ctx, "")
+ return ApplyResult{Message: "FRP 已停用"}, nil
+ }
+
+ runtimeCfg, token, err := m.store.LoadRuntime(ctx)
+ if err != nil {
+ _ = m.store.SetLastError(ctx, err.Error())
+ return ApplyResult{}, err
+ }
+
+ if err := m.EnsureDirs(); err != nil {
+ return ApplyResult{}, err
+ }
+
+ content, err := Generate(m.cfg, runtimeCfg, token, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort)
+ if err != nil {
+ _ = m.store.SetLastError(ctx, err.Error())
+ return ApplyResult{}, err
+ }
+
+ tmpPath := m.cfg.FrpConfigPath() + ".tmp"
+ if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
+ return ApplyResult{}, err
+ }
+ if err := os.Rename(tmpPath, m.cfg.FrpConfigPath()); err != nil {
+ _ = os.Remove(tmpPath)
+ return ApplyResult{}, err
+ }
+
+ if err := m.verifyConfig(ctx); err != nil {
+ _ = m.store.SetLastError(ctx, err.Error())
+ return ApplyResult{}, err
+ }
+
+ if !m.available() {
+ msg := "frpc 未安装,配置已保存"
+ _ = m.store.SetLastError(ctx, msg)
+ return ApplyResult{Message: msg}, nil
+ }
+
+ running, _ := m.isRunning()
+ if !running {
+ if err := m.start(ctx); err != nil {
+ _ = m.store.SetLastError(ctx, err.Error())
+ return ApplyResult{}, err
+ }
+ m.markStarted(ctx)
+ _ = m.store.SetLastError(ctx, "")
+ return ApplyResult{Message: "FRP 已启动"}, nil
+ }
+
+ if err := m.reload(ctx); err != nil {
+ m.logger.Warn("frpc reload failed, trying restart", "error", err)
+ if err := m.forceRestart(ctx); err != nil {
+ _ = m.store.SetLastError(ctx, err.Error())
+ return ApplyResult{}, err
+ }
+ m.markStarted(ctx)
+ _ = m.store.SetLastError(ctx, "")
+ return ApplyResult{Message: "FRP 已重新启动"}, nil
+ }
+ _ = m.store.SetLastError(ctx, "")
+ return ApplyResult{Message: "FRP 已重载"}, nil
+}
+
+func (m *Manager) Bootstrap(ctx context.Context) error {
+ cfg, err := m.store.Load(ctx)
+ if err != nil || !cfg.Enabled {
+ return nil
+ }
+ if !m.available() {
+ m.logger.Warn("frpc binary not found, skipping bootstrap", "bin", m.cfg.FrpcBin)
+ return nil
+ }
+ _, err = m.Apply(ctx, SaveInput{
+ Enabled: cfg.Enabled,
+ ServerAddr: cfg.ServerAddr,
+ ServerPort: cfg.ServerPort,
+ AuthToken: maskedToken,
+ TLSEnabled: cfg.TLSEnabled,
+ CustomDomains: cfg.CustomDomains,
+ })
+ if err != nil {
+ m.logger.Warn("initial frpc apply skipped", "error", err.Error())
+ }
+ return nil
+}
+
+func (m *Manager) Load(ctx context.Context) (Config, error) {
+ return m.store.Load(ctx)
+}
+
+func (m *Manager) Status(ctx context.Context) Status {
+ cfg, err := m.store.Load(ctx)
+ if err != nil {
+ return Status{Message: "读取配置失败"}
+ }
+ return BuildStatus(
+ m.cfg,
+ cfg,
+ cfg.Enabled,
+ m.store.LastError(ctx),
+ m.store.StartedAt(ctx),
+ ResolveClientVersion(m.cfg.FrpcBin),
+ )
+}
+
+func (m *Manager) SyncDomains(ctx context.Context, domains []string) (ApplyResult, error) {
+ cfg, err := m.store.Load(ctx)
+ if err != nil {
+ return ApplyResult{}, err
+ }
+ merged := MergeDomains(cfg.CustomDomains, domains)
+ return m.Apply(ctx, SaveInput{
+ Enabled: cfg.Enabled,
+ ServerAddr: cfg.ServerAddr,
+ ServerPort: cfg.ServerPort,
+ AuthToken: maskedToken,
+ TLSEnabled: cfg.TLSEnabled,
+ CustomDomains: merged,
+ })
+}
+
+func (m *Manager) Logs(ctx context.Context, limit int) ([]string, error) {
+ return ReadLogTail(m.cfg.FrpLogPath(), limit)
+}
+
+func (m *Manager) FRPSConfig(ctx context.Context) (string, error) {
+ cfg, err := m.store.Load(ctx)
+ if err != nil {
+ return "", err
+ }
+ _, token, err := m.store.LoadRuntime(ctx)
+ if err != nil {
+ return "", err
+ }
+ port := cfg.ServerPort
+ if port <= 0 {
+ port = 7000
+ }
+ return BuildFRPSConfig(port, m.cfg.NginxDefaultHTTPPort, m.cfg.NginxDefaultHTTPSPort, token), nil
+}
+
+func (m *Manager) NginxHTTPPort() int {
+ return m.cfg.NginxDefaultHTTPPort
+}
+
+func (m *Manager) NginxHTTPSPort() int {
+ return m.cfg.NginxDefaultHTTPSPort
+}
+
+func (m *Manager) verifyConfig(ctx context.Context) error {
+ if !m.available() {
+ return nil
+ }
+ return m.runFrpc(ctx, []string{"verify", "-c", m.cfg.FrpConfigPath()}, "FRP 配置校验失败")
+}
+
+func (m *Manager) markStarted(ctx context.Context) {
+ _ = m.store.SetStartedAt(ctx, time.Now().UTC().Format(time.RFC3339))
+}
+
+func (m *Manager) clearStarted(ctx context.Context) {
+ _ = m.store.SetStartedAt(ctx, "")
+}
+
+func (m *Manager) Stop(ctx context.Context) error {
+ if !m.isRunningQuick() {
+ removePIDFile(m.cfg.FrpPIDFile)
+ return nil
+ }
+ pid, err := readPIDFile(m.cfg.FrpPIDFile)
+ if err != nil {
+ removePIDFile(m.cfg.FrpPIDFile)
+ return nil
+ }
+ _ = terminateProcess(pid, syscall.SIGTERM)
+ waitProcessExit(pid, frpcTerminateWait)
+ if nginx.IsPIDAlive(pid) {
+ _ = terminateProcess(pid, syscall.SIGKILL)
+ waitProcessExit(pid, time.Second)
+ }
+ removePIDFile(m.cfg.FrpPIDFile)
+ m.clearStarted(ctx)
+ m.logger.Info("frpc stopped")
+ return nil
+}
+
+func (m *Manager) available() bool {
+ _, err := exec.LookPath(m.cfg.FrpcBin)
+ return err == nil
+}
+
+func (m *Manager) start(ctx context.Context) error {
+ if m.isRunningQuick() {
+ return m.reload(ctx)
+ }
+ args := []string{"-c", m.cfg.FrpConfigPath()}
+ if err := m.runFrpcStart(ctx, args, "FRP 启动失败"); err != nil {
+ return err
+ }
+ m.logger.Info("frpc started")
+ return nil
+}
+
+func (m *Manager) reload(ctx context.Context) error {
+ args := []string{"reload", "-c", m.cfg.FrpConfigPath()}
+ if err := m.runFrpc(ctx, args, "FRP 重载失败"); err != nil {
+ return err
+ }
+ m.logger.Info("frpc reloaded")
+ return nil
+}
+
+func (m *Manager) forceRestart(ctx context.Context) error {
+ _ = m.Stop(ctx)
+ return m.start(ctx)
+}
+
+func (m *Manager) runFrpc(ctx context.Context, args []string, prefix string) error {
+ ctx, cancel := context.WithTimeout(ctx, frpcCmdTimeout)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, m.cfg.FrpcBin, args...)
+ var stderr, stdout bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = &stdout
+ if err := cmd.Run(); err != nil {
+ msg := frpcCommandError(stdout.String(), stderr.String(), err)
+ if ctx.Err() == context.DeadlineExceeded {
+ msg = "操作超时"
+ }
+ return fmt.Errorf("%s:%s", prefix, msg)
+ }
+ return nil
+}
+
+func (m *Manager) runFrpcStart(ctx context.Context, args []string, prefix string) error {
+ cmd := exec.Command(m.cfg.FrpcBin, args...)
+ var stderr, stdout bytes.Buffer
+ cmd.Stderr = &stderr
+ cmd.Stdout = &stdout
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("%s:%s", prefix, err.Error())
+ }
+ if cmd.Process != nil {
+ if err := writePIDFile(m.cfg.FrpPIDFile, cmd.Process.Pid); err != nil {
+ _ = cmd.Process.Kill()
+ return fmt.Errorf("%s:%s", prefix, err.Error())
+ }
+ }
+
+ waitDone := make(chan error, 1)
+ go func() { waitDone <- cmd.Wait() }()
+
+ deadline := time.Now().Add(frpcStartWait)
+ for time.Now().Before(deadline) {
+ if running, _ := m.isRunning(); running {
+ return nil
+ }
+ select {
+ case err := <-waitDone:
+ if running, _ := m.isRunning(); running {
+ return nil
+ }
+ msg := frpcCommandError(stdout.String(), stderr.String(), err)
+ m.logger.Warn("frpc exited during start", "error", msg)
+ return fmt.Errorf("%s:%s", prefix, msg)
+ default:
+ }
+ if ctx.Err() != nil {
+ return fmt.Errorf("%s:%s", prefix, "操作超时")
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if running, _ := m.isRunning(); running {
+ return nil
+ }
+ msg := frpcCommandError(stdout.String(), stderr.String(), nil)
+ if msg == "" {
+ msg = "未检测到运行中的进程"
+ }
+ return fmt.Errorf("%s:%s", prefix, msg)
+}
+
+func frpcCommandError(stdout, stderr string, err error) string {
+ msg := strings.TrimSpace(stderr)
+ if msg == "" {
+ msg = strings.TrimSpace(stdout)
+ }
+ if msg == "" && err != nil {
+ msg = err.Error()
+ }
+ if msg == "" {
+ msg = "进程已退出"
+ }
+ return msg
+}
+
+func writePIDFile(path string, pid int) error {
+ if pid <= 0 {
+ return fmt.Errorf("无效的 frpc 进程 ID")
+ }
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(path, []byte(fmt.Sprintf("%d\n", pid)), 0o644)
+}
+
+func (m *Manager) isRunning() (bool, error) {
+ pid, err := readPIDFile(m.cfg.FrpPIDFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ removePIDFile(m.cfg.FrpPIDFile)
+ return false, nil
+ }
+ if !nginx.IsPIDAlive(pid) {
+ removePIDFile(m.cfg.FrpPIDFile)
+ return false, nil
+ }
+ return true, nil
+}
+
+func (m *Manager) isRunningQuick() bool {
+ ok, _ := m.isRunning()
+ return ok
+}
+
+func readPIDFile(path string) (int, error) {
+ return nginx.ReadPID(path)
+}
+
+func removePIDFile(path string) {
+ _ = os.Remove(path)
+}
+
+func waitProcessExit(pid int, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if !nginx.IsPIDAlive(pid) {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
+
+func terminateProcess(pid int, sig syscall.Signal) error {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return err
+ }
+ return proc.Signal(sig)
+}
+
+func FrpConfigDir(cfg config.Config) string {
+ return filepath.Clean(cfg.FrpDir())
+}
diff --git a/internal/frp-1.0.2/status.go b/internal/frp-1.0.2/status.go
new file mode 100644
index 0000000..50cad25
--- /dev/null
+++ b/internal/frp-1.0.2/status.go
@@ -0,0 +1,157 @@
+package frp
+
+import (
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/nginx"
+)
+
+type Status struct {
+ Enabled bool `json:"enabled"`
+ Connected bool `json:"connected"`
+ Message string `json:"message"`
+ LastError string `json:"last_error,omitempty"`
+ ServerEndpoint string `json:"server_endpoint,omitempty"`
+ ConnectedAt string `json:"connected_at,omitempty"`
+ UptimeSeconds int64 `json:"uptime_seconds"`
+ ClientVersion string `json:"client_version,omitempty"`
+ HTTPGatewayEnabled bool `json:"http_gateway_enabled"`
+ HTTPSGatewayEnabled bool `json:"https_gateway_enabled"`
+ SyncedDomainCount int `json:"synced_domain_count"`
+}
+
+func BuildStatus(cfg config.Config, frpCfg Config, enabled bool, lastError, startedAt, clientVersion string) Status {
+ st := Status{
+ Enabled: enabled,
+ Connected: false,
+ Message: "未启用",
+ LastError: strings.TrimSpace(lastError),
+ ClientVersion: clientVersion,
+ SyncedDomainCount: len(frpCfg.CustomDomains),
+ }
+ if enabled {
+ addr := strings.TrimSpace(frpCfg.ServerAddr)
+ if addr != "" {
+ port := frpCfg.ServerPort
+ if port <= 0 {
+ port = 7000
+ }
+ st.ServerEndpoint = addr + ":" + strconv.Itoa(port)
+ }
+ st.HTTPGatewayEnabled = len(frpCfg.CustomDomains) > 0
+ st.HTTPSGatewayEnabled = len(frpCfg.CustomDomains) > 0
+ }
+ if !enabled {
+ return st
+ }
+ running, err := isProcessRunning(cfg.FrpPIDFile)
+ if err != nil || !running {
+ st.Message = "未连接"
+ if st.LastError != "" {
+ st.Message = st.LastError
+ } else if hint := readLogHint(cfg.FrpLogPath()); hint != "" {
+ st.Message = hint
+ }
+ return st
+ }
+ ok, logHint := logIndicatesConnected(cfg.FrpLogPath())
+ if ok {
+ st.Connected = true
+ st.Message = "已连接"
+ st.LastError = ""
+ applyConnectedTiming(&st, startedAt)
+ return st
+ }
+ st.Message = "连接中"
+ if logHint != "" {
+ st.Message = logHint
+ }
+ if st.LastError != "" {
+ st.Message = st.LastError
+ }
+ return st
+}
+
+func applyConnectedTiming(st *Status, startedAt string) {
+ st.ConnectedAt = strings.TrimSpace(startedAt)
+ if st.ConnectedAt == "" {
+ return
+ }
+ t, err := time.Parse(time.RFC3339, st.ConnectedAt)
+ if err != nil {
+ return
+ }
+ st.UptimeSeconds = int64(time.Since(t).Seconds())
+ if st.UptimeSeconds < 0 {
+ st.UptimeSeconds = 0
+ }
+}
+
+func isProcessRunning(pidFile string) (bool, error) {
+ pid, err := nginx.ReadPID(pidFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ return false, err
+ }
+ return nginx.IsPIDAlive(pid), nil
+}
+
+func readLogHint(path string) string {
+ lines := tailLines(path, 40)
+ for i := len(lines) - 1; i >= 0; i-- {
+ line := strings.ToLower(lines[i])
+ if strings.Contains(line, "login to server success") {
+ return ""
+ }
+ if strings.Contains(line, "connect to server error") ||
+ strings.Contains(line, "authorization failed") ||
+ strings.Contains(line, "token in login doesn't match") {
+ return sanitizeLogLine(lines[i])
+ }
+ }
+ return ""
+}
+
+func logIndicatesConnected(path string) (bool, string) {
+ lines := tailLines(path, 80)
+ for i := len(lines) - 1; i >= 0; i-- {
+ line := strings.ToLower(lines[i])
+ if strings.Contains(line, "login to server success") {
+ return true, ""
+ }
+ if strings.Contains(line, "connect to server error") ||
+ strings.Contains(line, "authorization failed") {
+ return false, sanitizeLogLine(lines[i])
+ }
+ }
+ return false, ""
+}
+
+func tailLines(path string, max int) []string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ lines := strings.Split(string(data), "\n")
+ if len(lines) > max {
+ lines = lines[len(lines)-max:]
+ }
+ return lines
+}
+
+func sanitizeLogLine(line string) string {
+ line = strings.TrimSpace(line)
+ if idx := strings.Index(line, "]"); idx >= 0 && idx+1 < len(line) {
+ line = strings.TrimSpace(line[idx+1:])
+ }
+ if len(line) > 200 {
+ line = line[:200] + "…"
+ }
+ return line
+}
diff --git a/internal/frp-1.0.2/sync.go b/internal/frp-1.0.2/sync.go
new file mode 100644
index 0000000..0e52f5e
--- /dev/null
+++ b/internal/frp-1.0.2/sync.go
@@ -0,0 +1,42 @@
+package frp
+
+import (
+ "strings"
+
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func DomainsFromProxyRules(rules []proxy.Rule) []string {
+ seen := make(map[string]bool)
+ var domains []string
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+ for _, host := range rule.Hostnames() {
+ if host == "" || seen[host] {
+ continue
+ }
+ seen[host] = true
+ domains = append(domains, host)
+ }
+ }
+ return domains
+}
+
+func MergeDomains(existing, incoming []string) []string {
+ seen := make(map[string]bool)
+ var out []string
+ for _, list := range [][]string{existing, incoming} {
+ for _, d := range list {
+ d = strings.TrimSpace(d)
+ key := strings.ToLower(d)
+ if d == "" || seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, d)
+ }
+ }
+ return out
+}
diff --git a/internal/frp-1.0.2/sync_test.go b/internal/frp-1.0.2/sync_test.go
new file mode 100644
index 0000000..76ef79b
--- /dev/null
+++ b/internal/frp-1.0.2/sync_test.go
@@ -0,0 +1,19 @@
+package frp
+
+import (
+ "testing"
+
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestDomainsFromProxyRules(t *testing.T) {
+ rules := []proxy.Rule{
+ {Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}}, Domain: "b.example.com"},
+ {Enabled: false, Hosts: []proxy.Host{{Hostname: "skip.example.com"}}},
+ {Enabled: true, Hosts: []proxy.Host{{Hostname: "a.example.com"}, {Hostname: "c.example.com"}}},
+ }
+ domains := DomainsFromProxyRules(rules)
+ if len(domains) != 2 {
+ t.Fatalf("expected 2 domains, got %v", domains)
+ }
+}
diff --git a/internal/frp-1.0.2/template.go b/internal/frp-1.0.2/template.go
new file mode 100644
index 0000000..a5cc7e3
--- /dev/null
+++ b/internal/frp-1.0.2/template.go
@@ -0,0 +1,29 @@
+package frp
+
+import "fmt"
+
+// BuildFRPSConfig generates frps.toml for the user's VPS, aligned with Fonu Nginx ports and auth.
+func BuildFRPSConfig(bindPort, httpPort, httpsPort int, token string) string {
+ if bindPort <= 0 {
+ bindPort = 7000
+ }
+ if httpPort <= 0 {
+ httpPort = 80
+ }
+ if httpsPort <= 0 {
+ httpsPort = 443
+ }
+ if token == "" {
+ token = "your-secret-token"
+ }
+ return fmt.Sprintf(`bindAddr = "0.0.0.0"
+bindPort = %d
+
+vhostHTTPPort = %d
+vhostHTTPSPort = %d
+
+[auth]
+method = "token"
+token = "%s"
+`, bindPort, httpPort, httpsPort, token)
+}
diff --git a/internal/frp-1.0.2/template_test.go b/internal/frp-1.0.2/template_test.go
new file mode 100644
index 0000000..b2fddcc
--- /dev/null
+++ b/internal/frp-1.0.2/template_test.go
@@ -0,0 +1,23 @@
+package frp
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildFRPSConfig(t *testing.T) {
+ out := BuildFRPSConfig(7000, 18080, 9443, "fonu-dev-token")
+ if out == "" {
+ t.Fatal("empty config")
+ }
+ for _, want := range []string{
+ "bindPort = 7000",
+ "vhostHTTPPort = 18080",
+ "vhostHTTPSPort = 9443",
+ `token = "fonu-dev-token"`,
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing %q in:\n%s", want, out)
+ }
+ }
+}
diff --git a/internal/frp-1.0.2/version.go b/internal/frp-1.0.2/version.go
new file mode 100644
index 0000000..af7add1
--- /dev/null
+++ b/internal/frp-1.0.2/version.go
@@ -0,0 +1,27 @@
+package frp
+
+import (
+ "os/exec"
+ "strings"
+)
+
+const defaultClientVersion = "frpc v0.71.0"
+
+func ResolveClientVersion(bin string) string {
+ bin = strings.TrimSpace(bin)
+ if bin == "" {
+ return defaultClientVersion
+ }
+ out, err := exec.Command(bin, "--version").Output()
+ if err != nil {
+ return defaultClientVersion
+ }
+ line := strings.TrimSpace(string(out))
+ if line == "" {
+ return defaultClientVersion
+ }
+ if strings.HasPrefix(line, "frpc version ") {
+ return "frpc v" + strings.TrimPrefix(line, "frpc version ")
+ }
+ return line
+}
diff --git a/internal/frp/agent_client.go b/internal/frp/agent_client.go
new file mode 100644
index 0000000..58a7857
--- /dev/null
+++ b/internal/frp/agent_client.go
@@ -0,0 +1,184 @@
+package frp
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// AgentClient 调用公网 VPS 上的 fonu-agent。凭据由调用方解密后只在内存中使用。
+type AgentClient struct {
+ baseURL string
+ token string
+ http *http.Client
+}
+
+func NewAgentClient(baseURL, token string) *AgentClient {
+ return &AgentClient{
+ baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
+ token: token,
+ // 路由写入链路含 nginx -T 自省 + -t 校验 + reload(宝塔带 lua/waf 模块时各步可达数秒),15s 会超时
+ http: &http.Client{Timeout: 60 * time.Second},
+ }
+}
+
+func (c *AgentClient) request(ctx context.Context, method, path string, payload any, result any) error {
+ if c.baseURL == "" || c.token == "" {
+ return fmt.Errorf("公网 agent 未配置")
+ }
+ var body io.Reader
+ if payload != nil {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(data)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ if payload != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ data, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("agent 返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
+ }
+ if result != nil && len(data) > 0 {
+ if err := json.Unmarshal(data, result); err != nil {
+ return fmt.Errorf("agent 响应格式无效: %w", err)
+ }
+ }
+ return nil
+}
+
+func (c *AgentClient) Ping(ctx context.Context) error {
+ return c.request(ctx, http.MethodGet, "/api/v1/ping", nil, nil)
+}
+
+func (c *AgentClient) Status(ctx context.Context) (map[string]any, error) {
+ var result map[string]any
+ err := c.request(ctx, http.MethodGet, "/api/v1/status", nil, &result)
+ return result, err
+}
+
+func (c *AgentClient) SyncRoute(ctx context.Context, domain, upstream string, tls bool, certDir string) error {
+ _, err := c.SyncRouteEx(ctx, domain, upstream, tls, certDir, false, 0, false, nil, "")
+ return err
+}
+
+// RouteSpec 公网反代单域名部署参数(字段名与 agent routeRequest 的 JSON key 严格一致)。
+type RouteSpec struct {
+ Domain, Upstream, CertDir string
+ TLS, WebSocket, RedirectHTTPS bool
+ HTTPSDisabled, SecurityHeaders bool
+ TLS13Only bool
+ VhostPort int
+ AllowIPs, DenyIPs []string
+ BasicAuthUser, BasicAuthPassword string
+ ClientMaxBodySize, ProxyReadTimeout string
+ RateLimitRate, RateLimitBurst, ConnLimitMax int
+ ChinaOnly bool
+}
+
+// SyncRouteSpec 部署路由并要求回源自检;位置参数已到上限,新增选项一律走本方法。
+func (c *AgentClient) SyncRouteSpec(ctx context.Context, spec RouteSpec) (map[string]any, error) {
+ var result map[string]any
+ err := c.request(ctx, http.MethodPut, "/api/v1/routes", map[string]any{
+ "domain": spec.Domain, "upstream": spec.Upstream, "tls": spec.TLS, "cert_dir": spec.CertDir,
+ "websocket": spec.WebSocket, "vhost_port": spec.VhostPort,
+ "redirect_https": spec.RedirectHTTPS, "allow_ips": spec.AllowIPs, "basic_auth_user": spec.BasicAuthUser,
+ "deny_ips": spec.DenyIPs, "basic_auth_password": spec.BasicAuthPassword,
+ "https_disabled": spec.HTTPSDisabled, "client_max_body_size": spec.ClientMaxBodySize,
+ "proxy_read_timeout": spec.ProxyReadTimeout, "security_headers": spec.SecurityHeaders,
+ "tls13_only": spec.TLS13Only,
+ "rate_limit_rate": spec.RateLimitRate, "rate_limit_burst": spec.RateLimitBurst,
+ "conn_limit_max": spec.ConnLimitMax, "china_only": spec.ChinaOnly,
+ }, &result)
+ return result, err
+}
+
+// InstallNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (c *AgentClient) InstallNginx(ctx context.Context) (map[string]any, error) {
+ var result map[string]any
+ err := c.request(ctx, http.MethodPost, "/api/v1/nginx/install", nil, &result)
+ return result, err
+}
+
+// SyncChinaCIDR 把中国 IP 段(geo include 格式)下发到公网 agent。
+func (c *AgentClient) SyncChinaCIDR(ctx context.Context, content string) error {
+ var result map[string]any
+ return c.request(ctx, http.MethodPut, "/api/v1/china-cidr", map[string]any{"content": content}, &result)
+}
+
+// SyncRouteEx 保留的位置参数兼容包装(新代码请用 SyncRouteSpec)。
+func (c *AgentClient) SyncRouteEx(ctx context.Context, domain, upstream string, tls bool, certDir string, websocket bool, vhostPort int, redirectHTTPS bool, allowIPs []string, basicAuthUser string) (map[string]any, error) {
+ return c.SyncRouteSpec(ctx, RouteSpec{
+ Domain: domain, Upstream: upstream, TLS: tls, CertDir: certDir,
+ WebSocket: websocket, VhostPort: vhostPort, RedirectHTTPS: redirectHTTPS,
+ AllowIPs: allowIPs, BasicAuthUser: basicAuthUser,
+ })
+}
+
+// RouteConf 返回指定域名当前反代配置内容。
+func (c *AgentClient) RouteConf(ctx context.Context, domain string) (string, error) {
+ var result struct {
+ Domain string `json:"domain"`
+ Content string `json:"content"`
+ }
+ if err := c.request(ctx, http.MethodGet, "/api/v1/routes/"+domain+"/conf", nil, &result); err != nil {
+ return "", err
+ }
+ return result.Content, nil
+}
+
+// RouteDetail 单条公网反代路由的真实状态。
+type RouteDetail struct {
+ Domain string `json:"domain"`
+ FileExists bool `json:"file_exists"`
+ SyntaxOK bool `json:"syntax_ok"`
+ Loaded bool `json:"loaded"`
+}
+
+// RoutesDetail 返回全部公网反代路由的状态明细(文件/语法/被 Nginx 加载)。
+func (c *AgentClient) RoutesDetail(ctx context.Context) ([]RouteDetail, error) {
+ var result []RouteDetail
+ err := c.request(ctx, http.MethodGet, "/api/v1/routes", nil, &result)
+ return result, err
+}
+
+func (c *AgentClient) DeleteRoute(ctx context.Context, domain string) error {
+ return c.request(ctx, http.MethodDelete, "/api/v1/routes/"+domain, nil, nil)
+}
+
+func (c *AgentClient) FRPSAction(ctx context.Context, action string) error {
+ return c.request(ctx, http.MethodPost, "/api/v1/frps/action", map[string]string{"action": action}, nil)
+}
+
+func (c *AgentClient) InstallFRPS(ctx context.Context, version, proxy string) (map[string]any, error) {
+ var result map[string]any
+ err := c.request(ctx, http.MethodPost, "/api/v1/frps/install", map[string]string{"version": version, "proxy": proxy}, &result)
+ return result, err
+}
+
+func (c *AgentClient) PushFRPSConfig(ctx context.Context, content string) error {
+ return c.request(ctx, http.MethodPut, "/api/v1/frps/config", map[string]string{"content": content}, nil)
+}
+
+func (c *AgentClient) PushCertificate(ctx context.Context, domain, fullchain, privateKey string) error {
+ return c.request(ctx, http.MethodPost, "/api/v1/certs", map[string]string{
+ "domain": domain, "fullchain_pem": fullchain, "key_pem": privateKey,
+ }, nil)
+}
diff --git a/internal/frp/agent_installer.go b/internal/frp/agent_installer.go
new file mode 100644
index 0000000..28cecf9
--- /dev/null
+++ b/internal/frp/agent_installer.go
@@ -0,0 +1,172 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/frp/agentbin"
+)
+
+// AgentInstaller 负责把内嵌的 fonu-agent 通过 SSH 装到 VPS 上,并收集安装结果。
+type AgentInstaller struct {
+ SSH SSHInstaller
+}
+
+// Probe 返回 VPS 环境摘要:架构、nginx 是否安装、agent 是否已装、systemd 可用性。
+func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
+ client, err := i.SSH.client(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("SSH 连接失败: %w", err)
+ }
+ defer client.Close()
+ out := func(cmd string) string {
+ v, _ := runSSHSession(client, cmd)
+ return strings.TrimSpace(v)
+ }
+ arch := out("uname -m")
+ return map[string]any{
+ "arch": arch,
+ "nginx_conf": out(`conf=$(nginx -V 2>&1 | grep -o '\-\-conf-path=[^ ]*' | head -1 | cut -d= -f2-); [ -z "$conf" ] && conf=/etc/nginx/nginx.conf; grep '^[[:space:]]*include' "$conf" 2>/dev/null | grep -m1 -E 'conf\.d|sites-enabled|vhost/nginx' || ls -d /etc/nginx/conf.d 2>/dev/null`),
+ "agent_exist": out("test -x /usr/local/bin/fonu-agent && echo yes || echo no") == "yes",
+ "agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"),
+ "agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"),
+ "agent_token": out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"),
+ "frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
+ "frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
+ "frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
+ "frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
+ "frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
+ "port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
+ "port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
+ "port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
+ }, nil
+}
+
+// Install 上传内嵌二进制并执行安装脚本;重复执行等于覆盖升级。返回 agent Token。
+func (i AgentInstaller) Install(ctx context.Context) (string, error) {
+ arch, err := i.SSH.Run(ctx, "uname -m")
+ if err != nil {
+ return "", fmt.Errorf("探测架构失败: %w", err)
+ }
+ binary, ok := agentbin.BinaryForArch(strings.TrimSpace(arch))
+ if !ok {
+ return "", fmt.Errorf("不支持的 VPS 架构: %s", strings.TrimSpace(arch))
+ }
+ // 开发环境(go run / 非 Docker 构建)里内嵌的是占位文件;提前报错而不是让 VPS 上 systemd 反复 Exec format error
+ if !isELFBinary(binary) {
+ return "", fmt.Errorf("内嵌 agent 二进制无效(开发构建占位文件)。请用 Docker 构建或先执行构建脚本生成真实 agent:go build -o internal/frp/agentbin/fonu-agent-linux-amd64 ./cmd/fonu-agent(GOOS=linux GOARCH=amd64)")
+ }
+ script, err := agentInstallScript()
+ if err != nil {
+ return "", err
+ }
+ output, err := i.SSH.Install(ctx, binary, script)
+ if err != nil {
+ return "", err
+ }
+ token := extractToken(output)
+ if token == "" {
+ return "", fmt.Errorf("安装完成但未取回 Token: %s", strings.TrimSpace(output))
+ }
+ return token, nil
+}
+
+// Uninstall 停止并移除 agent 服务;--keep-data 保留路由与证书。
+func (i AgentInstaller) Uninstall(ctx context.Context, keepData bool) (string, error) {
+ command := "/usr/local/bin/fonu-agent-uninstall"
+ if keepData {
+ command += " --keep-data"
+ }
+ out, err := i.SSH.Run(ctx, fmt.Sprintf("if [ -x %s ]; then %s; else echo 'agent 未安装'; fi", "'"+command+"'", command))
+ return strings.TrimSpace(out), err
+}
+
+// Diagnose agent 失联时走 SSH 急救:进程、端口、服务日志尾部。
+func (i AgentInstaller) Diagnose(ctx context.Context) (string, error) {
+ return i.SSH.Run(ctx, "systemctl status fonu-agent --no-pager -l 2>/dev/null | head -20; echo '---'; journalctl -u fonu-agent -n 15 --no-pager 2>/dev/null; echo '---'; ss -tlnp 2>/dev/null | grep ':7700 ' || echo '7700 not listening'")
+}
+
+func extractToken(output string) string {
+ for _, line := range strings.Split(output, "\n") {
+ if token, found := strings.CutPrefix(strings.TrimSpace(line), "Token: "); found {
+ return strings.TrimSpace(token)
+ }
+ }
+ return ""
+}
+
+func agentInstallScript() ([]byte, error) {
+ // 与 scripts/agent-install.sh 同一份逻辑内嵌:支持传入二进制路径参数,卸载子命令。
+ return []byte(`#!/usr/bin/env bash
+set -eu
+if [ "$(id -u)" -ne 0 ]; then echo "请使用 root 或 sudo 运行" >&2; exit 1; fi
+AGENT_BINARY=${1:-/tmp/fonu-agent}
+PREFIX=/usr/local/bin/fonu-agent
+CONF_DIR=/etc/fonu-agent
+CONF_FILE="$CONF_DIR/agent.env"
+UNIT=/etc/systemd/system/fonu-agent.service
+
+if [ "${1:-}" = "--uninstall" ]; then
+ systemctl stop fonu-agent 2>/dev/null || true
+ systemctl disable fonu-agent 2>/dev/null || true
+ rm -f "$UNIT" "$PREFIX" /usr/local/bin/fonu-agent-uninstall
+ systemctl daemon-reload
+ if [ "${2:-}" != "--keep-data" ]; then rm -rf /var/lib/fonu-agent; fi
+ echo "fonu-agent 已卸载"
+ exit 0
+fi
+
+mkdir -p "$CONF_DIR" /var/lib/fonu-agent/certs
+# 先停服务,再原子替换;安装后必须确认目标存在、可执行且能输出版本
+systemctl stop fonu-agent 2>/dev/null || true
+TMP_PREFIX="${PREFIX}.new"
+install -m 0755 "$AGENT_BINARY" "$TMP_PREFIX"
+if [ ! -x "$TMP_PREFIX" ]; then
+ echo "agent 二进制安装失败:$TMP_PREFIX 不存在或不可执行" >&2
+ exit 1
+fi
+AGENT_VERSION=$($TMP_PREFIX -version 2>/dev/null) || {
+ echo "agent 二进制无法执行:$TMP_PREFIX" >&2
+ rm -f "$TMP_PREFIX"
+ exit 1
+}
+mv -f "$TMP_PREFIX" "$PREFIX"
+if [ ! -x "$PREFIX" ]; then
+ echo "agent 原子替换失败:$PREFIX 不存在或不可执行" >&2
+ exit 1
+fi
+printf 'agent version: %s\n' "$AGENT_VERSION"
+if [ ! -f "$CONF_FILE" ]; then
+ umask 077
+ printf 'FONU_AGENT_TOKEN=%s\n' "$(openssl rand -hex 32)" > "$CONF_FILE"
+fi
+cat > "$UNIT" <<'UNIT'
+[Unit]
+Description=Fonu FRPS management agent
+After=network-online.target nginx.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+EnvironmentFile=/etc/fonu-agent/agent.env
+ExecStart=/usr/local/bin/fonu-agent
+Restart=on-failure
+RestartSec=3
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+systemctl daemon-reload
+systemctl restart fonu-agent
+systemctl enable fonu-agent
+cp "$0" /usr/local/bin/fonu-agent-uninstall
+echo "fonu-agent installed"
+echo "Token: $(sed -n 's/^FONU_AGENT_TOKEN=//p' "$CONF_FILE")"
+`), nil
+}
+
+// isELFBinary 判断内容是否为 Linux ELF 可执行(魔数 0x7F 'ELF'),用于拦截开发构建的占位文件。
+func isELFBinary(data []byte) bool {
+ return len(data) > 4 && data[0] == 0x7F && data[1] == 'E' && data[2] == 'L' && data[3] == 'F'
+}
diff --git a/internal/frp/agentbin/embed.go b/internal/frp/agentbin/embed.go
new file mode 100644
index 0000000..be20eaf
--- /dev/null
+++ b/internal/frp/agentbin/embed.go
@@ -0,0 +1,25 @@
+// Package agentbin 内嵌各架构 fonu-agent 二进制,供 SSH 安装器按 VPS 架构选择。
+// 仓库中的占位文件保证开发机构建可通过;Docker 构建阶段会用真实二进制覆盖它们
+// (见 Dockerfile:先交叉编译 agent,再构建主程序)。
+package agentbin
+
+import (
+ _ "embed"
+)
+
+//go:embed fonu-agent-linux-amd64
+var LinuxAMD64 []byte
+
+//go:embed fonu-agent-linux-arm64
+var LinuxARM64 []byte
+
+// BinaryForArch 返回对应架构的 agent 二进制;arch 取 uname -m 输出(x86_64/aarch64)。
+func BinaryForArch(arch string) ([]byte, bool) {
+ switch arch {
+ case "x86_64", "amd64":
+ return LinuxAMD64, true
+ case "aarch64", "arm64":
+ return LinuxARM64, true
+ }
+ return nil, false
+}
diff --git a/internal/frp/agentbin/fonu-agent-linux-amd64 b/internal/frp/agentbin/fonu-agent-linux-amd64
new file mode 100644
index 0000000..7c51a7a
Binary files /dev/null and b/internal/frp/agentbin/fonu-agent-linux-amd64 differ
diff --git a/internal/frp/agentbin/fonu-agent-linux-arm64 b/internal/frp/agentbin/fonu-agent-linux-arm64
new file mode 100644
index 0000000..dc48497
Binary files /dev/null and b/internal/frp/agentbin/fonu-agent-linux-arm64 differ
diff --git a/internal/frp/config.go b/internal/frp/config.go
new file mode 100644
index 0000000..bb5a868
--- /dev/null
+++ b/internal/frp/config.go
@@ -0,0 +1,247 @@
+package frp
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+type renderedConfig struct {
+ Server Server
+ Token string
+ OIDCClientSecret string
+ LogPath string
+ TLSCertPath string
+ TLSKeyPath string
+ TLSTrustedCAPath string
+ Proxies []Proxy
+}
+
+func renderTOML(cfg renderedConfig) string {
+ var out strings.Builder
+ writeKeyValue(&out, "serverAddr", cfg.Server.ServerAddr)
+ writeKeyValue(&out, "serverPort", cfg.Server.ServerPort)
+ options := normalizeServerOptions(cfg.Server.Options)
+ if options.User != "" {
+ writeKeyValue(&out, "user", options.User)
+ }
+ if options.AuthMethod != "none" {
+ writeKeyValue(&out, "auth.method", options.AuthMethod)
+ }
+ if options.AuthMethod == "token" {
+ writeKeyValue(&out, "auth.token", cfg.Token)
+ }
+ if options.AuthMethod == "oidc" {
+ writeKeyValue(&out, "auth.oidc.clientID", options.OIDCClientID)
+ writeKeyValue(&out, "auth.oidc.clientSecret", cfg.OIDCClientSecret)
+ writeKeyValue(&out, "auth.oidc.tokenEndpointURL", options.OIDCTokenEndpointURL)
+ if options.OIDCAudience != "" {
+ writeKeyValue(&out, "auth.oidc.audience", options.OIDCAudience)
+ }
+ if options.OIDCScope != "" {
+ writeKeyValue(&out, "auth.oidc.scope", options.OIDCScope)
+ }
+ }
+ if cfg.LogPath != "" {
+ writeKeyValue(&out, "log.to", cfg.LogPath)
+ }
+ writeKeyValue(&out, "log.level", options.LogLevel)
+ writeKeyValue(&out, "log.maxDays", options.LogMaxDays)
+ writeKeyValue(&out, "transport.protocol", options.Protocol)
+ writeKeyValue(&out, "transport.tls.enable", cfg.Server.TLS)
+ writeKeyValue(&out, "transport.tls.disableCustomTLSFirstByte", options.TLSDisableCustomFirstByte)
+ if cfg.Server.TLSServerName != "" {
+ writeKeyValue(&out, "transport.tls.serverName", cfg.Server.TLSServerName)
+ }
+ if cfg.TLSCertPath != "" {
+ writeKeyValue(&out, "transport.tls.certFile", cfg.TLSCertPath)
+ }
+ if cfg.TLSKeyPath != "" {
+ writeKeyValue(&out, "transport.tls.keyFile", cfg.TLSKeyPath)
+ }
+ if cfg.TLSTrustedCAPath != "" {
+ writeKeyValue(&out, "transport.tls.trustedCaFile", cfg.TLSTrustedCAPath)
+ }
+ if options.ProxyURL != "" {
+ writeKeyValue(&out, "transport.proxyURL", options.ProxyURL)
+ }
+ writeKeyValue(&out, "transport.dialServerTimeout", options.DialServerTimeout)
+ writeKeyValue(&out, "transport.dialServerKeepalive", options.DialServerKeepalive)
+ if options.ConnectServerLocalIP != "" {
+ writeKeyValue(&out, "transport.connectServerLocalIP", options.ConnectServerLocalIP)
+ }
+ if options.DNSServer != "" {
+ writeKeyValue(&out, "transport.dnsServer", options.DNSServer)
+ }
+ writeKeyValue(&out, "transport.poolCount", options.PoolCount)
+ writeKeyValue(&out, "transport.tcpMux", options.TCPMux)
+ writeKeyValue(&out, "transport.tcpMuxKeepaliveInterval", options.TCPMuxKeepaliveInterval)
+ writeKeyValue(&out, "transport.heartbeatInterval", options.HeartbeatInterval)
+ writeKeyValue(&out, "transport.heartbeatTimeout", options.HeartbeatTimeout)
+ writeKeyValue(&out, "loginFailExit", options.LoginFailExit)
+
+ for _, proxy := range cfg.Proxies {
+ out.WriteString("\n[[proxies]]\n")
+ writeKeyValue(&out, "name", proxy.Name)
+ writeKeyValue(&out, "type", proxy.Type)
+ writeKeyValue(&out, "localIP", proxy.LocalIP)
+ writeKeyValue(&out, "localPort", proxy.LocalPort)
+ if proxy.Type == "tcp" && proxy.RemotePort != nil {
+ writeKeyValue(&out, "remotePort", *proxy.RemotePort)
+ }
+ if proxy.Type == "udp" && proxy.RemotePort != nil {
+ writeKeyValue(&out, "remotePort", *proxy.RemotePort)
+ }
+ if proxy.Type == "http" || proxy.Type == "https" || proxy.Type == "tcpmux" {
+ writeStringSlice(&out, "customDomains", proxy.CustomDomains)
+ }
+ if proxy.HostHeaderRewrite != "" {
+ writeKeyValue(&out, "hostHeaderRewrite", proxy.HostHeaderRewrite)
+ }
+ writeProxyOptions(&out, proxy)
+ }
+ return out.String()
+}
+
+func writeProxyOptions(out *strings.Builder, proxy Proxy) {
+ opts := proxy.Options
+ if opts.Subdomain != "" {
+ writeKeyValue(out, "subdomain", opts.Subdomain)
+ }
+ if len(opts.Locations) > 0 {
+ writeStringSlice(out, "locations", opts.Locations)
+ }
+ if opts.RouteByHTTPUser != "" {
+ writeKeyValue(out, "routeByHTTPUser", opts.RouteByHTTPUser)
+ }
+ if opts.HTTPUser != "" {
+ writeKeyValue(out, "httpUser", opts.HTTPUser)
+ }
+ if opts.HTTPPassword != "" {
+ writeKeyValue(out, "httpPassword", opts.HTTPPassword)
+ }
+ if opts.Multiplexer != "" {
+ writeKeyValue(out, "multiplexer", opts.Multiplexer)
+ }
+ if opts.SecretKey != "" {
+ writeKeyValue(out, "secretKey", opts.SecretKey)
+ }
+ if len(opts.AllowUsers) > 0 {
+ writeStringSlice(out, "allowUsers", opts.AllowUsers)
+ }
+ writeProxyTransport(out, opts.Transport)
+ writeProxyHealthCheck(out, opts.HealthCheck)
+ if opts.LoadBalancer.Group != "" {
+ writeKeyValue(out, "loadBalancer.group", opts.LoadBalancer.Group)
+ }
+ if opts.LoadBalancer.GroupKey != "" {
+ writeKeyValue(out, "loadBalancer.groupKey", opts.LoadBalancer.GroupKey)
+ }
+ writeStringMap(out, "metadatas", opts.Metadatas)
+ writeStringMap(out, "requestHeaders.set", opts.RequestHeaders)
+ writeStringMap(out, "responseHeaders.set", opts.ResponseHeaders)
+ // natTraversal 是较新版本 frpc 才支持的字段,旧版 frpc 会把未知字段判为校验错误;
+ // disableAssistedAddrs=false 与 frp 默认行为等价,因此仅在显式禁用时写入。
+ if opts.NATTraversal != nil && opts.NATTraversal.DisableAssistedAddrs {
+ writeKeyValue(out, "natTraversal.disableAssistedAddrs", true)
+ }
+ if opts.Plugin != nil {
+ writeProxyPlugin(out, *opts.Plugin)
+ }
+}
+
+func writeProxyTransport(out *strings.Builder, opts ProxyTransportOptions) {
+ if opts.BandwidthLimit != "" {
+ writeKeyValue(out, "transport.bandwidthLimit", opts.BandwidthLimit)
+ }
+ if opts.BandwidthLimitMode != "" {
+ writeKeyValue(out, "transport.bandwidthLimitMode", opts.BandwidthLimitMode)
+ }
+ if opts.UseEncryption {
+ writeKeyValue(out, "transport.useEncryption", true)
+ }
+ if opts.UseCompression {
+ writeKeyValue(out, "transport.useCompression", true)
+ }
+ if opts.ProxyProtocolVersion != "" {
+ writeKeyValue(out, "transport.proxyProtocolVersion", opts.ProxyProtocolVersion)
+ }
+}
+
+func writeProxyHealthCheck(out *strings.Builder, opts ProxyHealthCheckOptions) {
+ if opts.Type == "" {
+ return
+ }
+ writeKeyValue(out, "healthCheck.type", opts.Type)
+ if opts.Path != "" {
+ writeKeyValue(out, "healthCheck.path", opts.Path)
+ }
+ if opts.IntervalSeconds > 0 {
+ writeKeyValue(out, "healthCheck.intervalSeconds", opts.IntervalSeconds)
+ }
+ if opts.MaxFailed > 0 {
+ writeKeyValue(out, "healthCheck.maxFailed", opts.MaxFailed)
+ }
+ if opts.TimeoutSeconds > 0 {
+ writeKeyValue(out, "healthCheck.timeoutSeconds", opts.TimeoutSeconds)
+ }
+ if len(opts.HTTPHeaders) > 0 {
+ values := make([]string, 0, len(opts.HTTPHeaders))
+ for _, header := range opts.HTTPHeaders {
+ values = append(values, fmt.Sprintf("{ name = %s, value = %s }", strconv.Quote(header.Name), strconv.Quote(header.Value)))
+ }
+ fmt.Fprintf(out, "healthCheck.httpHeaders = [%s]\n", strings.Join(values, ", "))
+ }
+}
+
+func writeProxyPlugin(out *strings.Builder, plugin ProxyPluginOptions) {
+ out.WriteString("[proxies.plugin]\n")
+ writeKeyValue(out, "type", plugin.Type)
+ for _, item := range []struct{ key, value string }{
+ {"unixPath", plugin.UnixPath}, {"localPath", plugin.LocalPath}, {"stripPrefix", plugin.StripPrefix},
+ {"localAddr", plugin.LocalAddr}, {"httpUser", plugin.HTTPUser}, {"httpPassword", plugin.HTTPPassword},
+ {"username", plugin.Username}, {"password", plugin.Password}, {"crtPath", plugin.CRTPath}, {"keyPath", plugin.KeyPath},
+ {"hostHeaderRewrite", plugin.HostHeaderRewrite}, {"destinationIP", plugin.DestinationIP},
+ } {
+ if item.value != "" {
+ writeKeyValue(out, item.key, item.value)
+ }
+ }
+}
+
+func writeStringMap(out *strings.Builder, prefix string, values map[string]string) {
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ for _, key := range keys {
+ if key != "" {
+ writeKeyValue(out, prefix+"."+key, values[key])
+ }
+ }
+}
+
+func renderMaskedTOML(server Server, proxies []Proxy) string {
+ return renderTOML(renderedConfig{Server: server, Token: "********", OIDCClientSecret: "********", Proxies: proxies})
+}
+
+func writeKeyValue(out *strings.Builder, key string, value any) {
+ switch v := value.(type) {
+ case string:
+ fmt.Fprintf(out, "%s = %s\n", key, strconv.Quote(v))
+ case int:
+ fmt.Fprintf(out, "%s = %d\n", key, v)
+ case bool:
+ fmt.Fprintf(out, "%s = %t\n", key, v)
+ }
+}
+
+func writeStringSlice(out *strings.Builder, key string, values []string) {
+ quoted := make([]string, 0, len(values))
+ for _, value := range values {
+ quoted = append(quoted, strconv.Quote(value))
+ }
+ fmt.Fprintf(out, "%s = [%s]\n", key, strings.Join(quoted, ", "))
+}
diff --git a/internal/frp/config_test.go b/internal/frp/config_test.go
new file mode 100644
index 0000000..3ca5cba
--- /dev/null
+++ b/internal/frp/config_test.go
@@ -0,0 +1,65 @@
+package frp
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderTOML(t *testing.T) {
+ remotePort := 60022
+ content := renderTOML(renderedConfig{
+ Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true},
+ Token: "token with \"quotes\"",
+ Proxies: []Proxy{
+ {Name: "nas-ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &remotePort},
+ {Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}},
+ },
+ })
+
+ for _, expected := range []string{
+ `serverAddr = "vps.example.com"`,
+ `auth.token = "token with \"quotes\""`,
+ `remotePort = 60022`,
+ `customDomains = ["nas.example.com"]`,
+ } {
+ if !strings.Contains(content, expected) {
+ t.Fatalf("配置缺少 %q:\n%s", expected, content)
+ }
+ }
+}
+
+func TestRenderMaskedTOMLDoesNotExposeToken(t *testing.T) {
+ content := renderMaskedTOML(Server{ServerAddr: "vps.example.com", ServerPort: 7000, TLS: true}, nil)
+ if !strings.Contains(content, `auth.token = "********"`) {
+ t.Fatalf("脱敏配置未隐藏 Token:\n%s", content)
+ }
+}
+
+func TestRenderProxyOptions(t *testing.T) {
+ content := renderTOML(renderedConfig{Server: Server{ServerAddr: "vps.example.com", ServerPort: 7000}, Proxies: []Proxy{
+ {Name: "udp", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: intPtr(6002), Options: ProxyOptions{Transport: ProxyTransportOptions{UseEncryption: true, ProxyProtocolVersion: "v2"}, HealthCheck: ProxyHealthCheckOptions{Type: "tcp"}}},
+ {Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "secret", AllowUsers: []string{"*"}}},
+ }})
+ for _, expected := range []string{`type = "udp"`, `remotePort = 6002`, `transport.useEncryption = true`, `transport.proxyProtocolVersion = "v2"`, `healthCheck.type = "tcp"`, `secretKey = "secret"`, `allowUsers = ["*"]`} {
+ if !strings.Contains(content, expected) {
+ t.Fatalf("配置缺少 %q:\n%s", expected, content)
+ }
+ }
+}
+
+func TestRenderNATTraversalOnlyWhenDisabledAssistedAddrs(t *testing.T) {
+ omitted := renderTOML(renderedConfig{Proxies: []Proxy{
+ {Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{}}},
+ }})
+ if strings.Contains(omitted, "natTraversal") {
+ t.Fatalf("disableAssistedAddrs=false 不应渲染 natTraversal(旧版 frpc 校验会失败):\n%s", omitted)
+ }
+ enabled := renderTOML(renderedConfig{Proxies: []Proxy{
+ {Name: "p2p", Type: "xtcp", Options: ProxyOptions{NATTraversal: &ProxyNATTraversal{DisableAssistedAddrs: true}}},
+ }})
+ if !strings.Contains(enabled, "natTraversal.disableAssistedAddrs = true") {
+ t.Fatalf("disableAssistedAddrs=true 应渲染 natTraversal:\n%s", enabled)
+ }
+}
+
+func intPtr(value int) *int { return &value }
diff --git a/internal/frp/connection_state.go b/internal/frp/connection_state.go
new file mode 100644
index 0000000..5ed744c
--- /dev/null
+++ b/internal/frp/connection_state.go
@@ -0,0 +1,46 @@
+package frp
+
+import (
+ "os"
+ "strings"
+)
+
+const maxConnectionLogRead = 128 << 10
+
+func inspectConnectionLog(path, processState string) (string, string) {
+ if processState != "running" {
+ if processState == "error" {
+ return "error", "frpc 进程已退出"
+ }
+ return "disconnected", ""
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "unknown", ""
+ }
+ if len(data) > maxConnectionLogRead {
+ data = data[len(data)-maxConnectionLogRead:]
+ }
+ lines := strings.Split(string(data), "\n")
+ for index := len(lines) - 1; index >= 0; index-- {
+ line := strings.TrimSpace(lines[index])
+ lower := strings.ToLower(line)
+ switch {
+ case strings.Contains(lower, "login to server success"):
+ return "connected", ""
+ case strings.Contains(lower, "login to server failed"), strings.Contains(lower, "connect to server error"):
+ return "error", trimConnectionError(line)
+ case strings.Contains(lower, "frpc service") && strings.Contains(lower, "stopped"):
+ return "disconnected", ""
+ }
+ }
+ return "unknown", ""
+}
+
+func trimConnectionError(value string) string {
+ value = strings.TrimSpace(value)
+ if len(value) > 500 {
+ return value[:500]
+ }
+ return value
+}
diff --git a/internal/frp/diagnostics.go b/internal/frp/diagnostics.go
new file mode 100644
index 0000000..441038b
--- /dev/null
+++ b/internal/frp/diagnostics.go
@@ -0,0 +1,251 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "time"
+)
+
+// 诊断状态常量:ok 已确认可用;failed 已确认失败;unknown 无法确认(区别于失败)。
+const (
+ diagStateOK = "ok"
+ diagStateFailed = "failed"
+ diagStateUnknown = "unknown"
+
+ diagTCPTimeout = 3 * time.Second
+ diagDNSTimeout = 2 * time.Second
+ diagnosticsCacheTTL = 15 * time.Second
+ diagnosticsMaxInFlight = 4
+)
+
+type diagnosticsCacheEntry struct {
+ diagnostics ServerDiagnostics
+ expiresAt time.Time
+}
+
+// ServerDiagnostics 返回服务端诊断结果。带短 TTL 缓存与并发上限:
+// 列表页轮询不应同步等待定位等外部 API,也不能无限叠加探测请求。
+func (s *Service) ServerDiagnostics(ctx context.Context, id int64) (ServerDiagnostics, error) {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return ServerDiagnostics{}, err
+ }
+ return s.runDiagnostics(ctx, server), nil
+}
+
+func (s *Service) runDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
+ s.diagMu.Lock()
+ if entry, ok := s.diagCache[server.ID]; ok && time.Now().Before(entry.expiresAt) {
+ cached := entry.diagnostics
+ s.diagMu.Unlock()
+ return cached
+ }
+ // 并发上限:超限时返回旧结果或 unknown,不排队等待
+ if s.diagInFlight >= diagnosticsMaxInFlight {
+ cached, ok := s.diagCache[server.ID]
+ s.diagMu.Unlock()
+ if ok {
+ return cached.diagnostics
+ }
+ return ServerDiagnostics{ServerID: server.ID, Checks: []DiagnosticCheck{{Name: "tcp_reachable", State: diagStateUnknown, Detail: "探测请求过多,请稍后重试"}}}
+ }
+ s.diagInFlight++
+ s.diagMu.Unlock()
+ defer func() {
+ s.diagMu.Lock()
+ s.diagInFlight--
+ s.diagMu.Unlock()
+ }()
+
+ result := s.computeDiagnostics(ctx, server)
+ s.diagMu.Lock()
+ s.diagCache[server.ID] = diagnosticsCacheEntry{diagnostics: result, expiresAt: time.Now().Add(diagnosticsCacheTTL)}
+ s.diagMu.Unlock()
+ return result
+}
+
+// computeDiagnostics 执行各诊断项。定位失败、日志缺失等一律记为 unknown 或非阻断展示,
+// 绝不写入 Error(Error 仅表示控制端口探测失败)。
+func (s *Service) computeDiagnostics(ctx context.Context, server Server) ServerDiagnostics {
+ dnsCheck := diagnoseDNS(ctx, server.ServerAddr)
+ tcpCheck := diagnoseTCP(ctx, server, dnsCheck)
+ diagnostics := ServerDiagnostics{
+ ServerID: server.ID,
+ Available: tcpCheck.State == diagStateOK,
+ LatencyMS: tcpCheck.LatencyMS,
+ Checks: []DiagnosticCheck{tcpCheck, s.diagnoseFRPCLogin(server.ID), dnsCheck},
+ }
+ if tcpCheck.State == diagStateFailed {
+ diagnostics.Error = tcpCheck.Detail
+ }
+
+ // location:非阻断展示项。缓存命中直接返回;未命中先展示 IP 占位并后台刷新,
+ // 不让列表页同步等待外部定位 API。
+ location, resolved := s.locationSnapshot(ctx, server.ServerAddr)
+ diagnostics.Location = location
+ locationCheck := DiagnosticCheck{Name: "location", State: diagStateOK, Detail: location}
+ if !resolved {
+ locationCheck.State = diagStateUnknown
+ locationCheck.Detail = "定位信息后台获取中"
+ go s.refreshLocation(server.ServerAddr)
+ }
+ diagnostics.Checks = append(diagnostics.Checks, locationCheck)
+ s.store.AddServerDiagnostic(ctx, diagnostics)
+ return diagnostics
+}
+
+// diagnoseTCP 控制端口 TCP 探测:只证明端口可达,不涉及认证与代理可用性。
+func diagnoseTCP(ctx context.Context, server Server, dnsCheck DiagnosticCheck) DiagnosticCheck {
+ check := DiagnosticCheck{Name: "tcp_reachable"}
+ if dnsCheck.State == diagStateFailed {
+ check.State = diagStateFailed
+ check.Detail = "已跳过:" + dnsCheck.Detail
+ return check
+ }
+ dialer := net.Dialer{Timeout: diagTCPTimeout}
+ started := time.Now()
+ conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(server.ServerAddr, fmt.Sprint(server.ServerPort)))
+ check.LatencyMS = time.Since(started).Milliseconds()
+ if check.LatencyMS < 1 {
+ check.LatencyMS = 1
+ }
+ if err != nil {
+ check.State = diagStateFailed
+ check.Detail = diagnoseDialError(err)
+ return check
+ }
+ _ = conn.Close()
+ check.State = diagStateOK
+ check.Detail = fmt.Sprintf("控制端口 %d 可达", server.ServerPort)
+ return check
+}
+
+// diagnoseDNS 记录域名解析结果:A/AAAA 地址、解析耗时;TTL 由系统解析器决定,标准库无法读取,不展示。
+func diagnoseDNS(ctx context.Context, host string) DiagnosticCheck {
+ check := DiagnosticCheck{Name: "dns_resolution"}
+ if ip := net.ParseIP(host); ip != nil {
+ check.State = diagStateOK
+ check.Detail = fmt.Sprintf("配置为 IP 地址 %s,未经过域名解析", ip)
+ return check
+ }
+ resolver := net.DefaultResolver
+ ctxTimeout, cancel := context.WithTimeout(ctx, diagDNSTimeout)
+ defer cancel()
+ started := time.Now()
+ addresses, err := resolver.LookupIPAddr(ctxTimeout, host)
+ elapsed := time.Since(started).Milliseconds()
+ if err != nil || len(addresses) == 0 {
+ check.State = diagStateFailed
+ check.Detail = "域名解析失败:" + diagnoseResolveError(err)
+ return check
+ }
+ var ipv4, ipv6 []string
+ for _, address := range addresses {
+ if address.IP.To4() != nil {
+ ipv4 = append(ipv4, address.IP.String())
+ } else {
+ ipv6 = append(ipv6, address.IP.String())
+ }
+ }
+ summary := "系统解析器;A=" + strings.Join(ipv4, ", ")
+ if len(ipv6) > 0 {
+ summary += ";AAAA=" + strings.Join(ipv6, ", ")
+ } else {
+ summary += ";AAAA=无"
+ }
+ check.LatencyMS = elapsed
+ check.Detail = fmt.Sprintf("%s(解析耗时 %d ms)", summary, elapsed)
+ return check
+}
+
+func diagnoseResolveError(err error) string {
+ if err == nil {
+ return "无解析结果"
+ }
+ if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
+ return "域名不存在"
+ }
+ return err.Error()
+}
+
+// diagnoseFRPCLogin 从 frpc 运行日志推断登录状态:ok 已登录,failed 最近一次登录失败,unknown 无证据。
+func (s *Service) diagnoseFRPCLogin(serverID int64) DiagnosticCheck {
+ check := DiagnosticCheck{Name: "frpc_login"}
+ data, err := os.ReadFile(s.serverLogPath(serverID))
+ if os.IsNotExist(err) {
+ check.State = diagStateUnknown
+ check.Detail = "frpc 尚未运行,无登录记录"
+ return check
+ }
+ if err != nil {
+ check.State = diagStateUnknown
+ check.Detail = "读取 frpc 日志失败"
+ return check
+ }
+ state, detail := summarizeFRPCLogin(string(data))
+ check.State = state
+ check.Detail = detail
+ return check
+}
+
+// summarizeFRPCLogin 从日志尾部向前找最近的登录结论;登录成功优先于更早的失败记录。
+func summarizeFRPCLogin(log string) (string, string) {
+ lines := strings.Split(strings.TrimRight(log, "\n"), "\n")
+ const window = 200
+ if len(lines) > window {
+ lines = lines[len(lines)-window:]
+ }
+ failureDetail := ""
+ for index := len(lines) - 1; index >= 0; index-- {
+ line := lines[index]
+ if prefix := strings.Index(line, "] "); prefix >= 0 {
+ line = line[prefix+2:]
+ }
+ if strings.Contains(line, "login to server success") {
+ return diagStateOK, "frpc 已登录 frps"
+ }
+ if failureDetail == "" && isLoginFailureLine(line) {
+ failureDetail = line
+ }
+ }
+ if failureDetail != "" {
+ if len(failureDetail) > 160 {
+ failureDetail = failureDetail[:160] + "…"
+ }
+ return diagStateFailed, "frpc 登录失败:" + failureDetail
+ }
+ return diagStateUnknown, "日志中暂无登录结果记录"
+}
+
+func isLoginFailureLine(line string) bool {
+ if strings.Contains(line, "login to the server failed") || strings.Contains(line, "connect to server error") || strings.Contains(line, "authentication failed") {
+ return true
+ }
+ return strings.Contains(line, "token") && strings.Contains(line, "error")
+}
+
+// locationSnapshot 返回定位缓存值;未命中时返回 IP 占位(resolved=false),由调用方后台刷新。
+func (s *Service) locationSnapshot(ctx context.Context, host string) (string, bool) {
+ ip := resolvePublicIP(ctx, host)
+ if ip == "" {
+ // 内网地址或解析失败:展示主机本身,不发起外部定位,也不视为错误
+ return strings.TrimSpace(host), true
+ }
+ s.locationMu.Lock()
+ cached, ok := s.locations[ip]
+ s.locationMu.Unlock()
+ if ok && time.Now().Before(cached.expiresAt) {
+ return cached.value, true
+ }
+ return ip, false
+}
+
+// refreshLocation 后台补全定位缓存;结果只影响展示,不参与可用性判定。
+func (s *Service) refreshLocation(host string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+ defer cancel()
+ _ = s.serverLocation(ctx, host)
+}
diff --git a/internal/frp/frps_template.go b/internal/frp/frps_template.go
new file mode 100644
index 0000000..1d1b6c6
--- /dev/null
+++ b/internal/frp/frps_template.go
@@ -0,0 +1,216 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// buildFRPSServerConfig 把多服务端模块的 frpc 侧服务端记录渲染为 VPS 侧的 frps.toml,
+// bindPort、认证、管理接口与 frpc 配置同源生成,消除两端错配(官方 BuildFRPSConfig 的多服务端版)。
+// 密钥只在生成前一刻解密进模板,永不写入日志或其他 API 响应。
+func (s *Service) buildFRPSServerConfig(ctx context.Context, server Server) (string, error) {
+ // token 认证才需要取同一份 token;none / oidc 不涉及
+ token := ""
+ if server.Options.AuthMethod == "token" {
+ encoded, err := s.store.Token(ctx, server.ID)
+ if err != nil {
+ return "", err
+ }
+ if encoded != "" {
+ token, err = s.secretBox.Decrypt(encoded)
+ if err != nil {
+ return "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
+ }
+ }
+ }
+
+ dashboardOn := server.Options.DashboardPort > 0 && strings.TrimSpace(server.Options.DashboardAddr) != ""
+ hasVhost, vhostErr := s.hasVhostProxies(ctx, server.ID)
+ // subDomainHost 缺口检测:frpc 侧规则填了 subdomain 时,frps 必须配置 subDomainHost 才生效
+ proxies, proxyErr := s.store.ListProxiesByServer(ctx, server.ID)
+ hasSubdomain := false
+ if proxyErr == nil {
+ for _, p := range proxies {
+ if strings.TrimSpace(p.Options.Subdomain) != "" {
+ hasSubdomain = true
+ break
+ }
+ }
+ }
+
+ var b strings.Builder
+ b.WriteString("# 由 Fonu 生成:与本服务端的 frpc 配置同源(bindPort/auth 与客户端一致)\n")
+ b.WriteString("# 格式:frp v0.52.0+ 的 TOML 配置(Docker 镜像内置 frp 0.71.0;INI 自 v0.52.0 起已被官方弃用)\n")
+ b.WriteString("# 用法:保存为 VPS 上的 frps.toml,执行 ./frps -c frps.toml\n\n")
+ // TOML 规范:顶层标量键必须写在任何 [table] 段之前,否则会被归入上一个表
+ b.WriteString("bindAddr = \"0.0.0.0\"\n")
+ b.WriteString(fmt.Sprintf("bindPort = %d\n", server.ServerPort))
+ switch server.Options.Protocol {
+ case "kcp":
+ b.WriteString(fmt.Sprintf("kcpBindPort = %d # frpc 使用 kcp 协议接入;官方允许与 bindPort 相同(UDP)\n", server.ServerPort))
+ case "quic":
+ b.WriteString(fmt.Sprintf("quicBindPort = %d # frpc 使用 quic 协议接入;UDP 端口官方允许与 bindPort 相同,如冲突请改为独立端口\n", server.ServerPort))
+ }
+ if vhostErr == nil && hasVhost {
+ // 官方默认 vhost 端口为 0(禁用)。默认 8080/8443:80/443 由 Nginx 占用(Nginx 80/443 →
+ // proxy_pass 127.0.0.1:8080 → frps vhost → 隧道),可在服务端表单自定义,与 Nginx upstream 同源
+ httpPort := server.Options.VhostHTTPPort
+ if httpPort <= 0 {
+ httpPort = 8080
+ }
+ httpsPort := server.Options.VhostHTTPSPort
+ if httpsPort <= 0 {
+ httpsPort = 8443
+ }
+ b.WriteString(fmt.Sprintf("vhostHTTPPort = %d\n", httpPort))
+ b.WriteString(fmt.Sprintf("vhostHTTPSPort = %d\n", httpsPort))
+ }
+ // 子域名根:规则填了 subdomain 时必须配置,否则 subdomain 不生效
+ if host := strings.TrimSpace(server.Options.SubDomainHost); host != "" {
+ b.WriteString(fmt.Sprintf("subDomainHost = %q\n", host))
+ }
+ // 自定义 404 页面(HTTP vhost 命中不到规则时的响应)
+ if page := strings.TrimSpace(server.Options.Custom404Page); page != "" {
+ b.WriteString(fmt.Sprintf("custom404Page = %q\n", page))
+ }
+ // tcpmux 规则的硬依赖:存在 tcpmux 规则时必须配置监听端口
+ if port := server.Options.TCPMuxHTTPConnectPort; port > 0 {
+ b.WriteString(fmt.Sprintf("tcpmuxHTTPConnectPort = %d\n", port))
+ }
+ // 强制 TLS:公网 frps 标准安全实践;开启后 frpc 不开 TLS 将无法连接
+ if server.Options.TLSForce {
+ b.WriteString("transport.tls.force = true # 只接受 TLS 连接(frpc 侧必须启用 transport.tls.enable)\n")
+ }
+ // 传输参数与 frpc 侧同源:两端不一致会导致连不上或池被截断
+ if !server.Options.TCPMux {
+ b.WriteString("transport.tcpMux = false # 与 frpc 侧 tcp_mux 同源(官方默认 true,仅在关闭时输出)\n")
+ }
+ if server.Options.PoolCount > 0 {
+ b.WriteString(fmt.Sprintf("transport.maxPoolCount = %d # 不低于 frpc 侧 pool_count,避免连接池被静默截断\n", server.Options.PoolCount))
+ }
+ // 端口白名单:逗号分隔的单端口或「起-止」范围,如 6000,6005-6010,7000-8000;留空不限制
+ if ports := strings.TrimSpace(server.Options.AllowPorts); ports != "" {
+ b.WriteString(fmt.Sprintf("allowPorts = [\n%s\n]\n", renderAllowPorts(ports)))
+ }
+ // 每客户端可绑定端口上限;0 表示不限制
+ if maxPorts := server.Options.MaxPortsPerClient; maxPorts > 0 {
+ b.WriteString(fmt.Sprintf("maxPortsPerClient = %d\n", maxPorts))
+ }
+ // frps 文件日志:journalctl 只保留尾部,排障需完整历史时开启;写入 /var/log/frps.log
+ if server.Options.FrpsLogToFile {
+ maxDays := server.Options.FrpsLogMaxDays
+ if maxDays <= 0 {
+ maxDays = 3
+ }
+ b.WriteString("\nlog.to = \"/var/log/frps.log\"\n")
+ b.WriteString(fmt.Sprintf("log.maxDays = %d\n", maxDays))
+ }
+ if !server.TLS {
+ b.WriteString("# 安全提示:frpc 侧未启用 TLS,frps 可加 transport.tls.force = true 强制 TLS(开启后 frpc 不开 TLS 将无法连接)\n")
+ }
+ switch server.Options.AuthMethod {
+ case "token":
+ if token != "" {
+ // 点式键与官方 frps_full_example.toml 同款(TOML 语义上与 [auth] 表等价)
+ b.WriteString("\nauth.method = \"token\"\n")
+ b.WriteString(fmt.Sprintf("auth.token = %q\n", token))
+ } else {
+ b.WriteString("\n# 注意:frpc 侧为 Token 认证但尚未保存 Token;保存后在 Fonu 重新生成本文件以保持两端一致\n")
+ }
+ case "none":
+ b.WriteString("\n# frpc 侧为无认证(auth.method = \"none\"),frps 无需 [auth] 配置;\n")
+ b.WriteString("# 公网暴露的 frps 建议配置认证或用 transport.tls.force = true 强制 TLS,防止任意客户端接入\n")
+ case "oidc":
+ b.WriteString("\n# frpc 侧使用 OIDC 认证;frps 侧的 oidc.* 校验参数(issuer、client_id 等)依赖你的身份提供商,\n")
+ b.WriteString("# 请参考官方 frps_full_example.toml 的 [auth] oidc 段手动配置\n")
+ }
+ if server.TLS {
+ b.WriteString("\n# frpc 侧已启用 transport.tls.enable;frps 的 transport.tls.force 默认为 false,无需额外 TLS 配置即可接受 TLS 连接\n")
+ }
+
+ // webServer(frps 管理接口):与客户端 options.dashboard_* 同源
+ if dashboardOn {
+ b.WriteString("\nwebServer.addr = \"0.0.0.0\"\n")
+ b.WriteString(fmt.Sprintf("webServer.port = %d\n", server.Options.DashboardPort))
+ if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
+ b.WriteString(fmt.Sprintf("webServer.user = %q\n", user))
+ }
+ if encoded, err := s.store.DashboardPassword(ctx, server.ID); err == nil && encoded != "" {
+ if password, err := s.secretBox.Decrypt(encoded); err == nil && password != "" {
+ b.WriteString(fmt.Sprintf("webServer.password = %q\n", password))
+ }
+ }
+ b.WriteString("\n# 安全提示:webServer 暴露穿透流量与连接信息,建议用防火墙限制仅 Fonu 所在机器访问该端口\n")
+ }
+
+ if hasSubdomain {
+ b.WriteString("\n# 注意:该服务端下存在填写 subdomain 的规则;frps 需配置 subDomainHost(如 frps.example.com)后,\n")
+ b.WriteString("# 规则的 subdomain 字段才会生效为 sub.frps.example.com;域名 DNS 也需指向本 VPS\n")
+ }
+ b.WriteString("\n# 防火墙放行清单(按本服务端当前配置生成)\n")
+ b.WriteString(fmt.Sprintf("# %d/tcp # frps 控制端口\n", server.ServerPort))
+ if dashboardOn {
+ b.WriteString(fmt.Sprintf("# %d/tcp # frps 管理接口(webServer)\n", server.Options.DashboardPort))
+ }
+ if vhostErr == nil && hasVhost {
+ httpPort := server.Options.VhostHTTPPort
+ if httpPort <= 0 {
+ httpPort = 8080
+ }
+ httpsPort := server.Options.VhostHTTPSPort
+ if httpsPort <= 0 {
+ httpsPort = 8443
+ }
+ b.WriteString(fmt.Sprintf("# %d, %d/tcp # 上方 vhostHTTPPort / vhostHTTPSPort 对应端口(仅本机 Nginx 回源需要,可不对公网开放)\n", httpPort, httpsPort))
+ }
+ return b.String(), nil
+}
+
+// hasVhostProxies 判断该服务端下是否存在依赖 vhost 端口的规则(http/https/tcpmux)。
+func (s *Service) hasVhostProxies(ctx context.Context, serverID int64) (bool, error) {
+ proxies, err := s.store.ListProxiesByServer(ctx, serverID)
+ if err != nil {
+ return false, err
+ }
+ for _, p := range proxies {
+ if p.Type == "http" || p.Type == "https" || p.Type == "tcpmux" {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// renderAllowPorts 把表单端口白名单文本(如 "6000,6005-6010,7000-8000")渲染为 TOML 数组行;
+// 非法片段直接忽略,不中断生成。
+func renderAllowPorts(ports string) string {
+ var lines []string
+ for _, part := range strings.Split(ports, ",") {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+ if start, end, ok := strings.Cut(part, "-"); ok {
+ startN, err1 := strconv.Atoi(strings.TrimSpace(start))
+ endN, err2 := strconv.Atoi(strings.TrimSpace(end))
+ if err1 == nil && err2 == nil && startN > 0 && endN >= startN && endN <= 65535 {
+ lines = append(lines, fmt.Sprintf("{ start = %d, end = %d },", startN, endN))
+ }
+ continue
+ }
+ if n, err := strconv.Atoi(part); err == nil && n > 0 && n <= 65535 {
+ lines = append(lines, fmt.Sprintf("{ single = %d },", n))
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// FRPSServerConfig 返回指定服务端的 frps.toml 同源配置(对外 API 入口)。
+func (s *Service) FRPSServerConfig(ctx context.Context, id int64) (string, error) {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return "", err
+ }
+ return s.buildFRPSServerConfig(ctx, server)
+}
diff --git a/internal/frp/location.go b/internal/frp/location.go
new file mode 100644
index 0000000..78aaf59
--- /dev/null
+++ b/internal/frp/location.go
@@ -0,0 +1,99 @@
+package frp
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const locationCacheTTL = 24 * time.Hour
+
+type locationCache struct {
+ value string
+ expiresAt time.Time
+}
+
+type ipLocationResponse struct {
+ Success bool `json:"success"`
+ City string `json:"city"`
+ Region string `json:"region"`
+ Country string `json:"country"`
+}
+
+func (s *Service) serverLocation(ctx context.Context, host string) string {
+ ip := resolvePublicIP(ctx, host)
+ if ip == "" {
+ return strings.TrimSpace(host)
+ }
+ s.locationMu.Lock()
+ if cached, ok := s.locations[ip]; ok && time.Now().Before(cached.expiresAt) {
+ s.locationMu.Unlock()
+ return cached.value
+ }
+ s.locationMu.Unlock()
+ value := lookupIPLocation(ctx, ip)
+ // 定位失败只是展示信息缺失:缓存 IP 本身,不产生错误状态,下一次诊断会再次尝试
+ s.locationMu.Lock()
+ s.locations[ip] = locationCache{value: value, expiresAt: time.Now().Add(locationCacheTTL)}
+ s.locationMu.Unlock()
+ return value
+}
+
+func resolvePublicIP(ctx context.Context, host string) string {
+ if ip := net.ParseIP(host); isPublicIP(ip) {
+ return ip.String()
+ }
+ addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return ""
+ }
+ for _, address := range addresses {
+ if isPublicIP(address.IP) {
+ return address.IP.String()
+ }
+ }
+ return ""
+}
+
+func isPublicIP(ip net.IP) bool {
+ return ip != nil && ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
+}
+
+func lookupIPLocation(ctx context.Context, ip string) string {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://ipwho.is/"+ip, nil)
+ if err != nil {
+ return "未定位"
+ }
+ client := &http.Client{Timeout: 4 * time.Second}
+ response, err := client.Do(request)
+ if err != nil {
+ return "未定位"
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return "未定位"
+ }
+ var data ipLocationResponse
+ if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&data); err != nil || !data.Success {
+ return "未定位"
+ }
+ return formatLocation(data)
+}
+
+func formatLocation(data ipLocationResponse) string {
+ parts := []string{data.City, data.Region, data.Country}
+ values := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if part != "" && (len(values) == 0 || values[len(values)-1] != part) {
+ values = append(values, part)
+ }
+ }
+ if len(values) == 0 {
+ return "未定位"
+ }
+ return strings.Join(values, " · ")
+}
diff --git a/internal/frp/location_test.go b/internal/frp/location_test.go
new file mode 100644
index 0000000..321f499
--- /dev/null
+++ b/internal/frp/location_test.go
@@ -0,0 +1,22 @@
+package frp
+
+import (
+ "net"
+ "testing"
+)
+
+func TestFormatLocation(t *testing.T) {
+ value := formatLocation(ipLocationResponse{City: "Hong Kong", Region: "Hong Kong", Country: "China"})
+ if value != "Hong Kong · China" {
+ t.Fatalf("位置格式错误:%s", value)
+ }
+}
+
+func TestIsPublicIP(t *testing.T) {
+ if !isPublicIP(net.ParseIP("38.95.74.87")) {
+ t.Fatal("公网 IP 应被识别")
+ }
+ if isPublicIP(net.ParseIP("192.168.1.1")) {
+ t.Fatal("私网 IP 不应被定位")
+ }
+}
diff --git a/internal/frp/manager.go b/internal/frp/manager.go
new file mode 100644
index 0000000..c0f2f22
--- /dev/null
+++ b/internal/frp/manager.go
@@ -0,0 +1,300 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+)
+
+type Manager struct {
+ bin string
+ configPath string
+ logPath string
+ logger *slog.Logger
+
+ mu sync.RWMutex
+ cmd *exec.Cmd
+ orphan *os.Process
+ orphanPID int
+ pid int
+ version string
+ started time.Time
+ reloaded time.Time
+ lastErr string
+}
+
+func NewManager(bin, configPath, logPath string, logger *slog.Logger) *Manager {
+ return &Manager{bin: bin, configPath: configPath, logPath: logPath, logger: logger.With("module", "FRP")}
+}
+
+func (m *Manager) Verify(ctx context.Context, configPath string) error {
+ bin, err := m.BinaryPath()
+ if err != nil {
+ return err
+ }
+ cmd := exec.CommandContext(ctx, bin, "verify", "-c", configPath)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("frpc 配置校验失败:%s", safeCommandOutput(output, err))
+ }
+ return nil
+}
+
+func (m *Manager) VerifyBinary(ctx context.Context) error {
+ bin, err := m.BinaryPath()
+ if err != nil {
+ return err
+ }
+ output, err := exec.CommandContext(ctx, bin, "--version").CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("frpc 二进制校验失败:%s", safeCommandOutput(output, err))
+ }
+ return nil
+}
+
+func (m *Manager) Start() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.runningLocked() {
+ return nil
+ }
+ bin, err := m.binaryPathLocked()
+ if err != nil {
+ m.lastErr = err.Error()
+ return err
+ }
+ logFile, err := os.OpenFile(m.logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ cmd := exec.Command(bin, "-c", m.configPath)
+ cmd.Stdout = logFile
+ cmd.Stderr = logFile
+ if err := cmd.Start(); err != nil {
+ logFile.Close()
+ m.lastErr = err.Error()
+ return fmt.Errorf("启动 frpc 失败:%w", err)
+ }
+ m.cmd = cmd
+ m.pid = cmd.Process.Pid
+ m.started = time.Now().UTC()
+ m.reloaded = m.started
+ m.lastErr = ""
+ go m.wait(cmd, logFile)
+ return nil
+}
+
+func (m *Manager) Restart(ctx context.Context) error {
+ if err := m.Stop(ctx); err != nil {
+ return err
+ }
+ return m.Start()
+}
+
+func (m *Manager) Stop(ctx context.Context) error {
+ m.mu.Lock()
+ cmd := m.cmd
+ orphan := m.orphan
+ m.mu.Unlock()
+ if cmd == nil && orphan == nil {
+ return nil
+ }
+ process := orphan
+ if cmd != nil {
+ process = cmd.Process
+ }
+ if process == nil {
+ return nil
+ }
+ if err := process.Kill(); err != nil {
+ return fmt.Errorf("停止 frpc 失败:%w", err)
+ }
+ deadline := time.NewTimer(5 * time.Second)
+ defer deadline.Stop()
+ for {
+ if !m.running() {
+ m.mu.Lock()
+ m.lastErr = ""
+ m.orphan = nil
+ m.orphanPID = 0
+ m.mu.Unlock()
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-deadline.C:
+ return fmt.Errorf("等待 frpc 停止超时")
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+}
+
+func (m *Manager) Status() RuntimeStatus {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.statusLocked()
+}
+
+func (m *Manager) statusLocked() RuntimeStatus {
+ status := RuntimeStatus{
+ Status: "stopped",
+ PID: m.pid,
+ Version: m.version,
+ LastError: m.lastErr,
+ }
+ if m.runningLocked() {
+ status.Status = "running"
+ if status.PID == 0 {
+ status.PID = m.orphanPID
+ }
+ } else if m.orphan != nil {
+ status.PID = 0
+ }
+ if !m.started.IsZero() {
+ status.LastStartedAt = m.started.Format(time.RFC3339)
+ }
+ if !m.reloaded.IsZero() {
+ status.LastReloadedAt = m.reloaded.Format(time.RFC3339)
+ }
+ return status
+}
+
+func (m *Manager) RefreshVersion(ctx context.Context) {
+ bin, err := m.BinaryPath()
+ if err != nil {
+ return
+ }
+ output, err := exec.CommandContext(ctx, bin, "--version").Output()
+ if err != nil {
+ return
+ }
+ m.mu.Lock()
+ m.version = strings.TrimSpace(string(output))
+ m.mu.Unlock()
+}
+
+func (m *Manager) wait(cmd *exec.Cmd, logFile *os.File) {
+ err := cmd.Wait()
+ logFile.Close()
+ m.mu.Lock()
+ if m.cmd != cmd {
+ m.mu.Unlock()
+ return
+ }
+ m.cmd = nil
+ m.pid = 0
+ if err != nil {
+ // 退出原因在 frpc 自身日志的尾部(stderr 也写在那里),直接携带进应用日志,避免只看到 exit status 1
+ m.lastErr = fmt.Sprintf("frpc 已退出:%v(详见 %s 末尾)", err, m.logPath)
+ m.logger.Warn("frpc exited", "error", err.Error(), "log_tail", tailFile(m.logPath, 12))
+ }
+ m.mu.Unlock()
+}
+
+// tailFile 返回文件最后 maxLines 行文本;读不到时返回空串(不阻断退出处理)。
+func tailFile(path string, maxLines int) string {
+ data, err := os.ReadFile(path)
+ if err != nil || len(data) == 0 {
+ return ""
+ }
+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+ if len(lines) > maxLines {
+ lines = lines[len(lines)-maxLines:]
+ }
+ return strings.Join(lines, " | ")
+}
+
+func (m *Manager) running() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.runningLocked()
+}
+
+func (m *Manager) runningLocked() bool {
+ if m.cmd != nil && m.cmd.Process != nil {
+ return true
+ }
+ return m.orphan != nil && processAlive(m.orphanPID)
+}
+
+func (m *Manager) Attach(pid int) bool {
+ bin, err := m.BinaryPath()
+ if err != nil || !processMatches(pid, bin, m.configPath) {
+ return false
+ }
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ m.mu.Lock()
+ m.orphan = process
+ m.orphanPID = pid
+ m.pid = pid
+ m.lastErr = ""
+ m.mu.Unlock()
+ return true
+}
+
+func processAlive(pid int) bool {
+ alive, _ := probeProcess(pid)
+ return alive
+}
+
+func processMatches(pid int, binary, configPath string) bool {
+ alive, commandLine := probeProcess(pid)
+ if !alive {
+ return false
+ }
+ commandLine = strings.ToLower(filepath.ToSlash(commandLine))
+ return strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(binary))) &&
+ strings.Contains(commandLine, strings.ToLower(filepath.ToSlash(configPath)))
+}
+
+func (m *Manager) BinaryPath() (string, error) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.binaryPathLocked()
+}
+
+func (m *Manager) binaryPathLocked() (string, error) {
+ if strings.ContainsAny(m.bin, `/\\`) {
+ if _, err := os.Stat(m.bin); err != nil {
+ return "", fmt.Errorf("未找到 frpc 二进制:%s", m.bin)
+ }
+ return m.bin, nil
+ }
+ path, err := exec.LookPath(m.bin)
+ if err != nil {
+ return "", fmt.Errorf("未找到 frpc 二进制,请设置 FONU_FRPC_BIN")
+ }
+ return path, nil
+}
+
+func (m *Manager) SetBinary(bin string) {
+ m.mu.Lock()
+ m.bin = bin
+ m.version = ""
+ m.mu.Unlock()
+}
+
+func (m *Manager) LogPath() string {
+ return filepath.Clean(m.logPath)
+}
+
+func safeCommandOutput(output []byte, err error) string {
+ text := strings.TrimSpace(string(output))
+ if text == "" {
+ return err.Error()
+ }
+ if len(text) > 500 {
+ return text[:500]
+ }
+ return text
+}
diff --git a/internal/frp/model.go b/internal/frp/model.go
new file mode 100644
index 0000000..b857647
--- /dev/null
+++ b/internal/frp/model.go
@@ -0,0 +1,345 @@
+package frp
+
+import "time"
+
+type RuntimeInfo struct {
+ Platform string `json:"platform"`
+ ConfiguredBin string `json:"configured_bin"`
+ ActiveBin string `json:"active_bin,omitempty"`
+ ActiveVersion string `json:"active_version,omitempty"`
+ DetectedVersion string `json:"detected_version,omitempty"`
+ DownloadProxy string `json:"download_proxy"`
+ ConfigPath string `json:"config_path"`
+ ConfigPaths []string `json:"config_paths,omitempty"`
+ ConfigExists bool `json:"config_exists"`
+}
+
+type Release struct {
+ Version string `json:"version"`
+ PublishedAt string `json:"published_at,omitempty"`
+ AssetName string `json:"asset_name"`
+ Size int64 `json:"size"`
+ Checksum string `json:"-"`
+ AssetID int64 `json:"-"`
+}
+
+type Binary struct {
+ Version string `json:"version"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ InstalledAt string `json:"installed_at"`
+ Active bool `json:"active"`
+}
+
+// DiagnosticCheck 单项诊断结果;State 取 ok / failed / unknown(unknown 表示无法确认,不等于失败)。
+type DiagnosticCheck struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+ Detail string `json:"detail,omitempty"`
+ LatencyMS int64 `json:"latency_ms,omitempty"`
+}
+
+// ServerDiagnostics 服务端诊断结果。Available/LatencyMS/Error 保持 tcp_reachable 口径
+// 兼容历史延迟曲线;定位等非阻断信息只出现在 Checks 中,不参与 Error。
+type ServerDiagnostics struct {
+ ServerID int64 `json:"server_id"`
+ Available bool `json:"available"`
+ LatencyMS int64 `json:"latency_ms,omitempty"`
+ Location string `json:"location,omitempty"`
+ Checks []DiagnosticCheck `json:"checks"`
+ Error string `json:"error,omitempty"`
+}
+
+type ServerSample struct {
+ CheckedAt string `json:"checked_at"`
+ Available bool `json:"available"`
+ LatencyMS int64 `json:"latency_ms,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type ServerStatusDetail struct {
+ Available bool `json:"available"`
+ Availability float64 `json:"availability"`
+ AverageLatency int64 `json:"average_latency_ms"`
+ PeakLatency int64 `json:"peak_latency_ms"`
+ Samples []ServerSample `json:"samples"`
+ LastCheckedAt string `json:"last_checked_at,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+}
+
+type ServerDetail struct {
+ Server Server `json:"server"`
+ Status ServerStatusDetail `json:"status"`
+ Proxies []Proxy `json:"proxies"`
+}
+
+type DownloadTask struct {
+ Version string `json:"version,omitempty"`
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Downloaded int64 `json:"downloaded"`
+ Total int64 `json:"total"`
+ Error string `json:"error,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+}
+
+type Server struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ AgentURL string `json:"agent_url,omitempty"`
+ AgentConfigured bool `json:"agent_configured"`
+ SSHConfigured bool `json:"ssh_configured"`
+ MgmtEnabled bool `json:"mgmt_enabled"`
+ SSHHost string `json:"ssh_host,omitempty"`
+ SSHPort int `json:"ssh_port,omitempty"`
+ SSHUser string `json:"ssh_user,omitempty"`
+ SSHAuth string `json:"ssh_auth,omitempty"`
+ ServerAddr string `json:"server_addr"`
+ ServerPort int `json:"server_port"`
+ TLS bool `json:"tls_enabled"`
+ TLSServerName string `json:"tls_server_name,omitempty"`
+ Enabled bool `json:"enabled"`
+ HasToken bool `json:"has_token"`
+ HasOIDCSecret bool `json:"has_oidc_client_secret"`
+ DashboardHasPwd bool `json:"dashboard_has_pwd"`
+ Options ServerOptions `json:"options"`
+ BootStatus string `json:"boot_status"`
+ DesiredState string `json:"desired_state"`
+ ProcessState string `json:"process_state"`
+ ConnectionState string `json:"connection_state"`
+ StateSource string `json:"state_source"`
+ ProcessPID int `json:"process_pid,omitempty"`
+ ExitCode int `json:"exit_code,omitempty"`
+ LastConnectedAt string `json:"last_connected_at,omitempty"`
+ LastDisconnectedAt string `json:"last_disconnected_at,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+ LastStartedAt string `json:"last_started_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ServerRuntimeStatus struct {
+ ServerID int64
+ Status string
+ DesiredState string
+ ProcessState string
+ ConnectionState string
+ StateSource string
+ ProcessPID int
+ ExitCode int
+ LastError string
+ LastStartedAt string
+ LastConnectedAt string
+ LastDisconnectedAt string
+}
+
+type ServerOptions struct {
+ User string `json:"user,omitempty"`
+ Location string `json:"location,omitempty"`
+ AuthMethod string `json:"auth_method"`
+ OIDCClientID string `json:"oidc_client_id,omitempty"`
+ OIDCAudience string `json:"oidc_audience,omitempty"`
+ OIDCScope string `json:"oidc_scope,omitempty"`
+ OIDCTokenEndpointURL string `json:"oidc_token_endpoint_url,omitempty"`
+ OIDCTrustedCAPath string `json:"oidc_trusted_ca_path,omitempty"`
+ OIDCInsecureSkipVerify bool `json:"oidc_insecure_skip_verify"`
+ OIDCProxyURL string `json:"oidc_proxy_url,omitempty"`
+ // VPS frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 生成器和 Nginx upstream 同源
+ VhostHTTPPort int `json:"vhost_http_port,omitempty"`
+ VhostHTTPSPort int `json:"vhost_https_port,omitempty"`
+ // frps 进阶配置:子域名/404/强制 TLS/端口白名单/每客户端端口上限/tcpmux/文件日志
+ SubDomainHost string `json:"sub_domain_host,omitempty"`
+ Custom404Page string `json:"custom_404_page,omitempty"`
+ TLSForce bool `json:"tls_force"`
+ AllowPorts string `json:"allow_ports,omitempty"`
+ MaxPortsPerClient int `json:"max_ports_per_client,omitempty"`
+ TCPMuxHTTPConnectPort int `json:"tcpmux_http_connect_port,omitempty"`
+ FrpsLogToFile bool `json:"frps_log_to_file"`
+ FrpsLogMaxDays int `json:"frps_log_max_days,omitempty"`
+ // frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息,不写入 frpc.toml
+ DashboardAddr string `json:"dashboard_addr,omitempty"`
+ DashboardPort int `json:"dashboard_port,omitempty"`
+ DashboardUser string `json:"dashboard_user,omitempty"`
+ DashboardHasPwd bool `json:"dashboard_has_pwd"`
+ LogLevel string `json:"log_level"`
+ LogMaxDays int `json:"log_max_days"`
+ Protocol string `json:"protocol"`
+ ProxyURL string `json:"proxy_url,omitempty"`
+ DialServerTimeout int `json:"dial_server_timeout"`
+ DialServerKeepalive int `json:"dial_server_keepalive"`
+ ConnectServerLocalIP string `json:"connect_server_local_ip,omitempty"`
+ DNSServer string `json:"dns_server,omitempty"`
+ PoolCount int `json:"pool_count"`
+ TCPMux bool `json:"tcp_mux"`
+ TCPMuxKeepaliveInterval int `json:"tcp_mux_keepalive_interval"`
+ HeartbeatInterval int `json:"heartbeat_interval"`
+ HeartbeatTimeout int `json:"heartbeat_timeout"`
+ LoginFailExit bool `json:"login_fail_exit"`
+ TLSDisableCustomFirstByte bool `json:"tls_disable_custom_first_byte"`
+ AutoStart bool `json:"auto_start"`
+ Remark string `json:"remark,omitempty"`
+ TLSCertificateConfigured bool `json:"tls_certificate_configured"`
+ TLSKeyConfigured bool `json:"tls_key_configured"`
+ TLSTrustedCAConfigured bool `json:"tls_trusted_ca_configured"`
+}
+
+type Proxy struct {
+ ID int64 `json:"id"`
+ ServerID int64 `json:"server_id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ LocalIP string `json:"local_ip"`
+ LocalPort int `json:"local_port"`
+ RemotePort *int `json:"remote_port,omitempty"`
+ CustomDomains []string `json:"custom_domains"`
+ HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
+ Options ProxyOptions `json:"options"`
+ Enabled bool `json:"enabled"`
+ Remark string `json:"remark,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type ServerInput struct {
+ AgentURL string
+ AgentToken string
+ ClearAgentToken bool
+ SSHHost string
+ SSHPort int
+ SSHUser string
+ SSHAuth string
+ SSHSecret string
+ ClearSSHSecret bool
+ MgmtEnabled bool
+
+ Name string
+ ServerAddr string
+ ServerPort int
+ TLS bool
+ TLSServerName string
+ Enabled bool
+ Token string
+ ClearToken bool
+ Options ServerOptions
+ OIDCClientSecret string
+ ClearOIDCClientSecret bool
+ DashboardPassword string
+ ClearDashboardPassword bool
+ TLSCertificate string
+ TLSKey string
+ TLSTrustedCA string
+ ClearTLSCertificate bool
+ ClearTLSKey bool
+ ClearTLSTrustedCA bool
+}
+
+type ProxyInput struct {
+ ServerID int64
+ Name string
+ Type string
+ LocalIP string
+ LocalPort int
+ RemotePort *int
+ CustomDomains []string
+ HostHeaderRewrite string
+ Options ProxyOptions
+ Enabled bool
+ Remark string
+}
+
+// ProxyOptions 对应 FRP [[proxies]] 中除基础地址外的扩展配置。
+type ProxyOptions struct {
+ Subdomain string `json:"subdomain,omitempty"`
+ // WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头),不写入 frpc.toml
+ WebSocket bool `json:"websocket,omitempty"`
+ // 公网反代进阶选项(不写入 frpc.toml):强制跳转 HTTPS / IP 白名单 / Basic Auth 用户名(密码由 agent 生成)
+ RedirectHTTPS bool `json:"redirect_https,omitempty"`
+ AllowIPs []string `json:"allow_ips,omitempty"`
+ BasicAuthUser string `json:"basic_auth_user,omitempty"`
+ // 公网反代第一梯队部署设置(同属公网 Nginx vhost 选项,不写入 frpc.toml)
+ DenyIPs []string `json:"deny_ips,omitempty"` // IP 黑名单
+ BasicAuthPassword string `json:"basic_auth_password,omitempty"` // 自定义 Basic Auth 密码;空则首次自动生成
+ HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS
+ ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小,如 50m
+ ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
+ SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头
+ TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
+ // 第二梯队部署设置(同为公网 Nginx vhost 选项)
+ RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
+ RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
+ ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
+ ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP
+ Locations []string `json:"locations,omitempty"`
+ RouteByHTTPUser string `json:"route_by_http_user,omitempty"`
+ HTTPUser string `json:"http_user,omitempty"`
+ HTTPPassword string `json:"http_password,omitempty"`
+ Multiplexer string `json:"multiplexer,omitempty"`
+ SecretKey string `json:"secret_key,omitempty"`
+ AllowUsers []string `json:"allow_users,omitempty"`
+ Transport ProxyTransportOptions `json:"transport"`
+ HealthCheck ProxyHealthCheckOptions `json:"health_check"`
+ LoadBalancer ProxyLoadBalancer `json:"load_balancer"`
+ Metadatas map[string]string `json:"metadatas,omitempty"`
+ RequestHeaders map[string]string `json:"request_headers,omitempty"`
+ ResponseHeaders map[string]string `json:"response_headers,omitempty"`
+ Plugin *ProxyPluginOptions `json:"plugin,omitempty"`
+ NATTraversal *ProxyNATTraversal `json:"nat_traversal,omitempty"`
+}
+
+type ProxyTransportOptions struct {
+ BandwidthLimit string `json:"bandwidth_limit,omitempty"`
+ BandwidthLimitMode string `json:"bandwidth_limit_mode,omitempty"`
+ UseEncryption bool `json:"use_encryption"`
+ UseCompression bool `json:"use_compression"`
+ ProxyProtocolVersion string `json:"proxy_protocol_version,omitempty"`
+}
+
+type ProxyHealthCheckOptions struct {
+ Type string `json:"type,omitempty"`
+ Path string `json:"path,omitempty"`
+ IntervalSeconds int `json:"interval_seconds,omitempty"`
+ MaxFailed int `json:"max_failed,omitempty"`
+ TimeoutSeconds int `json:"timeout_seconds,omitempty"`
+ HTTPHeaders []ProxyHeader `json:"http_headers,omitempty"`
+}
+
+type ProxyHeader struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+type ProxyLoadBalancer struct {
+ Group string `json:"group,omitempty"`
+ GroupKey string `json:"group_key,omitempty"`
+}
+
+type ProxyPluginOptions struct {
+ Type string `json:"type"`
+ UnixPath string `json:"unix_path,omitempty"`
+ LocalPath string `json:"local_path,omitempty"`
+ StripPrefix string `json:"strip_prefix,omitempty"`
+ LocalAddr string `json:"local_addr,omitempty"`
+ HTTPUser string `json:"http_user,omitempty"`
+ HTTPPassword string `json:"http_password,omitempty"`
+ Username string `json:"username,omitempty"`
+ Password string `json:"password,omitempty"`
+ CRTPath string `json:"crt_path,omitempty"`
+ KeyPath string `json:"key_path,omitempty"`
+ HostHeaderRewrite string `json:"host_header_rewrite,omitempty"`
+ DestinationIP string `json:"destination_ip,omitempty"`
+}
+
+type ProxyNATTraversal struct {
+ DisableAssistedAddrs bool `json:"disable_assisted_addrs"`
+}
+
+type RuntimeStatus struct {
+ Status string `json:"status"`
+ PID int `json:"pid,omitempty"`
+ Version string `json:"frpc_version,omitempty"`
+ LastStartedAt string `json:"last_started_at,omitempty"`
+ LastReloadedAt string `json:"last_reloaded_at,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+ Configured bool `json:"configured"`
+ EnabledProxies int `json:"enabled_proxies"`
+}
diff --git a/internal/frp/multi_manager.go b/internal/frp/multi_manager.go
new file mode 100644
index 0000000..478585f
--- /dev/null
+++ b/internal/frp/multi_manager.go
@@ -0,0 +1,104 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "path/filepath"
+ "strings"
+)
+
+func (s *Service) managerForServer(serverID int64) *Manager {
+ s.managersMu.Lock()
+ defer s.managersMu.Unlock()
+ if manager, ok := s.managers[serverID]; ok {
+ return manager
+ }
+ bin, err := s.manager.BinaryPath()
+ if err != nil {
+ bin = s.cfg.FrpcBin
+ }
+ manager := NewManager(
+ bin,
+ s.serverConfigPath(serverID),
+ s.serverLogPath(serverID),
+ s.logger,
+ )
+ s.managers[serverID] = manager
+ return manager
+}
+
+func (s *Service) serverConfigPath(serverID int64) string {
+ return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.toml", serverID))
+}
+
+func (s *Service) serverLogPath(serverID int64) string {
+ return filepath.Join(s.cfg.FrpDir(), fmt.Sprintf("frpc-server-%d.log", serverID))
+}
+
+func (s *Service) managerSnapshot() map[int64]*Manager {
+ s.managersMu.RLock()
+ defer s.managersMu.RUnlock()
+ result := make(map[int64]*Manager, len(s.managers))
+ for id, manager := range s.managers {
+ result[id] = manager
+ }
+ return result
+}
+
+func (s *Service) setManagerBinary(path string) {
+ s.manager.SetBinary(path)
+ for _, manager := range s.managerSnapshot() {
+ manager.SetBinary(path)
+ }
+}
+
+func (s *Service) removeManager(serverID int64) {
+ s.managersMu.Lock()
+ delete(s.managers, serverID)
+ s.managersMu.Unlock()
+}
+
+func latestRuntimeStatus(statuses []RuntimeStatus) RuntimeStatus {
+ result := RuntimeStatus{Status: "stopped"}
+ for _, status := range statuses {
+ if status.Status == "running" {
+ result.Status = "running"
+ }
+ if status.Configured {
+ result.Configured = true
+ }
+ result.EnabledProxies += status.EnabledProxies
+ if result.PID == 0 && status.PID != 0 {
+ result.PID = status.PID
+ }
+ if status.LastStartedAt > result.LastStartedAt {
+ result.LastStartedAt = status.LastStartedAt
+ }
+ if status.LastReloadedAt > result.LastReloadedAt {
+ result.LastReloadedAt = status.LastReloadedAt
+ }
+ if result.LastError == "" && status.LastError != "" {
+ result.LastError = status.LastError
+ }
+ if status.Version != "" && result.Version == "" {
+ result.Version = status.Version
+ }
+ }
+ if len(statuses) == 0 {
+ result.Status = "unknown"
+ }
+ return result
+}
+
+func (s *Service) waitForManagers(ctx context.Context, action func(*Manager) error) error {
+ var messages []string
+ for id, manager := range s.managerSnapshot() {
+ if err := action(manager); err != nil {
+ messages = append(messages, fmt.Sprintf("服务端 %d:%v", id, err))
+ }
+ }
+ if len(messages) > 0 {
+ return fmt.Errorf("%s", strings.Join(messages, ";"))
+ }
+ return nil
+}
diff --git a/internal/frp/process_probe_other.go b/internal/frp/process_probe_other.go
new file mode 100644
index 0000000..56a9295
--- /dev/null
+++ b/internal/frp/process_probe_other.go
@@ -0,0 +1,16 @@
+//go:build !linux && !darwin && !freebsd && !windows
+
+package frp
+
+import "os"
+
+func probeProcess(pid int) (bool, string) {
+ if pid <= 0 {
+ return false, ""
+ }
+ process, err := os.FindProcess(pid)
+ if err != nil {
+ return false, ""
+ }
+ return true, process.String()
+}
diff --git a/internal/frp/process_probe_unix.go b/internal/frp/process_probe_unix.go
new file mode 100644
index 0000000..5588879
--- /dev/null
+++ b/internal/frp/process_probe_unix.go
@@ -0,0 +1,25 @@
+//go:build linux || darwin || freebsd
+
+package frp
+
+import (
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+func probeProcess(pid int) (bool, string) {
+ if pid <= 0 {
+ return false, ""
+ }
+ if data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/cmdline"); err == nil {
+ commandLine := strings.ReplaceAll(string(data), "\x00", " ")
+ return strings.TrimSpace(commandLine) != "", strings.TrimSpace(commandLine)
+ }
+ output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "command=").Output()
+ if err != nil {
+ return false, ""
+ }
+ return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
+}
diff --git a/internal/frp/process_probe_windows.go b/internal/frp/process_probe_windows.go
new file mode 100644
index 0000000..b3c6fe3
--- /dev/null
+++ b/internal/frp/process_probe_windows.go
@@ -0,0 +1,22 @@
+//go:build windows
+
+package frp
+
+import (
+ "fmt"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+func probeProcess(pid int) (bool, string) {
+ if pid <= 0 {
+ return false, ""
+ }
+ script := fmt.Sprintf("$p=Get-CimInstance Win32_Process -Filter \"ProcessId=%s\"; if ($p) { $p.CommandLine }", strconv.Itoa(pid))
+ output, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Output()
+ if err != nil {
+ return false, ""
+ }
+ return strings.TrimSpace(string(output)) != "", strings.TrimSpace(string(output))
+}
diff --git a/internal/frp/runtime.go b/internal/frp/runtime.go
new file mode 100644
index 0000000..bc9ec17
--- /dev/null
+++ b/internal/frp/runtime.go
@@ -0,0 +1,539 @@
+package frp
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "time"
+)
+
+const (
+ githubReleasesURL = "https://api.github.com/repos/fatedier/frp/releases"
+ maxArchiveSize = 150 << 20
+)
+
+var downloadProxyOptions = map[string]struct{}{
+ "": {},
+ "https://gh-proxy.org/": {},
+ "https://v4.gh-proxy.org/": {},
+ "https://cdn.gh-proxy.org/": {},
+}
+
+var releaseProxyOrder = []string{
+ "https://gh-proxy.org/",
+ "https://v4.gh-proxy.org/",
+ "https://cdn.gh-proxy.org/",
+}
+
+type githubRelease struct {
+ TagName string `json:"tag_name"`
+ Draft bool `json:"draft"`
+ Prerelease bool `json:"prerelease"`
+ PublishedAt time.Time `json:"published_at"`
+ Assets []githubAsset `json:"assets"`
+}
+
+type githubAsset struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Digest string `json:"digest"`
+}
+
+type downloadReporter func(status, message string, downloaded, total int64, failure string)
+
+func runtimePlatform() string {
+ return runtime.GOOS + "/" + runtime.GOARCH
+}
+
+func fetchOfficialReleases(ctx context.Context, selectedProxy string) ([]Release, error) {
+ var failures []string
+ for _, url := range releaseListURLs(selectedProxy) {
+ releases, err := fetchOfficialReleasesAt(ctx, url)
+ if err == nil {
+ return releases, nil
+ }
+ failures = append(failures, err.Error())
+ }
+ return nil, fmt.Errorf("获取 FRP 官方版本列表失败:%s", strings.Join(failures, ";"))
+}
+
+func fetchOfficialReleasesAt(ctx context.Context, url string) ([]Release, error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("Accept", "application/vnd.github+json")
+ client := &http.Client{Timeout: 30 * time.Second}
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", response.StatusCode)
+ }
+ var source []githubRelease
+ if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&source); err != nil {
+ return nil, fmt.Errorf("响应解析失败:%w", err)
+ }
+ return supportedReleases(source), nil
+}
+
+func releaseListURLs(selectedProxy string) []string {
+ urls := []string{}
+ add := func(proxy string) {
+ url := githubReleasesURL
+ if proxy != "" {
+ url = proxy + githubReleasesURL
+ }
+ for _, item := range urls {
+ if item == url {
+ return
+ }
+ }
+ urls = append(urls, url)
+ }
+ add(selectedProxy)
+ add("")
+ for _, proxy := range releaseProxyOrder {
+ add(proxy)
+ }
+ return urls
+}
+
+func supportedReleases(source []githubRelease) []Release {
+ releases := make([]Release, 0, len(source))
+ for _, item := range source {
+ if item.Draft || item.Prerelease {
+ continue
+ }
+ asset, ok := findArchiveAsset(item)
+ if !ok {
+ continue
+ }
+ releases = append(releases, Release{Version: item.TagName, PublishedAt: item.PublishedAt.UTC().Format(time.RFC3339), AssetName: asset.Name, Size: asset.Size, Checksum: strings.TrimPrefix(asset.Digest, "sha256:"), AssetID: asset.ID})
+ }
+ return releases
+}
+
+func findArchiveAsset(release githubRelease) (githubAsset, bool) {
+ version := strings.TrimPrefix(release.TagName, "v")
+ prefix := "frp_" + version + "_" + runtime.GOOS + "_" + runtime.GOARCH
+ for _, asset := range release.Assets {
+ if asset.Name == prefix+".zip" || asset.Name == prefix+".tar.gz" {
+ return asset, true
+ }
+ }
+ return githubAsset{}, false
+}
+
+func findRelease(releases []Release, version string) (Release, bool) {
+ for _, release := range releases {
+ if release.Version == version {
+ return release, true
+ }
+ }
+ return Release{}, false
+}
+
+func downloadOfficialBinary(ctx context.Context, frpDir string, release Release, proxy string, report downloadReporter) (string, error) {
+ checksum := release.Checksum
+ if checksum == "" {
+ report("queued", "正在获取官方 SHA-256 校验信息", 0, 0, "")
+ var err error
+ checksum, err = fetchChecksum(ctx, release)
+ if err != nil {
+ return "", err
+ }
+ }
+ archivePath, err := downloadArchive(ctx, frpDir, release, checksum, proxy, report)
+ if err != nil {
+ return "", err
+ }
+ defer os.Remove(archivePath)
+ report("extracting", "SHA-256 校验通过,正在解压 frpc", 0, 0, "")
+ return extractClientBinary(archivePath, frpDir, release)
+}
+
+func fetchChecksum(ctx context.Context, release Release) (string, error) {
+ version := strings.TrimPrefix(release.Version, "v")
+ name := "frp_" + version + "_sha256sums.txt"
+ url := officialAssetURL(release.Version, name)
+ data, err := fetchBytes(ctx, url, 1<<20)
+ if err != nil {
+ return "", fmt.Errorf("下载 FRP 官方校验文件失败:%w", err)
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == release.AssetName {
+ return fields[0], nil
+ }
+ }
+ return "", fmt.Errorf("FRP 官方校验文件中未找到 %s", release.AssetName)
+}
+
+func downloadArchive(ctx context.Context, frpDir string, release Release, checksum, proxy string, report downloadReporter) (string, error) {
+ report("downloading", "正在下载 FRP 官方安装包", 0, release.Size, "")
+ data, err := fetchBytesWithProgress(ctx, releaseDownloadURL(release, proxy), maxArchiveSize, func(downloaded, total int64) {
+ if total <= 0 {
+ total = release.Size
+ }
+ report("downloading", "正在下载 FRP 官方安装包", downloaded, total, "")
+ })
+ if err != nil {
+ return "", fmt.Errorf("下载 FRP 官方安装包失败:%w;请检查网络连接或设置 HTTPS_PROXY", err)
+ }
+ report("verifying", "安装包下载完成,正在校验 SHA-256", int64(len(data)), int64(len(data)), "")
+ sum := fmt.Sprintf("%x", sha256.Sum256(data))
+ if !strings.EqualFold(sum, checksum) {
+ return "", fmt.Errorf("FRP 安装包 SHA-256 校验失败")
+ }
+ dir := filepath.Join(frpDir, "downloads")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return "", err
+ }
+ path := filepath.Join(dir, release.AssetName)
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func fetchBytes(ctx context.Context, url string, limit int64) ([]byte, error) {
+ return fetchBytesWithProgress(ctx, url, limit, nil)
+}
+
+func fetchBytesWithProgress(ctx context.Context, url string, limit int64, progress func(int64, int64)) ([]byte, error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("User-Agent", "Fonu-FRP-Downloader")
+ if strings.HasPrefix(url, "https://api.github.com/repos/fatedier/frp/releases/assets/") {
+ request.Header.Set("Accept", "application/octet-stream")
+ request.Header.Set("X-GitHub-Api-Version", "2022-11-28")
+ }
+ client := &http.Client{Timeout: 5 * time.Minute}
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", response.StatusCode)
+ }
+ return readResponseBody(response.Body, response.ContentLength, limit, progress)
+}
+
+func readResponseBody(reader io.Reader, total, limit int64, progress func(int64, int64)) ([]byte, error) {
+ var output bytes.Buffer
+ buffer := make([]byte, 64*1024)
+ var downloaded int64
+ for {
+ count, err := reader.Read(buffer)
+ if count > 0 {
+ downloaded += int64(count)
+ if downloaded > limit {
+ return nil, fmt.Errorf("下载内容超过大小限制")
+ }
+ _, _ = output.Write(buffer[:count])
+ if progress != nil {
+ progress(downloaded, total)
+ }
+ }
+ if err == io.EOF {
+ return output.Bytes(), nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+}
+
+func officialAssetURL(version, name string) string {
+ return "https://github.com/fatedier/frp/releases/download/" + version + "/" + name
+}
+
+func releaseDownloadURL(release Release, proxy string) string {
+ if proxy != "" {
+ return proxy + officialAssetURL(release.Version, release.AssetName)
+ }
+ if release.AssetID > 0 {
+ return fmt.Sprintf("https://api.github.com/repos/fatedier/frp/releases/assets/%d", release.AssetID)
+ }
+ return officialAssetURL(release.Version, release.AssetName)
+}
+
+func downloadProxyPath(frpDir string) string {
+ return filepath.Join(frpDir, "download-proxy.json")
+}
+
+func readDownloadProxy(frpDir string) (string, error) {
+ data, err := os.ReadFile(downloadProxyPath(frpDir))
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ var state struct {
+ Proxy string `json:"proxy"`
+ }
+ if err := json.Unmarshal(data, &state); err != nil {
+ return "", fmt.Errorf("FRP 下载代理配置无效")
+ }
+ if !validDownloadProxy(state.Proxy) {
+ return "", fmt.Errorf("FRP 下载代理配置无效")
+ }
+ return state.Proxy, nil
+}
+
+func writeDownloadProxy(frpDir, proxy string) error {
+ if !validDownloadProxy(proxy) {
+ return fmt.Errorf("不支持的 FRP 下载加速代理")
+ }
+ if err := os.MkdirAll(frpDir, 0o700); err != nil {
+ return err
+ }
+ data, err := json.Marshal(struct {
+ Proxy string `json:"proxy"`
+ }{Proxy: proxy})
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(downloadProxyPath(frpDir), data, 0o600)
+}
+
+func validDownloadProxy(proxy string) bool {
+ _, ok := downloadProxyOptions[proxy]
+ return ok
+}
+
+func extractClientBinary(archivePath, frpDir string, release Release) (string, error) {
+ data, err := os.ReadFile(archivePath)
+ if err != nil {
+ return "", err
+ }
+ binary, err := extractBinary(data, release.AssetName)
+ if err != nil {
+ return "", err
+ }
+ path := filepath.Join(frpDir, "bin", release.Version, clientBinaryName())
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(path, binary, 0o700); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func extractBinary(data []byte, assetName string) ([]byte, error) {
+ if strings.HasSuffix(assetName, ".zip") {
+ return extractZipBinary(data)
+ }
+ return extractTarBinary(data)
+}
+
+func extractZipBinary(data []byte) ([]byte, error) {
+ reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
+ if err != nil {
+ return nil, fmt.Errorf("FRP ZIP 安装包无效:%w", err)
+ }
+ for _, file := range reader.File {
+ if filepath.Base(file.Name) != clientBinaryName() || file.FileInfo().IsDir() {
+ continue
+ }
+ return readZipFile(file)
+ }
+ return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
+}
+
+func readZipFile(file *zip.File) ([]byte, error) {
+ if file.UncompressedSize64 > maxArchiveSize {
+ return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+ }
+ reader, err := file.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ return boundedRead(reader, maxArchiveSize)
+}
+
+func extractTarBinary(data []byte) ([]byte, error) {
+ gzipReader, err := gzip.NewReader(bytes.NewReader(data))
+ if err != nil {
+ return nil, fmt.Errorf("FRP TAR.GZ 安装包无效:%w", err)
+ }
+ defer gzipReader.Close()
+ reader := tar.NewReader(gzipReader)
+ for {
+ header, err := reader.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != clientBinaryName() {
+ continue
+ }
+ if header.Size > maxArchiveSize {
+ return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+ }
+ return boundedRead(reader, maxArchiveSize)
+ }
+ return nil, fmt.Errorf("FRP 安装包中未找到 %s", clientBinaryName())
+}
+
+func boundedRead(reader io.Reader, limit int64) ([]byte, error) {
+ data, err := io.ReadAll(io.LimitReader(reader, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > limit {
+ return nil, fmt.Errorf("FRP 客户端文件超过大小限制")
+ }
+ return data, nil
+}
+
+func clientBinaryName() string {
+ if runtime.GOOS == "windows" {
+ return "frpc.exe"
+ }
+ return "frpc"
+}
+
+func listBinaries(frpDir, active string) ([]Binary, error) {
+ root := filepath.Join(frpDir, "bin")
+ entries, err := os.ReadDir(root)
+ if os.IsNotExist(err) {
+ return []Binary{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ binaries := make([]Binary, 0, len(entries))
+ for _, entry := range entries {
+ binary, ok := binaryFromEntry(root, entry, active)
+ if ok {
+ binaries = append(binaries, binary)
+ }
+ }
+ sort.Slice(binaries, func(i, j int) bool { return binaries[i].Version > binaries[j].Version })
+ return binaries, nil
+}
+
+func binaryFromEntry(root string, entry os.DirEntry, active string) (Binary, bool) {
+ if !entry.IsDir() || !validVersion(entry.Name()) {
+ return Binary{}, false
+ }
+ path := filepath.Join(root, entry.Name(), clientBinaryName())
+ info, err := os.Stat(path)
+ if err != nil || info.IsDir() {
+ return Binary{}, false
+ }
+ return Binary{Version: entry.Name(), Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339), Active: filepath.Clean(path) == filepath.Clean(active)}, true
+}
+
+func binaryPathForVersion(frpDir, version string) (string, error) {
+ if !validVersion(version) {
+ return "", fmt.Errorf("FRP 版本格式无效")
+ }
+ path := filepath.Join(frpDir, "bin", version, clientBinaryName())
+ if !fileExists(path) {
+ return "", fmt.Errorf("FRP %s 尚未下载", version)
+ }
+ return path, nil
+}
+
+func removeBinaryVersion(frpDir, version, binaryPath string) error {
+ root, err := filepath.Abs(filepath.Join(frpDir, "bin"))
+ if err != nil {
+ return err
+ }
+ target, err := filepath.Abs(filepath.Dir(binaryPath))
+ if err != nil {
+ return err
+ }
+ relative, err := filepath.Rel(root, target)
+ if err != nil || filepath.Clean(relative) != version {
+ return fmt.Errorf("FRP 客户端目录无效")
+ }
+ return os.RemoveAll(target)
+}
+
+func validVersion(version string) bool {
+ if version == "" || len(version) > 32 || strings.ContainsAny(version, `/\\`) {
+ return false
+ }
+ for _, char := range version {
+ if (char < '0' || char > '9') && char != '.' && char != 'v' && char != '-' {
+ return false
+ }
+ }
+ return true
+}
+
+func activeBinaryPath(frpDir string) string {
+ return filepath.Join(frpDir, "bin", "active.json")
+}
+
+func readActiveBinary(frpDir string) (string, error) {
+ data, err := os.ReadFile(activeBinaryPath(frpDir))
+ if err != nil {
+ return "", err
+ }
+ var state struct {
+ Path string `json:"path"`
+ }
+ if err := json.Unmarshal(data, &state); err != nil || versionFromBinaryPath(frpDir, state.Path) == "" || !fileExists(state.Path) {
+ return "", fmt.Errorf("已启用的 FRP 客户端无效")
+ }
+ return state.Path, nil
+}
+
+func writeActiveBinary(frpDir, path string) error {
+ if err := os.MkdirAll(filepath.Dir(activeBinaryPath(frpDir)), 0o700); err != nil {
+ return err
+ }
+ data, err := json.Marshal(struct {
+ Path string `json:"path"`
+ }{Path: path})
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(activeBinaryPath(frpDir), data, 0o600)
+}
+
+func versionFromBinaryPath(frpDir, path string) string {
+ root := filepath.Join(frpDir, "bin")
+ relative, err := filepath.Rel(root, path)
+ if err != nil || strings.HasPrefix(relative, "..") {
+ return ""
+ }
+ parts := strings.Split(filepath.ToSlash(relative), "/")
+ if len(parts) != 2 || !validVersion(parts[0]) {
+ return ""
+ }
+ return parts[0]
+}
+
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/internal/frp/runtime_test.go b/internal/frp/runtime_test.go
new file mode 100644
index 0000000..e5f5dcf
--- /dev/null
+++ b/internal/frp/runtime_test.go
@@ -0,0 +1,88 @@
+package frp
+
+import (
+ "archive/zip"
+ "bytes"
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+ "time"
+)
+
+func TestSupportedReleasesUsesOfficialDigest(t *testing.T) {
+ version := "v0.99.0"
+ extension := ".tar.gz"
+ if runtime.GOOS == "windows" {
+ extension = ".zip"
+ }
+ assetName := "frp_0.99.0_" + runtime.GOOS + "_" + runtime.GOARCH + extension
+ releases := supportedReleases([]githubRelease{{
+ TagName: version,
+ Assets: []githubAsset{{ID: 123, Name: assetName, Size: 42, Digest: "sha256:abc123"}},
+ PublishedAt: time.Date(2026, 9, 15, 0, 0, 0, 0, time.UTC),
+ }})
+ if len(releases) != 1 || releases[0].Checksum != "abc123" || releases[0].AssetID != 123 {
+ t.Fatalf("未读取官方资产摘要:%+v", releases)
+ }
+}
+
+func TestExtractZipBinary(t *testing.T) {
+ var buffer bytes.Buffer
+ writer := zip.NewWriter(&buffer)
+ file, err := writer.Create("frp/" + clientBinaryName())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := file.Write([]byte("client")); err != nil {
+ t.Fatal(err)
+ }
+ if err := writer.Close(); err != nil {
+ t.Fatal(err)
+ }
+ data, err := extractZipBinary(buffer.Bytes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "client" {
+ t.Fatalf("提取内容错误:%q", data)
+ }
+}
+
+func TestValidDownloadProxy(t *testing.T) {
+ for _, proxy := range []string{"", "https://gh-proxy.org/", "https://v4.gh-proxy.org/", "https://cdn.gh-proxy.org/"} {
+ if !validDownloadProxy(proxy) {
+ t.Fatalf("合法下载代理被拒绝:%s", proxy)
+ }
+ }
+ if validDownloadProxy("https://untrusted.example/") {
+ t.Fatal("非白名单下载代理不应被接受")
+ }
+}
+
+func TestReleaseListURLsPrioritizesSelectedProxy(t *testing.T) {
+ urls := releaseListURLs("https://v4.gh-proxy.org/")
+ if urls[0] != "https://v4.gh-proxy.org/"+githubReleasesURL {
+ t.Fatalf("未优先使用已选代理:%v", urls)
+ }
+ if urls[1] != githubReleasesURL {
+ t.Fatalf("直连地址缺失:%v", urls)
+ }
+}
+
+func TestRemoveBinaryVersion(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "bin", "v0.71.0", clientBinaryName())
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("client"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := removeBinaryVersion(root, "v0.71.0", path); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
+ t.Fatalf("版本目录未删除:%v", err)
+ }
+}
diff --git a/internal/frp/service.go b/internal/frp/service.go
new file mode 100644
index 0000000..cb2558a
--- /dev/null
+++ b/internal/frp/service.go
@@ -0,0 +1,1770 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/secret"
+)
+
+// maskedToken 是敏感凭据的统一回显掩码:API 永不返回明文;保存时收到掩码一律视为保留旧值
+// (对齐官方网关模式的「脱敏令牌闭环」,防止掩码被当新密钥覆盖真实凭据)。
+const maskedToken = "********"
+
+type Service struct {
+ cfg config.Config
+ store *Store
+ secretBox *secret.Box
+ manager *Manager
+ logger *slog.Logger
+ certStore *certificate.Store // 用于 HTTPS 规则部署时把 Fonu 本地证书推送到公网 agent
+
+ managersMu sync.RWMutex
+ managers map[int64]*Manager
+
+ // 穿透流量速率采样缓存:key 为规则 ID,用于在服务端计算两次采集之间的速率
+ trafficMu sync.Mutex
+ trafficClient *frpsTrafficClient
+ trafficLast map[int64]trafficSample
+
+ downloadMu sync.RWMutex
+ downloadTask DownloadTask
+ locationMu sync.Mutex
+ locations map[string]locationCache
+
+ // 诊断短 TTL 缓存与并发上限,避免列表页轮询叠加外部探测
+ diagMu sync.Mutex
+ diagCache map[int64]diagnosticsCacheEntry
+ diagInFlight int
+
+ // applyMu 保证 FRP 配置文件写入与批量启动/重载的原子边界(23.5):
+ // SQLite 事务只保护关系数据,不保护 frpc-server-*.toml 与 frpc 进程。
+ applyMu sync.Mutex
+}
+
+func NewService(cfg config.Config, store *Store, secretBox *secret.Box, logger *slog.Logger, certStore *certificate.Store) *Service {
+ bin := cfg.FrpcBin
+ if active, err := readActiveBinary(cfg.FrpDir()); err == nil && active != "" {
+ bin = active
+ }
+ service := &Service{
+ cfg: cfg,
+ store: store,
+ secretBox: secretBox,
+ manager: NewManager(bin, cfg.FrpConfigPath(), cfg.FrpLogPath(), logger),
+ managers: map[int64]*Manager{},
+ logger: logger.With("module", "FRP"),
+ certStore: certStore,
+ downloadTask: DownloadTask{
+ Status: "idle",
+ },
+ locations: map[string]locationCache{},
+ trafficClient: newFRPSTrafficClient(),
+ trafficLast: map[int64]trafficSample{},
+ diagCache: map[int64]diagnosticsCacheEntry{},
+ }
+ return service
+}
+
+func (s *Service) Runtime(ctx context.Context) RuntimeInfo {
+ s.manager.RefreshVersion(ctx)
+ bin, _ := s.manager.BinaryPath()
+ info := RuntimeInfo{
+ Platform: runtimePlatform(), ConfiguredBin: s.cfg.FrpcBin, ActiveBin: bin,
+ ConfigPath: s.cfg.FrpConfigPath(),
+ }
+ servers, _ := s.store.ListServers(ctx)
+ for _, server := range servers {
+ if !server.Enabled {
+ continue
+ }
+ info.ConfigPaths = append(info.ConfigPaths, s.serverConfigPath(server.ID))
+ if fileExists(s.serverConfigPath(server.ID)) {
+ info.ConfigExists = true
+ }
+ }
+ if len(info.ConfigPaths) == 0 {
+ info.ConfigExists = fileExists(info.ConfigPath)
+ }
+ info.DetectedVersion = s.manager.Status().Version
+ info.ActiveVersion = versionFromBinaryPath(s.cfg.FrpDir(), bin)
+ info.DownloadProxy, _ = readDownloadProxy(s.cfg.FrpDir())
+ return info
+}
+
+func (s *Service) ListReleases(ctx context.Context) ([]Release, error) {
+ proxy, err := readDownloadProxy(s.cfg.FrpDir())
+ if err != nil {
+ return nil, err
+ }
+ return fetchOfficialReleases(ctx, proxy)
+}
+
+func (s *Service) ListBinaries() ([]Binary, error) {
+ active, _ := s.manager.BinaryPath()
+ return listBinaries(s.cfg.FrpDir(), active)
+}
+
+func (s *Service) ServerDetail(ctx context.Context, id int64) (ServerDetail, error) {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return ServerDetail{}, err
+ }
+ s.enrichTLSStatus(&server)
+ s.enrichServerRuntime(ctx, &server)
+ status, err := s.store.ServerDiagnosticDetail(ctx, id)
+ if err != nil {
+ return ServerDetail{}, err
+ }
+ proxies, err := s.store.ListProxiesByServer(ctx, id)
+ if err != nil {
+ return ServerDetail{}, err
+ }
+ return ServerDetail{Server: server, Status: status, Proxies: proxies}, nil
+}
+
+func diagnoseDialError(err error) string {
+ message := err.Error()
+ if strings.Contains(strings.ToLower(message), "access permissions") {
+ return "本机网络策略阻止连接:" + message
+ }
+ return "连接失败:" + message
+}
+
+func (s *Service) DeleteBinary(version string) error {
+ path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
+ if err != nil {
+ return err
+ }
+ active, _ := s.manager.BinaryPath()
+ if filepath.Clean(path) == filepath.Clean(active) {
+ return fmt.Errorf("不能删除当前正在使用的 FRP 客户端")
+ }
+ return removeBinaryVersion(s.cfg.FrpDir(), version, path)
+}
+
+func (s *Service) StartDownload(version string) (DownloadTask, error) {
+ if !validVersion(version) {
+ return DownloadTask{}, fmt.Errorf("FRP 版本格式无效")
+ }
+ s.downloadMu.Lock()
+ defer s.downloadMu.Unlock()
+ if downloadInProgress(s.downloadTask.Status) {
+ return DownloadTask{}, fmt.Errorf("FRP %s 正在下载,请等待当前任务结束", s.downloadTask.Version)
+ }
+ s.downloadTask = DownloadTask{Version: version, Status: "queued", Message: "正在准备下载", UpdatedAt: time.Now().UTC().Format(time.RFC3339)}
+ go s.runDownload(version)
+ return s.downloadTask, nil
+}
+
+func (s *Service) DownloadStatus() DownloadTask {
+ s.downloadMu.RLock()
+ defer s.downloadMu.RUnlock()
+ return s.downloadTask
+}
+
+func (s *Service) runDownload(version string) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+ binary, err := s.downloadRelease(ctx, version, s.updateDownload)
+ if err != nil {
+ s.updateDownload("failed", "下载失败", 0, 0, err.Error())
+ return
+ }
+ s.updateDownload("completed", "下载、校验和解压完成", binary.Size, binary.Size, "")
+}
+
+func (s *Service) downloadRelease(ctx context.Context, version string, report downloadReporter) (Binary, error) {
+ report("queued", "正在获取官方版本信息", 0, 0, "")
+ releases, err := s.ListReleases(ctx)
+ if err != nil {
+ return Binary{}, err
+ }
+ release, ok := findRelease(releases, version)
+ if !ok {
+ return Binary{}, fmt.Errorf("未找到适用于当前系统的官方 FRP 版本")
+ }
+ proxy, err := readDownloadProxy(s.cfg.FrpDir())
+ if err != nil {
+ return Binary{}, err
+ }
+ path, err := downloadOfficialBinary(ctx, s.cfg.FrpDir(), release, proxy, report)
+ if err != nil {
+ return Binary{}, err
+ }
+ info, err := os.Stat(path)
+ if err != nil {
+ return Binary{}, err
+ }
+ return Binary{Version: release.Version, Path: path, Size: info.Size(), InstalledAt: info.ModTime().UTC().Format(time.RFC3339)}, nil
+}
+
+func (s *Service) updateDownload(status, message string, downloaded, total int64, failure string) {
+ s.downloadMu.Lock()
+ defer s.downloadMu.Unlock()
+ s.downloadTask.Status = status
+ s.downloadTask.Message = message
+ if downloaded > 0 {
+ s.downloadTask.Downloaded = downloaded
+ }
+ if total > 0 {
+ s.downloadTask.Total = total
+ }
+ s.downloadTask.Error = failure
+ s.downloadTask.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
+}
+
+func downloadInProgress(status string) bool {
+ return status == "queued" || status == "downloading" || status == "verifying" || status == "extracting"
+}
+
+func (s *Service) SetDownloadProxy(ctx context.Context, proxy string) (RuntimeInfo, error) {
+ if err := writeDownloadProxy(s.cfg.FrpDir(), proxy); err != nil {
+ return RuntimeInfo{}, err
+ }
+ return s.Runtime(ctx), nil
+}
+
+func (s *Service) ActivateBinary(ctx context.Context, version string) (RuntimeStatus, error) {
+ path, err := binaryPathForVersion(s.cfg.FrpDir(), version)
+ if err != nil {
+ return RuntimeStatus{}, err
+ }
+ old, _ := s.manager.BinaryPath()
+ s.setManagerBinary(path)
+ if err := s.verifyActiveBinary(ctx); err != nil {
+ s.setManagerBinary(old)
+ return RuntimeStatus{}, err
+ }
+ wasRunning := map[int64]bool{}
+ for id, manager := range s.managerSnapshot() {
+ wasRunning[id] = manager.Status().Status == "running"
+ if wasRunning[id] {
+ if err := manager.Stop(ctx); err != nil {
+ s.setManagerBinary(old)
+ return RuntimeStatus{}, err
+ }
+ }
+ }
+ if err := writeActiveBinary(s.cfg.FrpDir(), path); err != nil {
+ s.setManagerBinary(old)
+ return RuntimeStatus{}, err
+ }
+ for id, running := range wasRunning {
+ if running {
+ if err := s.managerForServer(id).Start(); err != nil {
+ return RuntimeStatus{}, err
+ }
+ }
+ }
+ s.manager.RefreshVersion(ctx)
+ return s.Status(ctx), nil
+}
+
+func (s *Service) verifyActiveBinary(ctx context.Context) error {
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return err
+ }
+ verified := false
+ for _, server := range servers {
+ if !server.Enabled {
+ continue
+ }
+ verified = true
+ manager := s.managerForServer(server.ID)
+ if !fileExists(s.serverConfigPath(server.ID)) {
+ if err := manager.VerifyBinary(ctx); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+ return err
+ }
+ }
+ if !verified {
+ return s.manager.VerifyBinary(ctx)
+ }
+ return nil
+}
+
+func (s *Service) EnsureDirs() error {
+ return os.MkdirAll(s.cfg.FrpDir(), 0o700)
+}
+
+func (s *Service) ListServers(ctx context.Context) ([]Server, error) {
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return nil, err
+ }
+ for index := range servers {
+ s.enrichTLSStatus(&servers[index])
+ s.enrichServerRuntime(ctx, &servers[index])
+ }
+ return servers, nil
+}
+
+func (s *Service) enrichServerRuntime(ctx context.Context, server *Server) {
+ runtime, err := s.store.ServerRuntimeStatus(ctx, server.ID)
+ if err != nil {
+ runtime = ServerRuntimeStatus{Status: "error", DesiredState: "error", ProcessState: "error", ConnectionState: "unknown", LastError: "读取启动状态失败"}
+ } else {
+ runtime = s.observeServerRuntime(ctx, server.ID, runtime)
+ }
+ if runtime.DesiredState == "" {
+ runtime.DesiredState = runtime.Status
+ }
+ server.BootStatus = runtime.DesiredState
+ server.DesiredState = runtime.DesiredState
+ server.ProcessState = runtime.ProcessState
+ server.ConnectionState = runtime.ConnectionState
+ server.StateSource = runtime.StateSource
+ server.ProcessPID = runtime.ProcessPID
+ server.ExitCode = runtime.ExitCode
+ server.LastConnectedAt = runtime.LastConnectedAt
+ server.LastDisconnectedAt = runtime.LastDisconnectedAt
+ server.LastError = runtime.LastError
+ server.LastStartedAt = runtime.LastStartedAt
+}
+
+func (s *Service) setServerRuntime(ctx context.Context, serverID int64, status, lastError string) {
+ runtime := ServerRuntimeStatus{ServerID: serverID, Status: status, DesiredState: status, LastError: lastError}
+ if status == "starting" || status == "running" {
+ runtime.LastStartedAt = time.Now().UTC().Format(time.RFC3339)
+ }
+ if err := s.store.SetServerDesiredState(ctx, runtime); err != nil {
+ s.logger.Warn("保存服务端启动状态失败", "server_id", serverID, "error", err.Error())
+ }
+}
+
+func (s *Service) observeServerRuntime(ctx context.Context, serverID int64, runtime ServerRuntimeStatus) ServerRuntimeStatus {
+ manager := s.managerForServer(serverID)
+ process := manager.Status()
+ processState := process.Status
+ if processState == "stopped" && process.LastError != "" {
+ processState = "error"
+ }
+ connectionState, connectionError := inspectConnectionLog(s.serverLogPath(serverID), processState)
+ if connectionError != "" && process.LastError == "" {
+ process.LastError = connectionError
+ }
+ if connectionState != runtime.ConnectionState {
+ now := time.Now().UTC().Format(time.RFC3339)
+ if connectionState == "connected" {
+ runtime.LastConnectedAt = now
+ }
+ if connectionState == "disconnected" || connectionState == "error" {
+ runtime.LastDisconnectedAt = now
+ }
+ }
+ runtime.ProcessState = processState
+ runtime.ConnectionState = connectionState
+ runtime.StateSource = "manager+log"
+ runtime.ProcessPID = process.PID
+ runtime.LastError = process.LastError
+ if connectionError != "" {
+ runtime.LastError = connectionError
+ }
+ if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+ s.logger.Warn("保存 FRP 实际状态失败", "server_id", serverID, "error", err.Error())
+ }
+ return runtime
+}
+
+func (s *Service) StartServer(ctx context.Context, id int64) (Server, error) {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return Server{}, err
+ }
+ s.setServerRuntime(ctx, id, "running", "")
+ s.enrichTLSStatus(&server)
+ s.enrichServerRuntime(ctx, &server)
+ return server, nil
+}
+
+func (s *Service) StopServer(ctx context.Context, id int64) (Server, error) {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return Server{}, err
+ }
+ s.setServerRuntime(ctx, id, "stopped", "")
+ s.enrichTLSStatus(&server)
+ s.enrichServerRuntime(ctx, &server)
+ return server, nil
+}
+
+func (s *Service) ListProxies(ctx context.Context) ([]Proxy, error) {
+ return s.store.ListProxies(ctx)
+}
+
+// ListProxiesTraffic 采集各 frps 管理接口的代理流量并按规则 ID 聚合。
+// dashboard 地址/端口存于服务端 options,密码复用 Token 列;未配置或不可达的服务端按规则记入 Errors。
+func (s *Service) ListProxiesTraffic(ctx context.Context) (ProxiesTraffic, error) {
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return ProxiesTraffic{}, err
+ }
+ proxies, err := s.store.ListProxies(ctx)
+ if err != nil {
+ return ProxiesTraffic{}, err
+ }
+ result := ProxiesTraffic{Items: map[int64]ProxyTraffic{}, Errors: map[int64]string{}, SampledAt: time.Now().UTC().Format(time.RFC3339)}
+ for _, proxy := range proxies {
+ if !proxy.Enabled {
+ continue
+ }
+ result.Items[proxy.ID] = ProxyTraffic{}
+ }
+ for _, server := range servers {
+ if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
+ continue
+ }
+ var password string
+ if server.DashboardHasPwd {
+ encoded, err := s.store.DashboardPassword(ctx, server.ID)
+ if err != nil {
+ continue
+ }
+ value, err := s.secretBox.Decrypt(encoded)
+ if err != nil {
+ continue
+ }
+ password = value
+ }
+ traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
+ if !traffic.OK {
+ // 旧版 frps(= 2 && current.TrafficIn >= previous.in && current.TrafficOut >= previous.out {
+ current.TrafficInRate = int64(float64(current.TrafficIn-previous.in) / elapsed)
+ current.TrafficOutRate = int64(float64(current.TrafficOut-previous.out) / elapsed)
+ }
+ }
+ s.trafficLast[proxyID] = trafficSample{at: at, in: current.TrafficIn, out: current.TrafficOut}
+ return current
+}
+
+func (s *Service) CreateServer(ctx context.Context, in ServerInput) (Server, error) {
+ in = normalizeServerInput(in)
+ if err := validateServerInput(in); err != nil {
+ return Server{}, err
+ }
+ if err := s.validateCredentials(in, Server{}); err != nil {
+ return Server{}, err
+ }
+ token, err := s.encryptOptional(in.Token)
+ if err != nil {
+ return Server{}, err
+ }
+ agentToken, err := s.encryptOptional(in.AgentToken)
+ if err != nil {
+ return Server{}, err
+ }
+ sshSecret, err := s.encryptOptional(in.SSHSecret)
+ if err != nil {
+ return Server{}, err
+ }
+ server, err := s.store.CreateServer(ctx, in, token, agentToken, sshSecret)
+ if err != nil {
+ return Server{}, err
+ }
+ if err := s.saveOIDCSecret(ctx, server.ID, in); err != nil {
+ return Server{}, err
+ }
+ if err := s.saveDashboardPassword(ctx, server.ID, in); err != nil {
+ return Server{}, err
+ }
+ if err := s.saveTLSAssets(server.ID, in); err != nil {
+ return Server{}, err
+ }
+ server, err = s.store.GetServer(ctx, server.ID)
+ if err != nil {
+ return Server{}, err
+ }
+ return server, s.persistConfig(ctx)
+}
+
+func (s *Service) UpdateServer(ctx context.Context, id int64, in ServerInput) (Server, error) {
+ in = normalizeServerInput(in)
+ if err := validateServerInput(in); err != nil {
+ return Server{}, err
+ }
+ existing, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return Server{}, err
+ }
+ // 掩码往返保护:回传掩码视为保留旧 Token,前端原样回填掩码也不会覆盖真实凭据
+ updateToken := (in.Token != "" && in.Token != maskedToken) || in.ClearToken
+ token := ""
+ if in.Token != "" && in.Token != maskedToken {
+ token, err = s.secretBox.Encrypt(in.Token)
+ if err != nil {
+ return Server{}, err
+ }
+ }
+ if err := s.validateCredentials(in, existing); err != nil {
+ return Server{}, err
+ }
+ agentTokenUpdate := (in.AgentToken != "" && in.AgentToken != maskedToken) || in.ClearAgentToken
+ agentToken := ""
+ if in.AgentToken != "" && in.AgentToken != maskedToken {
+ agentToken, err = s.secretBox.Encrypt(in.AgentToken)
+ if err != nil {
+ return Server{}, err
+ }
+ }
+ sshSecretUpdate := (in.SSHSecret != "" && in.SSHSecret != maskedToken) || in.ClearSSHSecret
+ sshSecret := ""
+ if in.SSHSecret != "" && in.SSHSecret != maskedToken {
+ sshSecret, err = s.secretBox.Encrypt(in.SSHSecret)
+ if err != nil {
+ return Server{}, err
+ }
+ }
+ server, err := s.store.UpdateServer(ctx, id, in, token, updateToken, agentToken, agentTokenUpdate, sshSecret, sshSecretUpdate)
+ if err != nil {
+ return Server{}, err
+ }
+ if err := s.saveOIDCSecret(ctx, id, in); err != nil {
+ return Server{}, err
+ }
+ if err := s.saveDashboardPassword(ctx, id, in); err != nil {
+ return Server{}, err
+ }
+ if err := s.saveTLSAssets(id, in); err != nil {
+ return Server{}, err
+ }
+ server, err = s.store.GetServer(ctx, id)
+ if err != nil {
+ return Server{}, err
+ }
+ if !server.Enabled {
+ _ = s.managerForServer(id).Stop(ctx)
+ }
+ return server, s.persistConfig(ctx)
+}
+
+func (s *Service) DeleteServer(ctx context.Context, id int64) error {
+ if err := s.managerForServer(id).Stop(ctx); err != nil {
+ return err
+ }
+ if err := s.store.DeleteServer(ctx, id); err != nil {
+ return err
+ }
+ _ = os.Remove(s.serverConfigPath(id))
+ _ = os.Remove(s.serverLogPath(id))
+ s.removeManager(id)
+ return s.persistConfig(ctx)
+}
+
+func (s *Service) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
+ in = normalizeProxyInput(in)
+ if err := validateProxyInput(in); err != nil {
+ return Proxy{}, err
+ }
+ if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
+ return Proxy{}, err
+ }
+ proxy, err := s.store.CreateProxy(ctx, in)
+ if err != nil {
+ return Proxy{}, err
+ }
+ if err := s.persistConfig(ctx); err != nil {
+ return Proxy{}, err
+ }
+ // 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
+ return proxy, nil
+}
+
+func (s *Service) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
+ in = normalizeProxyInput(in)
+ if err := validateProxyInput(in); err != nil {
+ return Proxy{}, err
+ }
+ if _, err := s.store.GetServer(ctx, in.ServerID); err != nil {
+ return Proxy{}, err
+ }
+ proxy, err := s.store.UpdateProxy(ctx, id, in)
+ if err != nil {
+ return Proxy{}, err
+ }
+ if err := s.persistConfig(ctx); err != nil {
+ return Proxy{}, err
+ }
+ // 公网 Nginx 反代改为在 Agent 页面中手动部署,保存穿透规则不再阻塞于 VPS/Nginx。
+ return proxy, nil
+}
+
+func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSHInstaller, error) {
+ server, err := s.store.GetServer(ctx, serverID)
+ if err != nil {
+ return Server{}, SSHInstaller{}, err
+ }
+ if server.SSHHost == "" || server.SSHUser == "" {
+ return Server{}, SSHInstaller{}, fmt.Errorf("请先在服务端表单填写 SSH 地址与用户")
+ }
+ secret := ""
+ if server.SSHConfigured {
+ encoded, err := s.store.SSHSecret(ctx, serverID)
+ if err != nil || encoded == "" {
+ return Server{}, SSHInstaller{}, fmt.Errorf("SSH 凭据未保存")
+ }
+ secret, err = s.secretBox.Decrypt(encoded)
+ if err != nil {
+ return Server{}, SSHInstaller{}, err
+ }
+ }
+ installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser}
+ if server.SSHAuth == "password" {
+ installer.Password = secret
+ } else {
+ installer.PrivateKey = secret
+ }
+ return server, installer, nil
+}
+
+func (s *Service) AgentProbe(ctx context.Context, serverID int64) (map[string]any, error) {
+ _, installer, err := s.sshInstaller(ctx, serverID)
+ if err != nil {
+ return nil, err
+ }
+ return AgentInstaller{SSH: installer}.Probe(ctx)
+}
+
+func (s *Service) AgentInstall(ctx context.Context, serverID int64) (Server, error) {
+ server, installer, err := s.sshInstaller(ctx, serverID)
+ if err != nil {
+ return Server{}, err
+ }
+ token, err := AgentInstaller{SSH: installer}.Install(ctx)
+ if err != nil {
+ return Server{}, err
+ }
+ agentToken, err := s.secretBox.Encrypt(token)
+ if err != nil {
+ return Server{}, err
+ }
+ input := ServerInput{
+ Name: server.Name, ServerAddr: server.ServerAddr, ServerPort: server.ServerPort,
+ TLS: server.TLS, TLSServerName: server.TLSServerName, Enabled: server.Enabled,
+ Options: server.Options, AgentURL: fmt.Sprintf("http://%s:%d", server.SSHHost, 7700),
+ AgentToken: token, SSHHost: server.SSHHost, SSHPort: server.SSHPort, SSHUser: server.SSHUser,
+ SSHAuth: server.SSHAuth, MgmtEnabled: true,
+ }
+ updated, err := s.store.UpdateServer(ctx, serverID, input, "", false, agentToken, true, "", false)
+ if err != nil {
+ return Server{}, err
+ }
+ return updated, nil
+}
+
+func (s *Service) AgentUninstall(ctx context.Context, serverID int64, keepData bool) (string, error) {
+ _, installer, err := s.sshInstaller(ctx, serverID)
+ if err != nil {
+ return "", err
+ }
+ return AgentInstaller{SSH: installer}.Uninstall(ctx, keepData)
+}
+
+func (s *Service) AgentSSHDiagnose(ctx context.Context, serverID int64) (string, error) {
+ _, installer, err := s.sshInstaller(ctx, serverID)
+ if err != nil {
+ return "", err
+ }
+ return AgentInstaller{SSH: installer}.Diagnose(ctx)
+}
+
+func (s *Service) AgentTest(ctx context.Context, serverID int64) error {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ return client.Ping(ctx)
+}
+
+// AgentClient 导出指定服务端的 agent 客户端(供 handler 直取明细端点);不可用返回 false。
+func (s *Service) AgentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
+ return s.agentClient(ctx, serverID)
+}
+
+func (s *Service) AgentStatus(ctx context.Context, serverID int64) (map[string]any, error) {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ return client.Status(ctx)
+}
+
+func (s *Service) InstallFRPSOnAgent(ctx context.Context, serverID int64, version, proxy string) (map[string]any, error) {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return nil, fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ result, err := client.InstallFRPS(ctx, version, proxy)
+ if err != nil {
+ return nil, err
+ }
+ // 安装后把同源 frps.toml 一起下发,保证 systemd 启动即指向正确配置
+ if server, err := s.store.GetServer(ctx, serverID); err == nil {
+ if content, err := s.buildFRPSServerConfig(ctx, server); err == nil {
+ _ = client.PushFRPSConfig(ctx, content)
+ }
+ }
+ return result, nil
+}
+
+func (s *Service) AgentFRPSAction(ctx context.Context, serverID int64, action string) error {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ return client.FRPSAction(ctx, action)
+}
+
+func (s *Service) PushFRPSConfigToAgent(ctx context.Context, serverID int64) error {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ server, err := s.store.GetServer(ctx, serverID)
+ if err != nil {
+ return err
+ }
+ // 下发的是 frps 服务端配置(vhost 端口/auth/webServer),不是 frpc 客户端配置
+ content, err := s.buildFRPSServerConfig(ctx, server)
+ if err != nil {
+ return err
+ }
+ return client.PushFRPSConfig(ctx, content)
+}
+
+// agentUpstreamFor 返回该规则反代时的回源地址(frps vhost 端口),端口来自服务端表单自定义,
+// 未配置时用默认 8080/8443,与 frps.toml 生成器同源。
+func (s *Service) agentUpstreamFor(ctx context.Context, proxy Proxy) (string, error) {
+ server, err := s.store.GetServer(ctx, proxy.ServerID)
+ if err != nil {
+ return "", err
+ }
+ httpPort := server.Options.VhostHTTPPort
+ if httpPort <= 0 {
+ httpPort = 8080
+ }
+ httpsPort := server.Options.VhostHTTPSPort
+ if httpsPort <= 0 {
+ httpsPort = 8443
+ }
+ if proxy.Type == "https" {
+ return fmt.Sprintf("127.0.0.1:%d", httpsPort), nil
+ }
+ return fmt.Sprintf("127.0.0.1:%d", httpPort), nil
+}
+
+// InstallAgentNginx 触发 VPS 上一键安装 Nginx(需 agent 以 root 运行)。
+func (s *Service) InstallAgentNginx(ctx context.Context, serverID int64) (map[string]any, error) {
+ client, ok := s.agentClient(ctx, serverID)
+ if !ok {
+ return nil, fmt.Errorf("该服务端未配置公网 agent(缺少地址或令牌)")
+ }
+ return client.InstallNginx(ctx)
+}
+
+// RouteHealthCandidate 公网反代规则的四段健康状态(DNS/证书/隧道/内网服务)。
+type RouteHealthCandidate struct {
+ ProxyID int64 `json:"proxy_id"`
+ Domain string `json:"domain"`
+ DNSOK bool `json:"dns_ok"`
+ DNSDetail string `json:"dns_detail,omitempty"`
+ CertOK bool `json:"cert_ok"`
+ CertDetail string `json:"cert_detail,omitempty"`
+ TunnelOK bool `json:"tunnel_ok"`
+ TunnelName string `json:"tunnel_name,omitempty"`
+ TunnelDetail string `json:"tunnel_detail,omitempty"`
+ ServiceOK bool `json:"service_ok"`
+}
+
+// RouteHealth 批量返回公网反代规则的四段健康状态:
+// DNS 解析是否指向服务端地址、本地证书覆盖情况、frps 隧道注册状态、内网服务探活。
+func (s *Service) RouteHealth(ctx context.Context, serverID int64) ([]RouteHealthCandidate, error) {
+ candidates, err := s.ListAgentRouteCandidates(ctx, serverID)
+ if err != nil {
+ return nil, err
+ }
+ server, err := s.store.GetServer(ctx, serverID)
+ if err != nil {
+ return nil, err
+ }
+ // 隧道注册名集合:frps dashboard(webServer)可用时取其代理名列表;
+ // 调用细节(URL/认证/具体错误)记录到 dashboardDiag,失败时透出给前端。
+ tunnelNames := map[string]bool{}
+ // 由 dashboard 返回的真实代理名(例如 admin.ssssss),按域名映射用于展示。
+ tunnelNameByDomain := map[string]string{}
+ dashboardReady := false
+ dashboardDiag := ""
+ if server.Options.DashboardAddr == "" || server.Options.DashboardPort == 0 {
+ dashboardDiag = "frps 管理接口未配置(服务端表单缺少地址或端口)"
+ } else {
+ encoded, perr := s.store.DashboardPassword(ctx, serverID)
+ if perr != nil {
+ dashboardDiag = fmt.Sprintf("读取 dashboard 密码失败: %v", perr)
+ } else if encoded == "" {
+ dashboardDiag = "dashboard 密码未保存(服务端表单留空),frps 已开启认证时无法访问管理接口"
+ } else {
+ password, derr := s.secretBox.Decrypt(encoded)
+ if derr != nil {
+ dashboardDiag = fmt.Sprintf("dashboard 密码解密失败: %v", derr)
+ } else {
+ traffic := s.trafficClient.fetchServerTraffic(ctx, server, password)
+ if traffic.OK {
+ dashboardReady = true
+ for name := range traffic.Proxies {
+ tunnelNames[name] = true
+ }
+ for domain, name := range traffic.ProxyNamesByDomain {
+ tunnelNameByDomain[domain] = name
+ }
+ } else {
+ // frp 0.68.0 没有全量代理列表,只支持 /api/proxies/{实际代理名};
+ // 用本地候选规则逐条查询,兼容 user.proxyName(例如 admin.ssssss)。
+ names := make([]string, 0, len(candidates))
+ for _, candidate := range candidates {
+ names = append(names, candidate.Name)
+ }
+ legacyTraffic := s.trafficClient.fetchLegacyRouteProxies(ctx, server, password, names)
+ if legacyTraffic.OK {
+ dashboardReady = true
+ for name := range legacyTraffic.Proxies {
+ if !strings.HasPrefix(name, "domain:") {
+ tunnelNames[name] = true
+ }
+ }
+ for domain, name := range legacyTraffic.ProxyNamesByDomain {
+ tunnelNameByDomain[domain] = name
+ }
+ } else {
+ dashboardDiag = fmt.Sprintf("frps 管理接口调用失败: %s(旧版逐规则查询失败: %s;Basic Auth 用户 %q)", traffic.Error, legacyTraffic.Error, server.Options.DashboardUser)
+ }
+ }
+ }
+ }
+ }
+ // 本地证书覆盖:域名精确或通配符匹配
+ certRecords, cerr := s.certStore.List(ctx)
+ if cerr != nil {
+ certRecords = nil
+ }
+ certFor := func(domain string) (*certificate.Record, bool) {
+ for i := range certRecords {
+ rec := &certRecords[i]
+ if rec.Domain == domain {
+ return rec, true
+ }
+ for _, d := range rec.Domains {
+ if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
+ return rec, true
+ }
+ }
+ }
+ return nil, false
+ }
+ resolver := &net.Resolver{}
+ out := make([]RouteHealthCandidate, 0, len(candidates))
+ for _, proxy := range candidates {
+ for _, domain := range proxy.CustomDomains {
+ item := RouteHealthCandidate{ProxyID: proxy.ID, Domain: domain}
+ // ① DNS:解析域名,与 server_addr(IP 或域名解析后)比对
+ ips, derr := resolver.LookupIPAddr(ctx, domain)
+ if derr != nil || len(ips) == 0 {
+ item.DNSDetail = "DNS 解析失败"
+ } else {
+ serverIPs, serr := resolver.LookupIPAddr(ctx, server.ServerAddr)
+ if serr != nil || len(serverIPs) == 0 {
+ item.DNSDetail = "服务端地址无法解析"
+ } else {
+ matched := false
+ for _, a := range ips {
+ for _, b := range serverIPs {
+ if a.IP.Equal(b.IP) {
+ matched = true
+ }
+ }
+ }
+ item.DNSOK = matched
+ if !matched {
+ item.DNSDetail = "未指向服务端地址"
+ }
+ }
+ }
+ // ② 证书:本地库覆盖 + 有效期
+ if rec, ok := certFor(domain); ok {
+ if rec.ExpiresAt != nil {
+ days := int(time.Until(*rec.ExpiresAt).Hours() / 24)
+ item.CertOK = days > 0
+ item.CertDetail = fmt.Sprintf("%d 天后到期", days)
+ } else {
+ item.CertOK = true
+ item.CertDetail = "有效期未知"
+ }
+ } else {
+ item.CertDetail = "本地无证书"
+ }
+ // ③ 隧道:frps dashboard 的代理名与规则名匹配。frpc 配置了 user 时,
+ // frp 会自动给代理名加 "." 前缀(如 admin.ssssss),两种形态都接受。
+ item.TunnelName = proxy.Name
+ if dashboardReady {
+ // 优先按域名索引匹配(V2 API 返回 spec.http.customDomains,代理名含 user 前缀不可靠),
+ // 再回退代理名(裸名或 user.前缀名)
+ switch {
+ case tunnelNames["domain:"+domain]:
+ item.TunnelOK = true
+ item.TunnelName = tunnelNameByDomain[strings.ToLower(domain)]
+ if item.TunnelName == "" {
+ item.TunnelName = proxy.Name
+ }
+ case tunnelNames[proxy.Name]:
+ item.TunnelOK = true
+ item.TunnelName = proxy.Name
+ case func() bool {
+ u := strings.TrimSpace(server.Options.User)
+ return u != "" && tunnelNames[u+"."+proxy.Name]
+ }():
+ item.TunnelOK = true
+ item.TunnelName = strings.TrimSpace(server.Options.User) + "." + proxy.Name
+ }
+ if item.TunnelOK {
+ item.TunnelDetail = "frps 已注册: " + item.TunnelName
+ } else {
+ // 诊断透出:dashboard 实际返回的代理名列表(供前端悬停排查)
+ names := make([]string, 0, len(tunnelNames))
+ for name := range tunnelNames {
+ if !strings.HasPrefix(name, "domain:") {
+ names = append(names, name)
+ }
+ }
+ sort.Strings(names)
+ item.TunnelDetail = fmt.Sprintf("frps 已注册代理: %s;但均未绑定域名 %s", strings.Join(names, ", "), domain)
+ }
+ } else {
+ item.TunnelDetail = dashboardDiag
+ }
+ // ④ 内网服务探活:Fonu 与服务同在内网,直接 TCP 拨测
+ conn, serr2 := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", proxy.LocalIP, proxy.LocalPort), 2*time.Second)
+ if serr2 == nil {
+ _ = conn.Close()
+ item.ServiceOK = true
+ }
+ out = append(out, item)
+ }
+ }
+ return out, nil
+}
+
+// GetProxy 导出单条穿透规则读取(供 handler 校验规则归属与域名)。
+func (s *Service) GetProxy(ctx context.Context, id int64) (Proxy, error) {
+ return s.store.GetProxy(ctx, id)
+}
+
+// ListAgentRouteCandidates 返回指定服务端可手动部署到公网 Nginx 的 HTTP/HTTPS 规则。
+func (s *Service) ListAgentRouteCandidates(ctx context.Context, serverID int64) ([]Proxy, error) {
+ proxies, err := s.store.ListProxiesByServer(ctx, serverID)
+ if err != nil {
+ return nil, err
+ }
+ candidates := make([]Proxy, 0, len(proxies))
+ for _, proxy := range proxies {
+ if proxy.Enabled && (proxy.Type == "http" || proxy.Type == "https") && len(proxy.CustomDomains) > 0 {
+ candidates = append(candidates, proxy)
+ }
+ }
+ return candidates, nil
+}
+
+// DeployAgentRoute 手动把单条穿透规则部署为公网 Nginx 反代。
+func (s *Service) DeployAgentRoute(ctx context.Context, proxyID int64) error {
+ proxy, err := s.store.GetProxy(ctx, proxyID)
+ if err != nil {
+ return err
+ }
+ if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
+ return fmt.Errorf("只有启用的 HTTP/HTTPS 规则可以部署公网反代")
+ }
+ client, ok := s.agentClient(ctx, proxy.ServerID)
+ if !ok {
+ return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ if proxy.Type == "https" {
+ // https 类型隧道是端到端透传:frps 不解密,要求内网服务自己持有证书并使用 TLS。
+ // 内网明文 HTTP 服务应改用 http 类型,公网 HTTPS 由 Nginx 挂证书终结(证书自动推送)。
+ return fmt.Errorf("https 类型隧道要求内网服务自己提供 TLS(192.168.11.30:8001 这类明文服务无法走通);" +
+ "请把规则类型改为 http(公网 HTTPS 由 Nginx 证书终结),或给内网服务配置证书")
+ }
+ upstream, uerr := s.agentUpstreamFor(ctx, proxy)
+ if uerr != nil {
+ return uerr
+ }
+ // vhost 端口供 agent 回源自检用
+ server, serr := s.store.GetServer(ctx, proxy.ServerID)
+ if serr != nil {
+ return serr
+ }
+ vhostPort := server.Options.VhostHTTPPort
+ if vhostPort <= 0 {
+ vhostPort = 8080
+ }
+ // 仅中国大陆 IP:先把本地已维护的中国 IP 段下发到 agent(geo 变量依赖它)
+ if proxy.Options.ChinaOnly {
+ data, err := os.ReadFile(s.cfg.ChinaCIDRPath())
+ if err != nil || strings.TrimSpace(string(data)) == "" {
+ return fmt.Errorf("启用「仅中国大陆 IP」前需先在「反向代理」页更新中国 IP 段(本地 china_cidr.conf 不存在或为空)")
+ }
+ if err := client.SyncChinaCIDR(ctx, string(data)); err != nil {
+ return fmt.Errorf("下发中国 IP 段失败: %w", err)
+ }
+ }
+ for _, domain := range proxy.CustomDomains {
+ // 证书用于公网 Nginx 的 TLS 终结(443 监听),与 frp 隧道类型解耦:
+ // 本地明文 HTTP 服务应选 http 类型规则,浏览器侧 HTTPS 由 Nginx 挂证书承担。
+ // https 类型隧道要求内网服务自己做 TLS,否则即使端口对上也无法工作。
+ tlsEdge := false
+ if certErr := s.pushAgentCertificate(ctx, client, domain); certErr == nil {
+ tlsEdge = true
+ } else if proxy.Type == "https" {
+ return fmt.Errorf("推送证书 %s 失败: %w", domain, certErr)
+ }
+ result, err := client.SyncRouteSpec(ctx, RouteSpec{
+ Domain: domain, Upstream: upstream, TLS: tlsEdge, CertDir: "",
+ WebSocket: proxy.Options.WebSocket, VhostPort: vhostPort,
+ RedirectHTTPS: proxy.Options.RedirectHTTPS,
+ AllowIPs: proxy.Options.AllowIPs, DenyIPs: proxy.Options.DenyIPs,
+ BasicAuthUser: proxy.Options.BasicAuthUser,
+ BasicAuthPassword: proxy.Options.BasicAuthPassword,
+ HTTPSDisabled: proxy.Options.HTTPSDisabled,
+ ClientMaxBodySize: proxy.Options.ClientMaxBodySize,
+ ProxyReadTimeout: proxy.Options.ProxyReadTimeout,
+ SecurityHeaders: proxy.Options.SecurityHeaders,
+ TLS13Only: proxy.Options.TLS13Only,
+ RateLimitRate: proxy.Options.RateLimitRate,
+ RateLimitBurst: proxy.Options.RateLimitBurst,
+ ConnLimitMax: proxy.Options.ConnLimitMax,
+ ChinaOnly: proxy.Options.ChinaOnly,
+ })
+ if err != nil {
+ return fmt.Errorf("部署域名 %s 失败: %w", domain, err)
+ }
+ // 回源自检分级提示,不掩盖灰区:
+ if code, ok := result["probe_status"].(float64); ok {
+ switch {
+ case code == 0:
+ return fmt.Errorf("反代已部署,但回源自检失败(连接不上 frps vhost :%d):检查 frps 是否运行、vhostHTTPPort 是否与表单一致", vhostPort)
+ case code == 404:
+ return fmt.Errorf("反代已部署,frps 正常但没有找到域名 %s 的隧道注册:请确认内网穿透里存在绑定该域名的 http 规则、类型为 http 且已启用,并已应用配置", domain)
+ case code >= 500:
+ return fmt.Errorf("反代已部署,隧道已连通但内网服务异常(HTTP %.0f):请检查内网服务 %s 是否存活", code, upstream)
+ }
+ }
+ }
+ return nil
+}
+
+// pushAgentCertificate 从 Fonu 本地证书存储读取域名对应证书并推送到公网 agent;
+// 支持精确域名与通配符(*.example.com)匹配。
+func (s *Service) pushAgentCertificate(ctx context.Context, client *AgentClient, domain string) error {
+ if s.certStore == nil {
+ return fmt.Errorf("证书存储不可用")
+ }
+ records, err := s.certStore.List(ctx)
+ if err != nil {
+ return err
+ }
+ for _, record := range records {
+ matched := record.Domain == domain
+ if !matched {
+ for _, d := range record.Domains {
+ if d == domain || (strings.HasPrefix(d, "*") && strings.HasSuffix(domain, strings.TrimPrefix(d, "*"))) {
+ matched = true
+ break
+ }
+ }
+ }
+ if !matched || record.CertPath == "" || record.KeyPath == "" {
+ continue
+ }
+ certPEM, err := os.ReadFile(record.CertPath)
+ if err != nil {
+ return fmt.Errorf("读取证书文件失败: %w", err)
+ }
+ keyPEM, err := os.ReadFile(record.KeyPath)
+ if err != nil {
+ return fmt.Errorf("读取私钥文件失败: %w", err)
+ }
+ return client.PushCertificate(ctx, domain, string(certPEM), string(keyPEM))
+ }
+ return fmt.Errorf("Fonu 本地没有域名 %s 的证书;请先在「证书」页申请,或改用 HTTP 类型规则", domain)
+}
+
+// RemoveAgentRoute 手动移除单条穿透规则对应的公网 Nginx 反代。
+func (s *Service) RemoveAgentRoute(ctx context.Context, proxyID int64) error {
+ proxy, err := s.store.GetProxy(ctx, proxyID)
+ if err != nil {
+ return err
+ }
+ client, ok := s.agentClient(ctx, proxy.ServerID)
+ if !ok {
+ return fmt.Errorf("公网 agent 未配置或 Token 未保存")
+ }
+ for _, domain := range proxy.CustomDomains {
+ if err := client.DeleteRoute(ctx, domain); err != nil {
+ return fmt.Errorf("移除域名 %s 失败: %w", domain, err)
+ }
+ }
+ return nil
+}
+
+func (s *Service) DeleteProxy(ctx context.Context, id int64) error {
+ if _, err := s.store.GetProxy(ctx, id); err != nil {
+ return err
+ }
+ if err := s.store.DeleteProxy(ctx, id); err != nil {
+ return err
+ }
+ if err := s.persistConfig(ctx); err != nil {
+ return err
+ }
+ // 公网 Nginx 反代由 Agent 页面手动管理,删除穿透规则不自动访问 VPS。
+ return nil
+}
+
+func (s *Service) agentClient(ctx context.Context, serverID int64) (*AgentClient, bool) {
+ server, err := s.store.GetServer(ctx, serverID)
+ if err != nil || !server.MgmtEnabled || server.AgentURL == "" || !server.AgentConfigured {
+ return nil, false
+ }
+ encoded, err := s.store.AgentToken(ctx, serverID)
+ if err != nil || encoded == "" {
+ return nil, false
+ }
+ token, err := s.secretBox.Decrypt(encoded)
+ if err != nil || token == "" {
+ return nil, false
+ }
+ return NewAgentClient(server.AgentURL, token), true
+}
+
+func (s *Service) syncAgentRoute(ctx context.Context, proxy Proxy) {
+ if !proxy.Enabled || (proxy.Type != "http" && proxy.Type != "https") || len(proxy.CustomDomains) == 0 {
+ return
+ }
+ client, ok := s.agentClient(ctx, proxy.ServerID)
+ if !ok {
+ return
+ }
+ upstream, uerr := s.agentUpstreamFor(ctx, proxy)
+ if uerr != nil {
+ s.logger.Warn("agent route sync skipped", "proxy_id", proxy.ID, "error", uerr)
+ return
+ }
+ for _, domain := range proxy.CustomDomains {
+ if err := client.SyncRoute(ctx, domain, upstream, proxy.Type == "https", ""); err != nil {
+ s.logger.Warn("agent route sync failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
+ }
+ }
+}
+
+func (s *Service) deleteAgentRoute(ctx context.Context, proxy Proxy) {
+ client, ok := s.agentClient(ctx, proxy.ServerID)
+ if !ok {
+ return
+ }
+ for _, domain := range proxy.CustomDomains {
+ if err := client.DeleteRoute(ctx, domain); err != nil {
+ s.logger.Warn("agent route delete failed", "proxy_id", proxy.ID, "domain", domain, "error", err)
+ }
+ }
+}
+
+func (s *Service) Start(ctx context.Context) error {
+ s.applyMu.Lock()
+ defer s.applyMu.Unlock()
+ if err := s.persistConfigsLocked(ctx); err != nil {
+ return err
+ }
+ s.manager.RefreshVersion(ctx)
+ servers, err := s.store.ListEnabledServers(ctx)
+ if err != nil {
+ return err
+ }
+ if len(servers) == 0 {
+ return fmt.Errorf("未启用 FRP 服务端")
+ }
+ // 23.5:按服务端隔离——某个服务端校验/启动失败不停止其他服务端,
+ // 已启动的保持运行,失败原因聚合成一条错误返回。
+ var failures []string
+ for _, server := range servers {
+ manager := s.managerForServer(server.ID)
+ if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+ s.recordProcessState(ctx, server.ID, manager, err.Error())
+ failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
+ continue
+ }
+ if err := manager.Start(); err != nil {
+ s.recordProcessState(ctx, server.ID, manager, err.Error())
+ failures = append(failures, fmt.Sprintf("服务端 %s 启动失败:%v", server.Name, err))
+ continue
+ }
+ s.recordProcessState(ctx, server.ID, manager, "")
+ }
+ if len(failures) > 0 {
+ return fmt.Errorf("%s", strings.Join(failures, ";"))
+ }
+ return nil
+}
+
+func (s *Service) AutoStart(ctx context.Context) error {
+ servers, err := s.store.ListEnabledServers(ctx)
+ if err != nil {
+ return err
+ }
+ shouldStart := false
+ for _, server := range servers {
+ if server.Options.AutoStart {
+ shouldStart = true
+ break
+ }
+ }
+ if !shouldStart {
+ return nil
+ }
+ return s.Start(ctx)
+}
+
+func (s *Service) Stop(ctx context.Context) error {
+ _ = s.ReconcileRuntime(ctx)
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return err
+ }
+ var messages []string
+ for _, server := range servers {
+ manager := s.managerForServer(server.ID)
+ if err := manager.Stop(ctx); err != nil {
+ messages = append(messages, fmt.Sprintf("服务端 %d:%v", server.ID, err))
+ continue
+ }
+ s.recordProcessState(ctx, server.ID, manager, "")
+ }
+ if len(messages) > 0 {
+ return fmt.Errorf("%s", strings.Join(messages, ";"))
+ }
+ return nil
+}
+
+func (s *Service) Reload(ctx context.Context) error {
+ s.applyMu.Lock()
+ defer s.applyMu.Unlock()
+ if err := s.persistConfigsLocked(ctx); err != nil {
+ return err
+ }
+ servers, err := s.store.ListEnabledServers(ctx)
+ if err != nil {
+ return err
+ }
+ // 23.5:按服务端隔离重载,失败不中断其他服务端,聚合报错。
+ var failures []string
+ for _, server := range servers {
+ manager := s.managerForServer(server.ID)
+ if err := manager.Verify(ctx, s.serverConfigPath(server.ID)); err != nil {
+ s.recordProcessState(ctx, server.ID, manager, err.Error())
+ failures = append(failures, fmt.Sprintf("服务端 %s 校验失败:%v", server.Name, err))
+ continue
+ }
+ if err := manager.Restart(ctx); err != nil {
+ s.recordProcessState(ctx, server.ID, manager, err.Error())
+ failures = append(failures, fmt.Sprintf("服务端 %s 重启失败:%v", server.Name, err))
+ continue
+ }
+ s.recordProcessState(ctx, server.ID, manager, "")
+ }
+ if len(failures) > 0 {
+ return fmt.Errorf("%s", strings.Join(failures, ";"))
+ }
+ return nil
+}
+
+func (s *Service) recordProcessState(ctx context.Context, serverID int64, manager *Manager, lastError string) {
+ status := manager.Status()
+ processState := status.Status
+ if lastError != "" {
+ processState = "error"
+ }
+ runtime := ServerRuntimeStatus{
+ ServerID: serverID, ProcessState: processState, ConnectionState: "unknown",
+ StateSource: "manager", ProcessPID: status.PID, LastStartedAt: status.LastStartedAt, LastError: lastError,
+ }
+ if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+ s.logger.Warn("保存 FRP 进程状态失败", "server_id", serverID, "error", err.Error())
+ }
+}
+
+func (s *Service) ReconcileRuntime(ctx context.Context) error {
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return err
+ }
+ for _, server := range servers {
+ runtime, runtimeErr := s.store.ServerRuntimeStatus(ctx, server.ID)
+ if runtimeErr != nil || runtime.ProcessPID <= 0 {
+ continue
+ }
+ manager := s.managerForServer(server.ID)
+ if manager.Attach(runtime.ProcessPID) {
+ runtime.ProcessState = "running"
+ runtime.StateSource = "reconciled"
+ runtime.LastError = ""
+ if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+ return err
+ }
+ continue
+ }
+ if processAlive(runtime.ProcessPID) {
+ runtime.ProcessState = "unknown"
+ runtime.StateSource = "reconcile"
+ runtime.LastError = "检测到运行中的进程,但无法确认其属于当前 FRP 配置"
+ } else {
+ runtime.ProcessState = "stopped"
+ runtime.ProcessPID = 0
+ runtime.StateSource = "reconcile"
+ runtime.LastError = ""
+ }
+ if err := s.store.SetServerObservedState(ctx, runtime); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Service) Test(ctx context.Context, id int64) error {
+ server, err := s.store.GetServer(ctx, id)
+ if err != nil {
+ return err
+ }
+ if err := s.persistServerConfigForVerify(ctx, server); err != nil {
+ return err
+ }
+ return s.managerForServer(id).Verify(ctx, s.serverConfigPath(id))
+}
+
+// persistServerConfigForVerify 为「测试」路径服务:渲染到临时文件并 verify,通过后才
+// 原子替换正式配置;不通过时删除临时文件、正式配置保持原样(23.5 的「临时配置加 verify,
+// 只有验证成功才替换正式配置」要求)。
+func (s *Service) persistServerConfigForVerify(ctx context.Context, server Server) error {
+ content, err := s.renderServerConfig(ctx, server)
+ if err != nil {
+ return err
+ }
+ officialPath := s.serverConfigPath(server.ID)
+ if err := os.WriteFile(officialPath+".verify", []byte(content), 0o600); err != nil {
+ return err
+ }
+ if err := s.managerForServer(server.ID).Verify(ctx, officialPath+".verify"); err != nil {
+ _ = os.Remove(officialPath + ".verify")
+ return err
+ }
+ if current, readErr := os.ReadFile(officialPath); readErr == nil {
+ _ = os.WriteFile(officialPath+".bak", current, 0o600)
+ }
+ return os.Rename(officialPath+".verify", officialPath)
+}
+
+func (s *Service) renderServerConfig(ctx context.Context, server Server) (string, error) {
+ proxies, err := s.store.ListEnabledProxies(ctx, server.ID)
+ if err != nil {
+ return "", err
+ }
+ token, oidcSecret, err := s.loadAuthSecrets(ctx, server)
+ if err != nil {
+ return "", err
+ }
+ return renderTOML(renderedConfig{
+ Server: server, Token: token, OIDCClientSecret: oidcSecret, LogPath: s.serverLogPath(server.ID),
+ TLSCertPath: s.existingTLSAssetPath(server.ID, "cert.pem"), TLSKeyPath: s.existingTLSAssetPath(server.ID, "key.pem"),
+ TLSTrustedCAPath: s.existingTLSAssetPath(server.ID, "ca.pem"), Proxies: proxies,
+ }), nil
+}
+
+func (s *Service) Status(ctx context.Context) RuntimeStatus {
+ versionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ s.manager.RefreshVersion(versionCtx)
+ servers, err := s.store.ListEnabledServers(ctx)
+ if err != nil {
+ return s.manager.Status()
+ }
+ statuses := make([]RuntimeStatus, 0, len(servers))
+ for _, server := range servers {
+ current := s.managerForServer(server.ID).Status()
+ current.Configured = s.serverConfigured(server)
+ if proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID); proxyErr == nil {
+ current.EnabledProxies = len(proxies)
+ }
+ statuses = append(statuses, current)
+ }
+ return latestRuntimeStatus(statuses)
+}
+
+func (s *Service) ReadLogs(limit int, serverID int64) ([]string, error) {
+ if limit <= 0 || limit > 500 {
+ limit = 200
+ }
+ servers, err := s.store.ListServers(context.Background())
+ if err != nil {
+ return nil, err
+ }
+ lines := []string{}
+ for _, server := range servers {
+ // 指定 server_id 时只读该服务端的日志文件;0 表示全部服务端
+ if serverID > 0 && server.ID != serverID {
+ continue
+ }
+ data, readErr := os.ReadFile(s.serverLogPath(server.ID))
+ if os.IsNotExist(readErr) {
+ continue
+ }
+ if readErr != nil {
+ return nil, readErr
+ }
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+ if strings.TrimSpace(line) != "" {
+ lines = append(lines, fmt.Sprintf("[%s] %s", server.Name, line))
+ }
+ }
+ }
+ if len(lines) > limit {
+ lines = lines[len(lines)-limit:]
+ }
+ return lines, nil
+}
+
+// ReadProxyLogs 返回单条穿透规则的日志:读所属服务端的 frpc 日志并按规则名过滤。
+func (s *Service) ReadProxyLogs(ctx context.Context, proxyID int64, limit int) ([]string, error) {
+ if limit <= 0 || limit > 500 {
+ limit = 200
+ }
+ proxy, err := s.store.GetProxy(ctx, proxyID)
+ if err != nil {
+ return nil, err
+ }
+ data, readErr := os.ReadFile(s.serverLogPath(proxy.ServerID))
+ if os.IsNotExist(readErr) {
+ return []string{}, nil
+ }
+ if readErr != nil {
+ return nil, readErr
+ }
+ lines := []string{}
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+ if strings.TrimSpace(line) != "" && strings.Contains(line, proxy.Name) {
+ lines = append(lines, line)
+ }
+ }
+ if len(lines) > limit {
+ lines = lines[len(lines)-limit:]
+ }
+ return lines, nil
+}
+
+func (s *Service) ExportMaskedConfig(ctx context.Context) (string, error) {
+ servers, err := s.store.ListEnabledServers(ctx)
+ if err != nil {
+ return "", err
+ }
+ if len(servers) == 0 {
+ return "", fmt.Errorf("未启用 FRP 服务端")
+ }
+ var out strings.Builder
+ for _, server := range servers {
+ proxies, proxyErr := s.store.ListEnabledProxies(ctx, server.ID)
+ if proxyErr != nil {
+ return "", proxyErr
+ }
+ fmt.Fprintf(&out, "# 服务端:%s (%s:%d)\n", server.Name, server.ServerAddr, server.ServerPort)
+ out.WriteString(renderMaskedTOML(server, proxies))
+ out.WriteString("\n")
+ }
+ return out.String(), nil
+}
+
+func (s *Service) validateCredentials(in ServerInput, existing Server) error {
+ if !in.Enabled {
+ return nil
+ }
+ switch in.Options.AuthMethod {
+ case "none":
+ return nil
+ case "token":
+ if in.ClearToken || ((in.Token == "" || in.Token == maskedToken) && !existing.HasToken) {
+ return fmt.Errorf("Token 认证需要填写 FRP Token")
+ }
+ case "oidc":
+ if in.ClearOIDCClientSecret || ((in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken) && !existing.HasOIDCSecret) {
+ return fmt.Errorf("OIDC 认证需要填写客户端密钥")
+ }
+ }
+ return nil
+}
+
+func (s *Service) encryptOptional(value string) (string, error) {
+ if value == "" {
+ return "", nil
+ }
+ return s.secretBox.Encrypt(value)
+}
+
+func (s *Service) saveOIDCSecret(ctx context.Context, serverID int64, in ServerInput) error {
+ if in.ClearOIDCClientSecret {
+ return s.store.SetOIDCSecret(ctx, serverID, "")
+ }
+ if in.OIDCClientSecret == "" || in.OIDCClientSecret == maskedToken {
+ return nil
+ }
+ value, err := s.secretBox.Encrypt(in.OIDCClientSecret)
+ if err != nil {
+ return err
+ }
+ return s.store.SetOIDCSecret(ctx, serverID, value)
+}
+
+// saveDashboardPassword 保存 frps 管理接口密码(与 frpc Token 无关,仅用于 Fonu 拉取流量信息)。
+func (s *Service) saveDashboardPassword(ctx context.Context, serverID int64, in ServerInput) error {
+ if in.ClearDashboardPassword {
+ return s.store.SetDashboardPassword(ctx, serverID, "")
+ }
+ if in.DashboardPassword == "" || in.DashboardPassword == maskedToken {
+ return nil
+ }
+ encoded, err := s.secretBox.Encrypt(in.DashboardPassword)
+ if err != nil {
+ return err
+ }
+ return s.store.SetDashboardPassword(ctx, serverID, encoded)
+}
+
+func (s *Service) loadAuthSecrets(ctx context.Context, server Server) (string, string, error) {
+ if server.Options.AuthMethod == "none" {
+ return "", "", nil
+ }
+ if server.Options.AuthMethod == "token" {
+ encoded, err := s.store.Token(ctx, server.ID)
+ if err != nil {
+ return "", "", err
+ }
+ value, err := s.secretBox.Decrypt(encoded)
+ if err != nil {
+ return "", "", fmt.Errorf("FRP Token 无法解密,请重新保存服务端配置")
+ }
+ return value, "", nil
+ }
+ encoded, err := s.store.OIDCSecret(ctx, server.ID)
+ if err != nil {
+ return "", "", err
+ }
+ value, err := s.secretBox.Decrypt(encoded)
+ if err != nil {
+ return "", "", fmt.Errorf("OIDC 客户端密钥无法解密,请重新保存服务端配置")
+ }
+ return "", value, nil
+}
+
+func (s *Service) serverConfigured(server Server) bool {
+ switch server.Options.AuthMethod {
+ case "none":
+ return true
+ case "oidc":
+ return server.HasOIDCSecret
+ default:
+ return server.HasToken
+ }
+}
+
+func (s *Service) enrichTLSStatus(server *Server) {
+ server.Options.TLSCertificateConfigured = s.existingTLSAssetPath(server.ID, "cert.pem") != ""
+ server.Options.TLSKeyConfigured = s.existingTLSAssetPath(server.ID, "key.pem") != ""
+ server.Options.TLSTrustedCAConfigured = s.existingTLSAssetPath(server.ID, "ca.pem") != ""
+}
+
+func (s *Service) tlsAssetPath(serverID int64, name string) string {
+ return filepath.Join(s.cfg.FrpDir(), "certs", fmt.Sprintf("server-%d-%s", serverID, name))
+}
+
+func (s *Service) existingTLSAssetPath(serverID int64, name string) string {
+ path := s.tlsAssetPath(serverID, name)
+ if _, err := os.Stat(path); err == nil {
+ return path
+ }
+ return ""
+}
+
+func (s *Service) saveTLSAssets(serverID int64, in ServerInput) error {
+ assets := []struct {
+ name, content string
+ clear bool
+ }{
+ {"cert.pem", in.TLSCertificate, in.ClearTLSCertificate}, {"key.pem", in.TLSKey, in.ClearTLSKey}, {"ca.pem", in.TLSTrustedCA, in.ClearTLSTrustedCA},
+ }
+ for _, asset := range assets {
+ path := s.tlsAssetPath(serverID, asset.name)
+ if asset.clear {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ continue
+ }
+ if asset.content == "" {
+ continue
+ }
+ if !strings.Contains(asset.content, "-----BEGIN") {
+ return fmt.Errorf("TLS %s 内容不是有效 PEM", asset.name)
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, []byte(strings.TrimSpace(asset.content)+"\n"), 0o600); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Service) persistConfig(ctx context.Context) error {
+ s.applyMu.Lock()
+ defer s.applyMu.Unlock()
+ return s.persistConfigsLocked(ctx)
+}
+
+// persistConfigsLocked 写全部服务端配置文件。调用方必须已持有 s.applyMu。
+// 单个服务端写入失败立即返回,其余服务端保持上一版配置,不会出现部分新部分旧混写。
+func (s *Service) persistConfigsLocked(ctx context.Context) error {
+ servers, err := s.store.ListServers(ctx)
+ if err != nil {
+ return err
+ }
+ for _, server := range servers {
+ path := s.serverConfigPath(server.ID)
+ if !server.Enabled {
+ if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
+ return removeErr
+ }
+ continue
+ }
+ if err := s.persistServerConfig(ctx, server); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Service) persistServerConfig(ctx context.Context, server Server) error {
+ content, err := s.renderServerConfig(ctx, server)
+ if err != nil {
+ return err
+ }
+ return writeConfigAtomically(s.serverConfigPath(server.ID), content)
+}
+
+func writeConfigAtomically(path, content string) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return err
+ }
+ // 23.5:写入前把当前版本留存为 .bak。临时文件写入本身已原子(CreateTemp+Rename),
+ // 但 frpc verify 失败时上层需要回退目标,这里提供版本化备份。
+ if current, err := os.ReadFile(path); err == nil {
+ if err := os.WriteFile(path+".bak", current, 0o600); err != nil {
+ return err
+ }
+ }
+ tmp, err := os.CreateTemp(dir, "frpc-*.toml")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath)
+ if _, err := tmp.WriteString(content); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Chmod(0o600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
diff --git a/internal/frp/service_diagnostics_test.go b/internal/frp/service_diagnostics_test.go
new file mode 100644
index 0000000..fe27d8d
--- /dev/null
+++ b/internal/frp/service_diagnostics_test.go
@@ -0,0 +1,64 @@
+package frp
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+)
+
+func TestDiagnoseDialError(t *testing.T) {
+ err := errors.New("connectex: An attempt was made to access a socket in a way forbidden by its access permissions.")
+ message := diagnoseDialError(err)
+ if !strings.HasPrefix(message, "本机网络策略阻止连接:") {
+ t.Fatalf("unexpected message: %s", message)
+ }
+}
+
+// 23.3:诊断拆分为独立检查项,登录状态从 frpc 日志推断,unknown 不等于失败。
+func TestSummarizeFRPCLogin(t *testing.T) {
+ logLines := strings.Join([]string{
+ "2026/09/17 10:00:00 [I] [root.go:139] frpc service start",
+ "2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token error",
+ "2026/09/17 10:01:00 [I] [control.go:164] login to server success, get run id [abc]",
+ }, "\n")
+ state, detail := summarizeFRPCLogin(logLines)
+ if state != diagStateOK {
+ t.Fatalf("login success should win over earlier failure, got %s (%s)", state, detail)
+ }
+ failedLog := "2026/09/17 10:00:01 [W] [control.go:167] login to the server failed: token [wrong] error: authentication failed"
+ state, detail = summarizeFRPCLogin(failedLog)
+ if state != diagStateFailed || !strings.Contains(detail, "登录失败") {
+ t.Fatalf("login failure should be reported, got %s (%s)", state, detail)
+ }
+ state, _ = summarizeFRPCLogin("2026/09/17 10:00:00 [I] [root.go:139] frpc service start")
+ if state != diagStateUnknown {
+ t.Fatalf("no login evidence should be unknown, got %s", state)
+ }
+}
+
+func TestDiagnoseDNSFormatsRecords(t *testing.T) {
+ check := diagnoseDNS(context.Background(), "192.168.1.10")
+ if check.State != diagStateOK || !strings.Contains(check.Detail, "IP 地址") {
+ t.Fatalf("literal IP should skip resolution: %+v", check)
+ }
+}
+
+func TestSummarizeServerSamplesUsesLatestStatus(t *testing.T) {
+ detail := summarizeServerSamples([]ServerSample{
+ {CheckedAt: "2026-09-15 18:00:00", Available: true, LatencyMS: 20},
+ {CheckedAt: "2026-09-15 18:01:00", Available: false, Error: "连接失败"},
+ })
+ if detail.Available {
+ t.Fatal("latest failed sample should make current status unavailable")
+ }
+ if detail.Availability != 50 {
+ t.Fatalf("unexpected availability: %v", detail.Availability)
+ }
+ if detail.AverageLatency != 20 || detail.PeakLatency != 20 {
+ t.Fatalf("unexpected latency summary: average=%d peak=%d", detail.AverageLatency, detail.PeakLatency)
+ }
+ if detail.LastError != "连接失败" {
+ t.Fatalf("unexpected last error: %s", detail.LastError)
+ }
+}
diff --git a/internal/frp/ssh_installer.go b/internal/frp/ssh_installer.go
new file mode 100644
index 0000000..574fac8
--- /dev/null
+++ b/internal/frp/ssh_installer.go
@@ -0,0 +1,126 @@
+package frp
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/ssh"
+)
+
+// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
+// agent 的日常管理仍走 AgentClient;SSH 只承担生命周期和失联自救。
+type SSHInstaller struct {
+ Host string
+ Port int
+ User string
+ Password string
+ PrivateKey string
+ Timeout time.Duration
+}
+
+func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
+ if i.Port == 0 {
+ i.Port = 22
+ }
+ var auth ssh.AuthMethod
+ if i.PrivateKey != "" {
+ signer, err := ssh.ParsePrivateKey([]byte(i.PrivateKey))
+ if err != nil {
+ return nil, fmt.Errorf("解析 SSH 私钥失败: %w", err)
+ }
+ auth = ssh.PublicKeys(signer)
+ } else if i.Password != "" {
+ auth = ssh.Password(i.Password)
+ } else {
+ return nil, fmt.Errorf("SSH 密码或私钥不能为空")
+ }
+ config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: i.Timeout}
+ if config.Timeout == 0 {
+ config.Timeout = 15 * time.Second
+ }
+ return ssh.Dial("tcp", fmt.Sprintf("%s:%d", i.Host, i.Port), config)
+}
+
+func (i SSHInstaller) Run(ctx context.Context, command string) (string, error) {
+ client, err := i.client(ctx)
+ if err != nil {
+ return "", err
+ }
+ defer client.Close()
+ session, err := client.NewSession()
+ if err != nil {
+ return "", err
+ }
+ defer session.Close()
+ var output strings.Builder
+ session.Stdout = &output
+ session.Stderr = &output
+ if err := session.Run(command); err != nil {
+ return output.String(), fmt.Errorf("远程命令失败: %w: %s", err, strings.TrimSpace(output.String()))
+ }
+ return output.String(), nil
+}
+
+// Install 只接受本地已构建的 agent 二进制与安装脚本,不接受用户任意命令。
+func (i SSHInstaller) Install(ctx context.Context, binary []byte, script []byte) (string, error) {
+ client, err := i.client(ctx)
+ if err != nil {
+ return "", err
+ }
+ defer client.Close()
+ if err := uploadBytes(client, "/tmp/fonu-agent", binary, 0o755); err != nil {
+ return "", err
+ }
+ if err := uploadBytes(client, "/tmp/fonu-agent-install.sh", script, 0o700); err != nil {
+ return "", err
+ }
+ return runSSHSession(client, "sudo /tmp/fonu-agent-install.sh /tmp/fonu-agent && test -x /usr/local/bin/fonu-agent && /usr/local/bin/fonu-agent -version && rm -f /tmp/fonu-agent /tmp/fonu-agent-install.sh")
+}
+
+func uploadBytes(client *ssh.Client, path string, data []byte, mode uint32) error {
+ session, err := client.NewSession()
+ if err != nil {
+ return err
+ }
+ defer session.Close()
+ stdin, err := session.StdinPipe()
+ if err != nil {
+ return err
+ }
+ command := fmt.Sprintf("umask 077; cat > %s; chmod %o %s", shellPath(path), mode, shellPath(path))
+ if err := session.Start(command); err != nil {
+ return err
+ }
+ if _, err := stdin.Write(data); err != nil {
+ return err
+ }
+ _ = stdin.Close()
+ return session.Wait()
+}
+
+func runSSHSession(client *ssh.Client, command string) (string, error) {
+ session, err := client.NewSession()
+ if err != nil {
+ return "", err
+ }
+ defer session.Close()
+ var output strings.Builder
+ session.Stdout = &output
+ session.Stderr = &output
+ if err := session.Run(command); err != nil {
+ return output.String(), fmt.Errorf("远程 agent 安装失败: %w: %s", err, strings.TrimSpace(output.String()))
+ }
+ return output.String(), nil
+}
+
+func shellPath(path string) string {
+ return "'" + strings.ReplaceAll(path, "'", "'\\''") + "'"
+}
+
+var _ io.Reader = (*os.File)(nil)
+var _ = filepath.Separator
diff --git a/internal/frp/store.go b/internal/frp/store.go
new file mode 100644
index 0000000..2edf2a2
--- /dev/null
+++ b/internal/frp/store.go
@@ -0,0 +1,496 @@
+package frp
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+type Store struct {
+ db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) ListServers(ctx context.Context) ([]Server, error) {
+ rows, err := s.db.QueryContext(ctx, serverSelect+` ORDER BY id ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ servers := []Server{}
+ for rows.Next() {
+ server, err := scanServer(rows)
+ if err != nil {
+ return nil, err
+ }
+ servers = append(servers, server)
+ }
+ return servers, rows.Err()
+}
+
+func (s *Store) GetServer(ctx context.Context, id int64) (Server, error) {
+ server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Server{}, fmt.Errorf("FRP 服务端不存在")
+ }
+ return server, err
+}
+
+func (s *Store) GetEnabledServer(ctx context.Context) (Server, error) {
+ server, err := scanServer(s.db.QueryRowContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id LIMIT 1`))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Server{}, fmt.Errorf("未启用 FRP 服务端")
+ }
+ return server, err
+}
+
+func (s *Store) ListEnabledServers(ctx context.Context) ([]Server, error) {
+ rows, err := s.db.QueryContext(ctx, serverSelect+` WHERE enabled = 1 ORDER BY id ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ servers := []Server{}
+ for rows.Next() {
+ server, err := scanServer(rows)
+ if err != nil {
+ return nil, err
+ }
+ servers = append(servers, server)
+ }
+ return servers, rows.Err()
+}
+
+func (s *Store) CreateServer(ctx context.Context, in ServerInput, tokenEnc, agentTokenEnc, sshSecretEnc string) (Server, error) {
+ opts, _ := json.Marshal(in.Options)
+ result, err := s.db.ExecContext(ctx, `
+ INSERT INTO frp_servers(name, server_addr, server_port, auth_token_enc, tls_enabled, tls_server_name, enabled, config_json, agent_url, agent_token_enc, ssh_host, ssh_port, ssh_user, ssh_auth, ssh_secret_enc, mgmt_enabled, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ `, in.Name, in.ServerAddr, in.ServerPort, tokenEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(opts), in.AgentURL, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, sshSecretEnc, boolInt(in.MgmtEnabled))
+ if err != nil {
+ return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
+ }
+ id, _ := result.LastInsertId()
+ return s.GetServer(ctx, id)
+}
+
+func (s *Store) UpdateServer(ctx context.Context, id int64, in ServerInput, tokenEnc string, updateToken bool, agentTokenEnc string, updateAgentToken bool, sshSecretEnc string, updateSSHSecret bool) (Server, error) {
+ options, _ := json.Marshal(in.Options)
+ query := `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, agent_url = ?, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
+ args := []any{in.Name, in.ServerAddr, in.ServerPort, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), in.AgentURL, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, boolInt(in.MgmtEnabled), id}
+ if updateToken || updateAgentToken || updateSSHSecret {
+ query = `UPDATE frp_servers SET name = ?, server_addr = ?, server_port = ?, auth_token_enc = CASE WHEN ? THEN ? ELSE auth_token_enc END, agent_url = ?, agent_token_enc = CASE WHEN ? THEN ? ELSE agent_token_enc END, ssh_host = ?, ssh_port = ?, ssh_user = ?, ssh_auth = ?, ssh_secret_enc = CASE WHEN ? THEN ? ELSE ssh_secret_enc END, tls_enabled = ?, tls_server_name = ?, enabled = ?, config_json = ?, mgmt_enabled = ?, updated_at = datetime('now') WHERE id = ?`
+ args = []any{in.Name, in.ServerAddr, in.ServerPort, updateToken, tokenEnc, in.AgentURL, updateAgentToken, agentTokenEnc, in.SSHHost, in.SSHPort, in.SSHUser, in.SSHAuth, updateSSHSecret, sshSecretEnc, boolInt(in.TLS), in.TLSServerName, boolInt(in.Enabled), string(options), boolInt(in.MgmtEnabled), id}
+ }
+ result, err := s.db.ExecContext(ctx, query, args...)
+ if err != nil {
+ return Server{}, mapUniqueError(err, "FRP 服务端名称已存在")
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return Server{}, fmt.Errorf("FRP 服务端不存在")
+ }
+ return s.GetServer(ctx, id)
+}
+
+func (s *Store) DeleteServer(ctx context.Context, id int64) error {
+ result, err := s.db.ExecContext(ctx, `DELETE FROM frp_servers WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return fmt.Errorf("FRP 服务端不存在")
+ }
+ return nil
+}
+
+func (s *Store) ServerRuntimeStatus(ctx context.Context, serverID int64) (ServerRuntimeStatus, error) {
+ status := ServerRuntimeStatus{
+ ServerID: serverID, Status: "stopped", DesiredState: "stopped",
+ ProcessState: "stopped", ConnectionState: "unknown", StateSource: "legacy",
+ }
+ var pid, exitCode sql.NullInt64
+ err := s.db.QueryRowContext(ctx, `
+ SELECT status, COALESCE(desired_state, status), COALESCE(process_state, 'stopped'),
+ COALESCE(connection_state, 'unknown'), COALESCE(state_source, 'legacy'),
+ process_pid, exit_code, COALESCE(last_error, ''), COALESCE(last_started_at, ''),
+ COALESCE(last_connected_at, ''), COALESCE(last_disconnected_at, '')
+ FROM frp_runtime_status WHERE server_id = ?
+ `, serverID).Scan(
+ &status.Status, &status.DesiredState, &status.ProcessState, &status.ConnectionState,
+ &status.StateSource, &pid, &exitCode, &status.LastError, &status.LastStartedAt,
+ &status.LastConnectedAt, &status.LastDisconnectedAt,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return status, nil
+ }
+ if pid.Valid {
+ status.ProcessPID = int(pid.Int64)
+ }
+ if exitCode.Valid {
+ status.ExitCode = int(exitCode.Int64)
+ }
+ return status, err
+}
+
+func (s *Store) SetServerDesiredState(ctx context.Context, status ServerRuntimeStatus) error {
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO frp_runtime_status(server_id, status, desired_state, state_source, last_started_at, last_error, updated_at)
+ VALUES (?, ?, ?, 'manual', NULLIF(?, ''), ?, datetime('now'))
+ ON CONFLICT(server_id) DO UPDATE SET
+ status = excluded.status,
+ desired_state = excluded.desired_state,
+ state_source = 'manual',
+ last_started_at = COALESCE(excluded.last_started_at, frp_runtime_status.last_started_at),
+ last_error = excluded.last_error,
+ updated_at = datetime('now')
+ `, status.ServerID, status.Status, status.DesiredState, status.LastStartedAt, status.LastError)
+ return err
+}
+
+func (s *Store) SetServerObservedState(ctx context.Context, status ServerRuntimeStatus) error {
+ desiredState := "stopped"
+ if status.ProcessState == "running" {
+ desiredState = "running"
+ }
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO frp_runtime_status(
+ server_id, status, desired_state, process_state, connection_state, state_source,
+ process_pid, exit_code, last_error, last_started_at, last_connected_at, last_disconnected_at, updated_at
+ )
+ VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, 0), NULLIF(?, 0), ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), datetime('now'))
+ ON CONFLICT(server_id) DO UPDATE SET
+ process_state = excluded.process_state,
+ connection_state = excluded.connection_state,
+ state_source = excluded.state_source,
+ process_pid = excluded.process_pid,
+ exit_code = excluded.exit_code,
+ last_error = excluded.last_error,
+ last_started_at = COALESCE(NULLIF(excluded.last_started_at, ''), frp_runtime_status.last_started_at),
+ last_connected_at = COALESCE(NULLIF(excluded.last_connected_at, ''), frp_runtime_status.last_connected_at),
+ last_disconnected_at = COALESCE(NULLIF(excluded.last_disconnected_at, ''), frp_runtime_status.last_disconnected_at),
+ updated_at = datetime('now')
+ `, status.ServerID, desiredState, desiredState, status.ProcessState, status.ConnectionState, status.StateSource,
+ status.ProcessPID, status.ExitCode, status.LastError, status.LastStartedAt,
+ status.LastConnectedAt, status.LastDisconnectedAt)
+ return err
+}
+
+func (s *Store) AddServerDiagnostic(ctx context.Context, diagnostic ServerDiagnostics) error {
+ if _, err := s.db.ExecContext(ctx, `
+ INSERT INTO frp_server_diagnostics(server_id, checked_at, available, latency_ms, error)
+ VALUES (?, datetime('now'), ?, NULLIF(?, 0), ?)
+ `, diagnostic.ServerID, boolInt(diagnostic.Available), diagnostic.LatencyMS, diagnostic.Error); err != nil {
+ return err
+ }
+ // 只保留每个服务端最近 60 条采样,30 分钟曲线窗口远用不到这么多
+ _, err := s.db.ExecContext(ctx, `
+ DELETE FROM frp_server_diagnostics
+ WHERE server_id = ? AND id NOT IN (
+ SELECT id FROM frp_server_diagnostics WHERE server_id = ? ORDER BY id DESC LIMIT 60
+ )
+ `, diagnostic.ServerID, diagnostic.ServerID)
+ return err
+}
+
+func (s *Store) ServerDiagnosticDetail(ctx context.Context, serverID int64) (ServerStatusDetail, error) {
+ rows, err := s.db.QueryContext(ctx, `
+ SELECT checked_at, available, COALESCE(latency_ms, 0), COALESCE(error, '')
+ FROM frp_server_diagnostics
+ WHERE server_id = ? AND checked_at >= datetime('now', '-30 minutes')
+ ORDER BY checked_at ASC, id ASC
+ `, serverID)
+ if err != nil {
+ return ServerStatusDetail{}, err
+ }
+ defer rows.Close()
+ detail := ServerStatusDetail{Samples: []ServerSample{}}
+ for rows.Next() {
+ var sample ServerSample
+ var available int
+ if err := rows.Scan(&sample.CheckedAt, &available, &sample.LatencyMS, &sample.Error); err != nil {
+ return ServerStatusDetail{}, err
+ }
+ sample.Available = available == 1
+ detail.Samples = append(detail.Samples, sample)
+ }
+ if err := rows.Err(); err != nil {
+ return ServerStatusDetail{}, err
+ }
+ return summarizeServerSamples(detail.Samples), nil
+}
+
+func summarizeServerSamples(samples []ServerSample) ServerStatusDetail {
+ detail := ServerStatusDetail{Samples: samples}
+ if len(samples) == 0 {
+ return detail
+ }
+ detail.Available = samples[len(samples)-1].Available
+ detail.LastCheckedAt = samples[len(samples)-1].CheckedAt
+ detail.LastError = samples[len(samples)-1].Error
+ var total int64
+ var available int
+ for _, sample := range samples {
+ if !sample.Available {
+ continue
+ }
+ available++
+ total += sample.LatencyMS
+ if sample.LatencyMS > detail.PeakLatency {
+ detail.PeakLatency = sample.LatencyMS
+ }
+ }
+ detail.Availability = float64(available) / float64(len(samples)) * 100
+ if available > 0 {
+ detail.AverageLatency = total / int64(available)
+ }
+ return detail
+}
+
+func (s *Store) CountOtherEnabledServers(ctx context.Context, id int64) (int, error) {
+ var count int
+ err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM frp_servers WHERE enabled = 1 AND id != ?`, id).Scan(&count)
+ return count, err
+}
+
+func (s *Store) SSHSecret(ctx context.Context, id int64) (string, error) {
+ var secret string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(ssh_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("FRP 服务端不存在")
+ }
+ return secret, err
+}
+
+func (s *Store) AgentToken(ctx context.Context, id int64) (string, error) {
+ var token string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(agent_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("FRP 服务端不存在")
+ }
+ return token, err
+}
+
+func (s *Store) Token(ctx context.Context, id int64) (string, error) {
+ var token string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(auth_token_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&token)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("FRP 服务端不存在")
+ }
+ return token, err
+}
+
+func (s *Store) OIDCSecret(ctx context.Context, id int64) (string, error) {
+ var secret string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(oidc_client_secret_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&secret)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("FRP 服务端不存在")
+ }
+ return secret, err
+}
+
+func (s *Store) SetOIDCSecret(ctx context.Context, id int64, value string) error {
+ result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET oidc_client_secret_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
+ if err != nil {
+ return err
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return fmt.Errorf("FRP 服务端不存在")
+ }
+ return nil
+}
+
+func (s *Store) DashboardPassword(ctx context.Context, id int64) (string, error) {
+ var value string
+ err := s.db.QueryRowContext(ctx, `SELECT COALESCE(dashboard_password_enc, '') FROM frp_servers WHERE id = ?`, id).Scan(&value)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", fmt.Errorf("FRP 服务端不存在")
+ }
+ return value, err
+}
+
+func (s *Store) SetDashboardPassword(ctx context.Context, id int64, value string) error {
+ result, err := s.db.ExecContext(ctx, `UPDATE frp_servers SET dashboard_password_enc = ?, updated_at = datetime('now') WHERE id = ?`, value, id)
+ if err != nil {
+ return err
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return fmt.Errorf("FRP 服务端不存在")
+ }
+ return nil
+}
+
+func (s *Store) ListProxies(ctx context.Context) ([]Proxy, error) {
+ rows, err := s.db.QueryContext(ctx, proxySelect+` ORDER BY server_id ASC, id ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanProxies(rows)
+}
+
+func (s *Store) ListProxiesByServer(ctx context.Context, serverID int64) ([]Proxy, error) {
+ rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? ORDER BY id ASC`, serverID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanProxies(rows)
+}
+
+func (s *Store) ListEnabledProxies(ctx context.Context, serverID int64) ([]Proxy, error) {
+ rows, err := s.db.QueryContext(ctx, proxySelect+` WHERE server_id = ? AND enabled = 1 ORDER BY id ASC`, serverID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return scanProxies(rows)
+}
+
+func (s *Store) GetProxy(ctx context.Context, id int64) (Proxy, error) {
+ proxy, err := scanProxy(s.db.QueryRowContext(ctx, proxySelect+` WHERE id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Proxy{}, fmt.Errorf("FRP 规则不存在")
+ }
+ return proxy, err
+}
+
+func (s *Store) CreateProxy(ctx context.Context, in ProxyInput) (Proxy, error) {
+ domains, _ := json.Marshal(in.CustomDomains)
+ options, _ := json.Marshal(in.Options)
+ result, err := s.db.ExecContext(ctx, `
+ INSERT INTO frp_proxies(server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ `, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark)
+ if err != nil {
+ return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
+ }
+ id, _ := result.LastInsertId()
+ return s.GetProxy(ctx, id)
+}
+
+func (s *Store) UpdateProxy(ctx context.Context, id int64, in ProxyInput) (Proxy, error) {
+ domains, _ := json.Marshal(in.CustomDomains)
+ options, _ := json.Marshal(in.Options)
+ result, err := s.db.ExecContext(ctx, `
+ UPDATE frp_proxies SET server_id = ?, name = ?, type = ?, local_ip = ?, local_port = ?, remote_port = ?, custom_domains_json = ?, host_header_rewrite = ?, config_json = ?, enabled = ?, remark = ?, updated_at = datetime('now')
+ WHERE id = ?
+ `, in.ServerID, in.Name, in.Type, in.LocalIP, in.LocalPort, in.RemotePort, string(domains), in.HostHeaderRewrite, string(options), boolInt(in.Enabled), in.Remark, id)
+ if err != nil {
+ return Proxy{}, mapUniqueError(err, "FRP 规则名称或 TCP 远程端口已存在")
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return Proxy{}, fmt.Errorf("FRP 规则不存在")
+ }
+ return s.GetProxy(ctx, id)
+}
+
+func (s *Store) DeleteProxy(ctx context.Context, id int64) error {
+ result, err := s.db.ExecContext(ctx, `DELETE FROM frp_proxies WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ count, _ := result.RowsAffected()
+ if count == 0 {
+ return fmt.Errorf("FRP 规则不存在")
+ }
+ return nil
+}
+
+func scanProxies(rows *sql.Rows) ([]Proxy, error) {
+ proxies := []Proxy{}
+ for rows.Next() {
+ proxy, err := scanProxy(rows)
+ if err != nil {
+ return nil, err
+ }
+ proxies = append(proxies, proxy)
+ }
+ return proxies, rows.Err()
+}
+
+const serverSelect = `
+SELECT id, name, server_addr, server_port, tls_enabled, tls_server_name, enabled,
+ CASE WHEN auth_token_enc IS NOT NULL AND auth_token_enc != '' THEN 1 ELSE 0 END,
+ CASE WHEN oidc_client_secret_enc IS NOT NULL AND oidc_client_secret_enc != '' THEN 1 ELSE 0 END,
+ CASE WHEN dashboard_password_enc IS NOT NULL AND dashboard_password_enc != '' THEN 1 ELSE 0 END,
+ COALESCE(agent_url, ''), CASE WHEN COALESCE(agent_token_enc, '') != '' THEN 1 ELSE 0 END,
+ COALESCE(ssh_host, ''), COALESCE(ssh_port, 22), COALESCE(ssh_user, ''), COALESCE(ssh_auth, 'key'),
+ CASE WHEN COALESCE(ssh_secret_enc, '') != '' THEN 1 ELSE 0 END, COALESCE(mgmt_enabled, 0),
+ COALESCE(config_json, '{}'), created_at, updated_at
+FROM frp_servers`
+
+const proxySelect = `
+SELECT id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
+FROM frp_proxies`
+
+func scanServer(row interface{ Scan(dest ...any) error }) (Server, error) {
+ var server Server
+ var tls, enabled, token, oidcSecret, dashboardPwd, agentConfigured, sshConfigured, mgmtEnabled int
+ var configJSON, created, updated string
+ err := row.Scan(&server.ID, &server.Name, &server.ServerAddr, &server.ServerPort, &tls, &server.TLSServerName, &enabled, &token, &oidcSecret, &dashboardPwd, &server.AgentURL, &agentConfigured, &server.SSHHost, &server.SSHPort, &server.SSHUser, &server.SSHAuth, &sshConfigured, &mgmtEnabled, &configJSON, &created, &updated)
+ if err != nil {
+ return Server{}, err
+ }
+ server.TLS = tls == 1
+ server.Enabled = enabled == 1
+ server.HasToken = token == 1
+ server.HasOIDCSecret = oidcSecret == 1
+ server.DashboardHasPwd = dashboardPwd == 1
+ server.AgentConfigured = agentConfigured == 1
+ server.SSHConfigured = sshConfigured == 1
+ server.MgmtEnabled = mgmtEnabled == 1
+ _ = json.Unmarshal([]byte(configJSON), &server.Options)
+ server.Options = normalizeServerOptions(server.Options)
+ server.CreatedAt = parseTime(created)
+ server.UpdatedAt = parseTime(updated)
+ return server, nil
+}
+
+func scanProxy(row interface{ Scan(dest ...any) error }) (Proxy, error) {
+ var proxy Proxy
+ var domains, configJSON, created, updated string
+ var enabled int
+ if err := row.Scan(&proxy.ID, &proxy.ServerID, &proxy.Name, &proxy.Type, &proxy.LocalIP, &proxy.LocalPort, &proxy.RemotePort, &domains, &proxy.HostHeaderRewrite, &configJSON, &enabled, &proxy.Remark, &created, &updated); err != nil {
+ return Proxy{}, err
+ }
+ _ = json.Unmarshal([]byte(domains), &proxy.CustomDomains)
+ _ = json.Unmarshal([]byte(configJSON), &proxy.Options)
+ proxy.Enabled = enabled == 1
+ proxy.CreatedAt = parseTime(created)
+ proxy.UpdatedAt = parseTime(updated)
+ return proxy, nil
+}
+
+func mapUniqueError(err error, message string) error {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return fmt.Errorf(message)
+ }
+ return err
+}
+
+func boolInt(value bool) int {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func parseTime(value string) time.Time {
+ parsed, err := time.Parse("2006-01-02 15:04:05", value)
+ if err != nil {
+ parsed, _ = time.Parse(time.RFC3339, value)
+ }
+ return parsed.UTC()
+}
diff --git a/internal/frp/traffic.go b/internal/frp/traffic.go
new file mode 100644
index 0000000..96b30d5
--- /dev/null
+++ b/internal/frp/traffic.go
@@ -0,0 +1,226 @@
+package frp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// frps 管理接口(webServer)返回的代理状态结构,仅取本模块用到的字段。
+type frpsProxyStatus struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ TodayTrafficIn int64 `json:"today_traffic_in"`
+ TodayTrafficOut int64 `json:"today_traffic_out"`
+ CurrentConns int `json:"cur_conns"`
+}
+
+type ProxyTraffic struct {
+ TrafficIn int64 `json:"traffic_in"`
+ TrafficOut int64 `json:"traffic_out"`
+ CurConns int `json:"cur_conns"`
+ TrafficInRate int64 `json:"traffic_in_rate"`
+ TrafficOutRate int64 `json:"traffic_out_rate"`
+}
+
+type ServerTraffic struct {
+ ServerID int64 `json:"server_id"`
+ OK bool `json:"ok"`
+ Error string `json:"error,omitempty"`
+ Proxies map[string]ProxyTraffic `json:"proxies"`
+ ProxyNamesByDomain map[string]string `json:"proxy_names_by_domain,omitempty"`
+}
+
+type ProxiesTraffic struct {
+ Items map[int64]ProxyTraffic `json:"items"`
+ Errors map[int64]string `json:"errors,omitempty"`
+ SampledAt string `json:"sampled_at"`
+}
+
+type trafficSample struct {
+ at time.Time
+ in int64
+ out int64
+}
+
+type frpsTrafficClient struct {
+ httpClient *http.Client
+}
+
+func newFRPSTrafficClient() *frpsTrafficClient {
+ return &frpsTrafficClient{httpClient: &http.Client{Timeout: 6 * time.Second}}
+}
+
+// fetchServerTraffic 版本自适应采集:
+// - frp >= v0.71.0:GET /api/v2/proxies(一次返回全部类型,data.items[].name 含 user 前缀);
+// - frp < v0.71.0(如 v0.68.0):无 V2 API(/api/v2/* 404),用 GET /api/proxies(一次返回全部类型)。
+//
+// 判定方式:V2 返回 404 page not found 即回退旧端点;其余错误如实返回。
+func (c *frpsTrafficClient) fetchServerTraffic(ctx context.Context, server Server, password string) ServerTraffic {
+ empty := map[string]ProxyTraffic{}
+ if strings.TrimSpace(server.Options.DashboardAddr) == "" || server.Options.DashboardPort == 0 {
+ return ServerTraffic{ServerID: server.ID, Error: "未配置 frps 管理接口(webServer)", Proxies: empty}
+ }
+
+ // 优先 V2 API(v0.71.0+)
+ v2URL := fmt.Sprintf("http://%s:%d/api/v2/proxies", server.Options.DashboardAddr, server.Options.DashboardPort)
+ body, status, err := c.getJSON(ctx, v2URL, server, password)
+ if err != nil {
+ return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口请求失败(GET %s): %v", v2URL, err), Proxies: empty}
+ }
+ if status == http.StatusOK {
+ return c.parseV2Proxies(server, body)
+ }
+ if status != http.StatusNotFound {
+ return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("frps 管理接口返回 %d(GET %s): %s", status, v2URL, strings.TrimSpace(string(body))), Proxies: empty}
+ }
+
+ // 旧版没有全量列表端点,流量采集无法在不知道代理名时继续;公网反代健康检查使用
+ // fetchLegacyRouteProxies 按候选规则逐条查询 /api/proxies/{user.name}。
+ return ServerTraffic{ServerID: server.ID, Error: "frps 旧版 dashboard 不支持全量代理列表,请通过规则名逐条查询", Proxies: empty}
+}
+
+// legacyProxyInfo 是 frp 0.68.0 的单代理响应结构。
+type legacyProxyInfo struct {
+ Name string `json:"name"`
+ User string `json:"user"`
+ Conf struct {
+ CustomDomains []string `json:"customDomains"`
+ } `json:"conf"`
+ Status string `json:"status"`
+ TodayTrafficIn int64 `json:"todayTrafficIn"`
+ TodayTrafficOut int64 `json:"todayTrafficOut"`
+ CurConns int `json:"curConns"`
+}
+
+// fetchLegacyProxyByName 兼容 frp 0.68.0:旧 dashboard 只能按实际代理名查询,
+// 代理名遵循 user.proxyName 规则(例如 admin.ssssss)。
+func (c *frpsTrafficClient) fetchLegacyProxyByName(ctx context.Context, server Server, password, proxyName string) (legacyProxyInfo, int, error) {
+ url := fmt.Sprintf("http://%s:%d/api/proxies/%s", server.Options.DashboardAddr, server.Options.DashboardPort, proxyName)
+ body, status, err := c.getJSON(ctx, url, server, password)
+ if err != nil {
+ return legacyProxyInfo{}, 0, err
+ }
+ if status != http.StatusOK {
+ return legacyProxyInfo{}, status, fmt.Errorf("GET %s 返回 %d: %s", url, status, strings.TrimSpace(string(body)))
+ }
+ var info legacyProxyInfo
+ if err := json.Unmarshal(body, &info); err != nil {
+ return legacyProxyInfo{}, status, fmt.Errorf("解析 GET %s 响应失败: %v", url, err)
+ }
+ return info, status, nil
+}
+
+// fetchLegacyRouteProxies 按候选规则逐条查询旧版 frps,并保留域名到真实代理名的映射。
+func (c *frpsTrafficClient) fetchLegacyRouteProxies(ctx context.Context, server Server, password string, names []string) ServerTraffic {
+ empty := map[string]ProxyTraffic{}
+ proxies := make(map[string]ProxyTraffic)
+ proxyNamesByDomain := make(map[string]string)
+ var lastErr error
+ for _, name := range names {
+ proxyName := name
+ if user := strings.TrimSpace(server.Options.User); user != "" && !strings.HasPrefix(proxyName, user+".") {
+ proxyName = user + "." + proxyName
+ }
+ info, _, err := c.fetchLegacyProxyByName(ctx, server, password, proxyName)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ traffic := ProxyTraffic{TrafficIn: info.TodayTrafficIn, TrafficOut: info.TodayTrafficOut, CurConns: info.CurConns}
+ proxies[info.Name] = traffic
+ for _, domain := range info.Conf.CustomDomains {
+ if domain = strings.ToLower(strings.TrimSpace(domain)); domain != "" {
+ proxies["domain:"+domain] = traffic
+ proxyNamesByDomain[domain] = info.Name
+ }
+ }
+ }
+ if len(proxies) == 0 && lastErr != nil {
+ return ServerTraffic{ServerID: server.ID, Error: lastErr.Error(), Proxies: empty}
+ }
+ return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
+}
+
+// getJSON 执行带 Basic Auth 的 GET 请求并返回响应体与状态码。
+func (c *frpsTrafficClient) getJSON(ctx context.Context, url string, server Server, password string) ([]byte, int, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, 0, err
+ }
+ req.Header.Set("Accept", "application/json")
+ if user := strings.TrimSpace(server.Options.DashboardUser); user != "" {
+ req.SetBasicAuth(user, password)
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return nil, resp.StatusCode, err
+ }
+ return body, resp.StatusCode, nil
+}
+
+// parseV2Proxies 解析 V2 API 响应(实测结构):
+// {"code":200,"data":{"items":[{"name":"admin.ssssss","user":"admin","spec":{"type":"http","http":{"customDomains":[...]}},"status":{"phase":"online",...}}]}}
+func (c *frpsTrafficClient) parseV2Proxies(server Server, body []byte) ServerTraffic {
+ empty := map[string]ProxyTraffic{}
+ var payload struct {
+ Data struct {
+ Items []struct {
+ Name string `json:"name"`
+ User string `json:"user"`
+ Spec struct {
+ Type string `json:"type"`
+ HTTP struct {
+ CustomDomains []string `json:"customDomains"`
+ } `json:"http"`
+ } `json:"spec"`
+ Status struct {
+ Phase string `json:"phase"`
+ TodayTrafficIn int64 `json:"todayTrafficIn"`
+ TodayTrafficOut int64 `json:"todayTrafficOut"`
+ CurConns int `json:"curConns"`
+ } `json:"status"`
+ } `json:"items"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return ServerTraffic{ServerID: server.ID, Error: fmt.Sprintf("解析 V2 响应失败: %v", err), Proxies: empty}
+ }
+ proxies := make(map[string]ProxyTraffic)
+ proxyNamesByDomain := make(map[string]string)
+ for _, item := range payload.Data.Items {
+ traffic := ProxyTraffic{
+ TrafficIn: item.Status.TodayTrafficIn,
+ TrafficOut: item.Status.TodayTrafficOut,
+ CurConns: item.Status.CurConns,
+ }
+ proxies[item.Name] = traffic
+ // 域名索引:健康检查按域名直接匹配(比代理名猜测可靠)
+ for _, d := range item.Spec.HTTP.CustomDomains {
+ domain := strings.ToLower(strings.TrimSpace(d))
+ if domain != "" {
+ proxies["domain:"+domain] = traffic
+ proxyNamesByDomain[domain] = item.Name
+ }
+ }
+ }
+ return ServerTraffic{ServerID: server.ID, OK: true, Proxies: proxies, ProxyNamesByDomain: proxyNamesByDomain}
+}
+
+// validDashboardAddr 宽松校验管理接口地址(IP 或域名,不带协议与端口)。
+func validDashboardAddr(addr string) bool {
+ addr = strings.TrimSpace(addr)
+ if addr == "" {
+ return true
+ }
+ return validHost(addr)
+}
diff --git a/internal/frp/validate.go b/internal/frp/validate.go
new file mode 100644
index 0000000..6559a1e
--- /dev/null
+++ b/internal/frp/validate.go
@@ -0,0 +1,361 @@
+package frp
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "regexp"
+ "strings"
+)
+
+var namePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+
+func validateServerInput(in ServerInput) error {
+ if strings.TrimSpace(in.Name) == "" || len([]rune(strings.TrimSpace(in.Name))) > 64 {
+ return fmt.Errorf("服务端名称长度应为 1-64 个字符")
+ }
+ if !validHost(in.ServerAddr) {
+ return fmt.Errorf("FRP 服务端地址无效")
+ }
+ if !validPort(in.ServerPort) {
+ return fmt.Errorf("FRP 服务端端口无效")
+ }
+ if in.Options.DashboardPort != 0 && !validPort(in.Options.DashboardPort) {
+ return fmt.Errorf("frps 管理接口端口无效")
+ }
+ if !validDashboardAddr(in.Options.DashboardAddr) {
+ return fmt.Errorf("frps 管理接口地址无效")
+ }
+ if strings.TrimSpace(in.TLSServerName) != "" && !validHost(in.TLSServerName) {
+ return fmt.Errorf("TLS 服务名无效")
+ }
+ if !validAuthMethod(in.Options.AuthMethod) {
+ return fmt.Errorf("不支持的认证方式")
+ }
+ if !validLogLevel(in.Options.LogLevel) {
+ return fmt.Errorf("日志级别无效")
+ }
+ if in.Options.LogMaxDays < 0 || in.Options.LogMaxDays > 3650 {
+ return fmt.Errorf("日志保留天数应为 0-3650")
+ }
+ if !validProtocol(in.Options.Protocol) {
+ return fmt.Errorf("传输协议无效")
+ }
+ if !validOptionalURL(in.Options.ProxyURL) || !validOptionalURL(in.Options.OIDCProxyURL) || !validOptionalURL(in.Options.OIDCTokenEndpointURL) {
+ return fmt.Errorf("代理或 OIDC 地址无效")
+ }
+ if in.Options.AuthMethod == "oidc" && (strings.TrimSpace(in.Options.OIDCClientID) == "" || strings.TrimSpace(in.Options.OIDCTokenEndpointURL) == "") {
+ return fmt.Errorf("OIDC 认证需要客户端 ID 和 Token Endpoint")
+ }
+ if in.Options.DialServerTimeout < 0 || in.Options.DialServerKeepalive < 0 || in.Options.PoolCount < 0 || in.Options.TCPMuxKeepaliveInterval < 0 || in.Options.HeartbeatInterval < 0 || in.Options.HeartbeatTimeout < 0 {
+ return fmt.Errorf("传输参数不能小于 0")
+ }
+ if len([]rune(in.Options.Location)) > 120 {
+ return fmt.Errorf("服务端位置不能超过 120 个字符")
+ }
+ return nil
+}
+
+func validateProxyInput(in ProxyInput) error {
+ if !namePattern.MatchString(strings.TrimSpace(in.Name)) {
+ return fmt.Errorf("规则名称仅支持字母、数字、下划线和连字符,长度 1-64")
+ }
+ if in.Options.Plugin == nil {
+ if !validHost(in.LocalIP) {
+ return fmt.Errorf("内网地址无效")
+ }
+ if !validPort(in.LocalPort) {
+ return fmt.Errorf("内网端口无效")
+ }
+ }
+ switch in.Type {
+ case "tcp", "udp":
+ if in.RemotePort == nil || !validPort(*in.RemotePort) {
+ return fmt.Errorf("%s 规则必须填写有效的远程端口", strings.ToUpper(in.Type))
+ }
+ if len(in.CustomDomains) > 0 {
+ return fmt.Errorf("%s 规则不能设置自定义域名", strings.ToUpper(in.Type))
+ }
+ case "http", "https":
+ if in.RemotePort != nil {
+ return fmt.Errorf("HTTP/HTTPS 规则不能设置远程端口")
+ }
+ if len(in.CustomDomains) == 0 {
+ return fmt.Errorf("HTTP/HTTPS 规则至少需要一个域名")
+ }
+ for _, domain := range in.CustomDomains {
+ if !validDomain(domain) {
+ return fmt.Errorf("自定义域名无效:%s", domain)
+ }
+ }
+ case "tcpmux":
+ if in.Options.Multiplexer != "httpconnect" {
+ return fmt.Errorf("TCPMUX 仅支持 httpconnect 多路复用器")
+ }
+ if len(in.CustomDomains) == 0 {
+ return fmt.Errorf("TCPMUX 规则至少需要一个自定义域名")
+ }
+ for _, domain := range in.CustomDomains {
+ if !validDomain(domain) {
+ return fmt.Errorf("自定义域名无效:%s", domain)
+ }
+ }
+ if in.RemotePort != nil {
+ return fmt.Errorf("TCPMUX 规则不能设置远程端口")
+ }
+ case "stcp", "sudp", "xtcp":
+ if strings.TrimSpace(in.Options.SecretKey) == "" {
+ return fmt.Errorf("%s 规则必须填写访问密钥", strings.ToUpper(in.Type))
+ }
+ if in.RemotePort != nil {
+ return fmt.Errorf("%s 规则不能设置远程端口", strings.ToUpper(in.Type))
+ }
+ default:
+ return fmt.Errorf("不支持的 FRP 规则类型")
+ }
+ if err := validateProxyOptions(in); err != nil {
+ return err
+ }
+ return nil
+}
+
+func validateProxyOptions(in ProxyInput) error {
+ opts := in.Options
+ if opts.Transport.BandwidthLimitMode != "" && opts.Transport.BandwidthLimitMode != "client" && opts.Transport.BandwidthLimitMode != "server" {
+ return fmt.Errorf("限速位置只能是 client 或 server")
+ }
+ if opts.Transport.ProxyProtocolVersion != "" && opts.Transport.ProxyProtocolVersion != "v1" && opts.Transport.ProxyProtocolVersion != "v2" {
+ return fmt.Errorf("代理协议版本只能是 v1 或 v2")
+ }
+ if opts.HealthCheck.Type != "" && opts.HealthCheck.Type != "tcp" && opts.HealthCheck.Type != "http" {
+ return fmt.Errorf("健康检查类型只能是 tcp 或 http")
+ }
+ if opts.HealthCheck.IntervalSeconds < 0 || opts.HealthCheck.MaxFailed < 0 || opts.HealthCheck.TimeoutSeconds < 0 {
+ return fmt.Errorf("健康检查参数不能小于 0")
+ }
+ if opts.LoadBalancer.Group != "" && opts.LoadBalancer.GroupKey == "" {
+ return fmt.Errorf("负载均衡组设置后必须填写组密钥")
+ }
+ return validateProxyPlugin(opts.Plugin)
+}
+
+// validateProxyPlugin 按插件类型分别校验必填字段;不能只判断插件类型非空。
+func validateProxyPlugin(plugin *ProxyPluginOptions) error {
+ if plugin == nil {
+ return nil
+ }
+ pluginType := strings.TrimSpace(plugin.Type)
+ if pluginType == "" {
+ return fmt.Errorf("插件类型不能为空")
+ }
+ validAddr := func(addr string, label string) error {
+ addr = strings.TrimSpace(addr)
+ host, portText, ok := strings.Cut(addr, ":")
+ if !ok || !validHost(host) || !validPort(parseInt(portText)) {
+ return fmt.Errorf("插件本地地址 %s 无效,应为 host:port 形式", label)
+ }
+ return nil
+ }
+ validSecretFile := func(path, label string) error {
+ if strings.TrimSpace(path) == "" {
+ return fmt.Errorf("插件 %s 需要提供 %s", pluginType, label)
+ }
+ return nil
+ }
+ switch pluginType {
+ case "unix_domain_socket":
+ if strings.TrimSpace(plugin.UnixPath) == "" {
+ return fmt.Errorf("插件 unix_domain_socket 需要提供 Unix Socket 路径")
+ }
+ case "static_file":
+ if strings.TrimSpace(plugin.LocalPath) == "" {
+ return fmt.Errorf("插件 static_file 需要提供静态文件目录")
+ }
+ case "http_proxy", "socks5":
+ // 用户名/密码可选
+ case "http2https", "http2http":
+ if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
+ return err
+ }
+ case "https2http", "https2https", "tls2raw":
+ if err := validAddr(plugin.LocalAddr, "localAddr"); err != nil {
+ return err
+ }
+ if err := validSecretFile(plugin.CRTPath, "crtPath"); err != nil {
+ return err
+ }
+ if err := validSecretFile(plugin.KeyPath, "keyPath"); err != nil {
+ return err
+ }
+ default:
+ return fmt.Errorf("不支持的插件类型:%s", pluginType)
+ }
+ return nil
+}
+
+// parseInt 解析十进制端口号;非数字或越界返回 0,由 validPort 拒绝。
+func parseInt(value string) int {
+ value = strings.TrimSpace(value)
+ number := 0
+ if value == "" {
+ return 0
+ }
+ for _, ch := range value {
+ if ch < '0' || ch > '9' {
+ return 0
+ }
+ number = number*10 + int(ch-'0')
+ }
+ return number
+}
+
+// ValidateProxy 是 FRP 规则校验器的对外入口:关系表等跨模块模式校验必须复用此函数,
+// 不能在编排服务中另行实现一套判断(FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)。
+func ValidateProxy(in ProxyInput) error {
+ return validateProxyInput(normalizeProxyInput(in))
+}
+
+func normalizeServerInput(in ServerInput) ServerInput {
+ in.Name = strings.TrimSpace(in.Name)
+ in.ServerAddr = strings.ToLower(strings.TrimSpace(in.ServerAddr))
+ in.TLSServerName = strings.ToLower(strings.TrimSpace(in.TLSServerName))
+ in.Token = strings.TrimSpace(in.Token)
+ in.OIDCClientSecret = strings.TrimSpace(in.OIDCClientSecret)
+ in.Options = normalizeServerOptions(in.Options)
+ return in
+}
+
+func normalizeServerOptions(in ServerOptions) ServerOptions {
+ in.User = strings.TrimSpace(in.User)
+ in.Location = strings.TrimSpace(in.Location)
+ in.AuthMethod = strings.ToLower(strings.TrimSpace(in.AuthMethod))
+ in.OIDCClientID = strings.TrimSpace(in.OIDCClientID)
+ in.OIDCAudience = strings.TrimSpace(in.OIDCAudience)
+ in.OIDCScope = strings.TrimSpace(in.OIDCScope)
+ in.OIDCTokenEndpointURL = strings.TrimSpace(in.OIDCTokenEndpointURL)
+ in.OIDCTrustedCAPath = strings.TrimSpace(in.OIDCTrustedCAPath)
+ in.OIDCProxyURL = strings.TrimSpace(in.OIDCProxyURL)
+ in.LogLevel = strings.ToLower(strings.TrimSpace(in.LogLevel))
+ in.Protocol = strings.ToLower(strings.TrimSpace(in.Protocol))
+ in.ProxyURL = strings.TrimSpace(in.ProxyURL)
+ in.ConnectServerLocalIP = strings.TrimSpace(in.ConnectServerLocalIP)
+ in.DNSServer = strings.TrimSpace(in.DNSServer)
+ in.Remark = strings.TrimSpace(in.Remark)
+ if in.AuthMethod == "" {
+ in.AuthMethod = "token"
+ }
+ if in.LogLevel == "" {
+ in.LogLevel = "info"
+ }
+ if in.Protocol == "" {
+ in.Protocol = "tcp"
+ }
+ if in.LogMaxDays == 0 {
+ in.LogMaxDays = 3
+ }
+ if in.DialServerTimeout == 0 {
+ in.DialServerTimeout = 10
+ }
+ if in.DialServerKeepalive == 0 {
+ in.DialServerKeepalive = 7200
+ }
+ if in.TCPMuxKeepaliveInterval == 0 {
+ in.TCPMuxKeepaliveInterval = 60
+ }
+ if in.HeartbeatInterval == 0 {
+ in.HeartbeatInterval = 30
+ }
+ if in.HeartbeatTimeout == 0 {
+ in.HeartbeatTimeout = 90
+ }
+ return in
+}
+
+func validAuthMethod(value string) bool {
+ return value == "none" || value == "token" || value == "oidc"
+}
+func validLogLevel(value string) bool {
+ return value == "trace" || value == "debug" || value == "info" || value == "warn" || value == "error"
+}
+func validProtocol(value string) bool {
+ return value == "tcp" || value == "kcp" || value == "quic" || value == "websocket" || value == "wss"
+}
+func validOptionalURL(value string) bool {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return true
+ }
+ parsed, err := url.Parse(value)
+ return err == nil && parsed.Scheme != "" && parsed.Host != ""
+}
+
+func normalizeProxyInput(in ProxyInput) ProxyInput {
+ in.Name = strings.TrimSpace(in.Name)
+ in.Type = strings.ToLower(strings.TrimSpace(in.Type))
+ in.LocalIP = strings.TrimSpace(in.LocalIP)
+ in.HostHeaderRewrite = strings.TrimSpace(in.HostHeaderRewrite)
+ in.Remark = strings.TrimSpace(in.Remark)
+ in.Options = normalizeProxyOptions(in.Options)
+ seen := map[string]bool{}
+ domains := make([]string, 0, len(in.CustomDomains))
+ for _, domain := range in.CustomDomains {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain != "" && !seen[domain] {
+ seen[domain] = true
+ domains = append(domains, domain)
+ }
+ }
+ in.CustomDomains = domains
+ return in
+}
+
+func normalizeProxyOptions(in ProxyOptions) ProxyOptions {
+ in.Subdomain = strings.TrimSpace(in.Subdomain)
+ in.RouteByHTTPUser = strings.TrimSpace(in.RouteByHTTPUser)
+ in.HTTPUser = strings.TrimSpace(in.HTTPUser)
+ in.HTTPPassword = strings.TrimSpace(in.HTTPPassword)
+ in.Multiplexer = strings.ToLower(strings.TrimSpace(in.Multiplexer))
+ in.SecretKey = strings.TrimSpace(in.SecretKey)
+ for index, user := range in.AllowUsers {
+ in.AllowUsers[index] = strings.TrimSpace(user)
+ }
+ in.Transport.BandwidthLimit = strings.TrimSpace(in.Transport.BandwidthLimit)
+ in.Transport.BandwidthLimitMode = strings.ToLower(strings.TrimSpace(in.Transport.BandwidthLimitMode))
+ in.Transport.ProxyProtocolVersion = strings.ToLower(strings.TrimSpace(in.Transport.ProxyProtocolVersion))
+ in.HealthCheck.Type = strings.ToLower(strings.TrimSpace(in.HealthCheck.Type))
+ in.HealthCheck.Path = strings.TrimSpace(in.HealthCheck.Path)
+ return in
+}
+
+func validPort(port int) bool {
+ return port > 0 && port <= 65535
+}
+
+func validHost(host string) bool {
+ host = strings.TrimSpace(host)
+ if host == "" || len(host) > 253 || strings.ContainsAny(host, " \t\r\n/\\\"'") {
+ return false
+ }
+ if net.ParseIP(host) != nil || host == "localhost" {
+ return true
+ }
+ return validDomain(host)
+}
+
+func validDomain(domain string) bool {
+ domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
+ if domain == "" || len(domain) > 253 || strings.Contains(domain, "..") {
+ return false
+ }
+ for _, label := range strings.Split(domain, ".") {
+ if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
+ return false
+ }
+ for _, ch := range label {
+ if !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z') && !(ch >= '0' && ch <= '9') && ch != '-' {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/internal/frp/validate_test.go b/internal/frp/validate_test.go
new file mode 100644
index 0000000..ebfc192
--- /dev/null
+++ b/internal/frp/validate_test.go
@@ -0,0 +1,43 @@
+package frp
+
+import "testing"
+
+func TestValidateProxyInput(t *testing.T) {
+ port := 60022
+ cases := []struct {
+ name string
+ input ProxyInput
+ wantErr bool
+ }{
+ {name: "tcp", input: ProxyInput{Name: "nas_ssh", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port}},
+ {name: "http", input: ProxyInput{Name: "nas-web", Type: "http", LocalIP: "127.0.0.1", LocalPort: 18080, CustomDomains: []string{"nas.example.com"}}},
+ {name: "missing remote port", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
+ {name: "invalid name", input: ProxyInput{Name: "bad name", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"nas.example.com"}}, wantErr: true},
+ {name: "invalid domain", input: ProxyInput{Name: "nas", Type: "http", LocalIP: "127.0.0.1", LocalPort: 80, CustomDomains: []string{"bad domain"}}, wantErr: true},
+ {name: "udp", input: ProxyInput{Name: "dns", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
+ {name: "stcp", input: ProxyInput{Name: "secret", Type: "stcp", LocalIP: "127.0.0.1", LocalPort: 22, Options: ProxyOptions{SecretKey: "abc"}}},
+ {name: "tcpmux missing multiplexer", input: ProxyInput{Name: "mux", Type: "tcpmux", LocalIP: "127.0.0.1", LocalPort: 80}, wantErr: true},
+ {name: "invalid bandwidth mode", input: ProxyInput{Name: "nas", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Transport: ProxyTransportOptions{BandwidthLimitMode: "wrong"}}}, wantErr: true},
+ {name: "udp remote port", input: ProxyInput{Name: "dns2", Type: "udp", LocalIP: "127.0.0.1", LocalPort: 53, RemotePort: &port}},
+ {name: "xtcp missing secret key", input: ProxyInput{Name: "p2p", Type: "xtcp", LocalIP: "127.0.0.1", LocalPort: 22}, wantErr: true},
+ {name: "plugin http2https missing local addr", input: ProxyInput{Name: "p1", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", CRTPath: "cert.pem", KeyPath: "key.pem"}}}, wantErr: true},
+ {name: "plugin https2http missing cert", input: ProxyInput{Name: "p2", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "https2http", LocalAddr: "127.0.0.1:80"}}}, wantErr: true},
+ {name: "plugin socks5 optional auth", input: ProxyInput{Name: "p3", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "socks5"}}}},
+ {name: "plugin unix_domain_socket missing path", input: ProxyInput{Name: "p4", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "unix_domain_socket"}}}, wantErr: true},
+ {name: "plugin static_file missing local path", input: ProxyInput{Name: "p5", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "static_file"}}}, wantErr: true},
+ {name: "plugin unknown type", input: ProxyInput{Name: "p6", Type: "tcp", LocalIP: "127.0.0.1", LocalPort: 22, RemotePort: &port, Options: ProxyOptions{Plugin: &ProxyPluginOptions{Type: "whatever"}}}, wantErr: true},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ err := validateProxyInput(testCase.input)
+ if (err != nil) != testCase.wantErr {
+ t.Fatalf("validateProxyInput() error = %v, wantErr %v", err, testCase.wantErr)
+ }
+ // 对外导出的校验入口应与内部入口行为一致(关系表模式校验复用此函数)
+ if (ValidateProxy(testCase.input) != nil) != testCase.wantErr {
+ t.Fatalf("ValidateProxy() error = %v, wantErr %v", ValidateProxy(testCase.input), testCase.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/logstore/access_parse_test.go b/internal/logstore/access_parse_test.go
new file mode 100644
index 0000000..f336a22
--- /dev/null
+++ b/internal/logstore/access_parse_test.go
@@ -0,0 +1,46 @@
+package logstore
+
+import "testing"
+
+func TestParseRealNginxAccessLine(t *testing.T) {
+ line := "2026-09-12T19:55:18+08:00 127.0.0.1 GET / 200 0.000 127.0.0.1 [::1]:5173"
+ entry, ok := parseAccess(line)
+ if !ok {
+ t.Fatal("parse failed")
+ }
+ if entry.Status != 200 {
+ t.Fatalf("status %d", entry.Status)
+ }
+}
+
+func TestParseAccessWithBytes(t *testing.T) {
+ line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080 512 4096"
+ entry, ok := parseAccess(line)
+ if !ok {
+ t.Fatal("parse failed")
+ }
+ if entry.ServerPort != 6893 {
+ t.Fatalf("server_port=%d", entry.ServerPort)
+ }
+ if entry.RequestLength != 512 || entry.BytesSent != 4096 {
+ t.Fatalf("bytes upload=%d download=%d", entry.RequestLength, entry.BytesSent)
+ }
+}
+
+func TestAccessEndpointFilterMatchesPort(t *testing.T) {
+ line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
+ other := "2026-09-12T19:55:19+08:00 speedtest.example.com 8443 GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
+ filter := AccessEndpointFilter([]AccessEndpoint{{Host: "speedtest.example.com", Port: 6893}})
+ if filter == nil || !filter(line) || filter(other) {
+ t.Fatal("endpoint filter mismatch")
+ }
+}
+
+func TestAccessHostFilter(t *testing.T) {
+ line := "2026-09-12T19:55:18+08:00 speedtest.example.com GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
+ other := "2026-09-12T19:55:19+08:00 other.example.com GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
+ filter := AccessHostFilter([]string{"speedtest.example.com"})
+ if filter == nil || !filter(line) || filter(other) {
+ t.Fatal("host filter mismatch")
+ }
+}
diff --git a/internal/logstore/dashboard_access_test.go b/internal/logstore/dashboard_access_test.go
new file mode 100644
index 0000000..4f98bd8
--- /dev/null
+++ b/internal/logstore/dashboard_access_test.go
@@ -0,0 +1,37 @@
+package logstore
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestDashboardAccessFilterSkipsNoisePaths(t *testing.T) {
+ if isDashboardAccess(AccessEntry{Path: "/login"}) != true {
+ t.Fatal("expected /login to count")
+ }
+ if isDashboardAccess(AccessEntry{Path: "/favicon.ico"}) {
+ t.Fatal("expected favicon to be excluded")
+ }
+ if isDashboardAccess(AccessEntry{Path: "/fonu-errors/error.png"}) {
+ t.Fatal("expected error asset to be excluded")
+ }
+}
+
+func TestCountAccessForDayExcludesNoisePaths(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "access.log")
+ now := time.Now()
+ line := formatAccessLine(now, "/login")
+ noise := formatAccessLine(now, "/favicon.ico")
+ if err := os.WriteFile(path, []byte(strings.Join([]string{line, noise}, "\n")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ total, _, _ := CountAccessForDay(path, now)
+ if total != 1 {
+ t.Fatalf("expected 1 dashboard request, got %d", total)
+ }
+}
diff --git a/internal/logstore/display.go b/internal/logstore/display.go
new file mode 100644
index 0000000..bdb80ca
--- /dev/null
+++ b/internal/logstore/display.go
@@ -0,0 +1,211 @@
+package logstore
+
+import (
+ "fmt"
+ "strings"
+)
+
+func localizeSystemEntry(entry SystemEntry, raw map[string]any) SystemEntry {
+ msg := strings.TrimSpace(entry.Message)
+ if msg == "" {
+ return entry
+ }
+ entry.Module = localizeSystemModule(entry.Module)
+ entry.Message = localizeSystemMessage(msg, raw)
+ return entry
+}
+
+func localizeSystemModule(module string) string {
+ switch strings.ToUpper(strings.TrimSpace(module)) {
+ case "SYSTEM":
+ return "系统"
+ case "NGINX":
+ return "Nginx"
+ case "DDNS":
+ return "域名解析"
+ case "ACME", "CERT":
+ return "证书"
+ case "CHINA_CIDR":
+ return "中国 IP 段"
+ case "API":
+ return "API"
+ case "HTTP":
+ return "HTTP"
+ case "FRP":
+ return "内网穿透"
+ default:
+ if module == "" {
+ return "系统"
+ }
+ return module
+ }
+}
+
+func localizeSystemMessage(msg string, raw map[string]any) string {
+ if looksChinese(msg) {
+ return appendSystemAttrs(msg, raw)
+ }
+
+ switch msg {
+ case "application started":
+ if listen := rawString(raw, "listen"); listen != "" {
+ return fmt.Sprintf("应用已启动,监听 %s", listen)
+ }
+ return "应用已启动"
+ case "application shutting down":
+ return "应用正在关闭"
+ case "nginx started":
+ return "Nginx 已启动"
+ case "nginx stopped":
+ return "Nginx 已停止"
+ case "nginx reloaded":
+ return "Nginx 已重载"
+ case "nginx reloaded existing master":
+ return "Nginx 已重载(使用现有主进程)"
+ case "nginx start skipped duplicate master, reloaded instead":
+ return "检测到重复 Nginx 主进程,已改为重载"
+ case "nginx reload failed, trying restart":
+ return fmt.Sprintf("Nginx 重载失败,正在尝试重启:%s", rawString(raw, "error"))
+ case "nginx signal reload failed, falling back to cli":
+ return fmt.Sprintf("Nginx 信号重载失败,回退到命令行重载(PID %s):%s", rawString(raw, "pid"), rawString(raw, "error"))
+ case "nginx command failed":
+ return fmt.Sprintf("Nginx 命令执行失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
+ case "nginx start failed":
+ return fmt.Sprintf("Nginx 启动失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
+ case "nginx binary not found, applied syntax-only validation":
+ return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行配置语法校验(%s)", rawString(raw, "bin"))
+ case "nginx binary not found, syntax-only validation":
+ return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行语法校验(%s)", rawString(raw, "bin"))
+ case "initial nginx apply skipped":
+ return fmt.Sprintf("初始 Nginx 配置应用已跳过:%s", rawString(raw, "error"))
+ case "initial frpc bootstrap skipped":
+ return fmt.Sprintf("初始 frpc 引导已跳过:%s", rawString(raw, "error"))
+ case "nginx stop failed":
+ return fmt.Sprintf("Nginx 停止失败:%s", rawString(raw, "error"))
+ case "frpc stop failed":
+ return fmt.Sprintf("frpc 停止失败:%s", rawString(raw, "error"))
+ case "frpc started":
+ return "frpc 已启动"
+ case "frpc stopped":
+ return "frpc 已停止"
+ case "frpc reloaded":
+ return "frpc 已重载"
+ case "frpc reload failed, trying restart":
+ return fmt.Sprintf("frpc 重载失败,正在尝试重启:%s", rawString(raw, "error"))
+ case "frpc binary not found, skipping bootstrap":
+ return fmt.Sprintf("未找到 frpc 可执行文件,已跳过引导(%s)", rawString(raw, "bin"))
+ case "initial frpc apply skipped":
+ return fmt.Sprintf("初始 frpc 配置应用已跳过:%s", rawString(raw, "error"))
+ case "frpc exited during start":
+ return fmt.Sprintf("frpc 启动过程中退出:%s", rawString(raw, "error"))
+ case "china cidr update failed":
+ return fmt.Sprintf("中国 IP 段更新失败:%s", rawString(raw, "error"))
+ case "china cidr updated":
+ return fmt.Sprintf("中国 IP 段已更新(IPv4 %s 条,IPv6 %s 条)", rawString(raw, "v4"), rawString(raw, "v6"))
+ case "ddns update failed":
+ return fmt.Sprintf("DDNS 更新失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+ case "ddns ipv4 updated":
+ return fmt.Sprintf("DDNS IPv4 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
+ case "ddns ipv6 updated":
+ return fmt.Sprintf("DDNS IPv6 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
+ case "ddns records updated":
+ return fmt.Sprintf("DDNS 记录已更新(服务商:%s)", rawString(raw, "provider"))
+ case "notify load config failed":
+ return fmt.Sprintf("通知配置加载失败:%s", rawString(raw, "error"))
+ case "notify send failed":
+ return fmt.Sprintf("通知发送失败(%s):%s", rawString(raw, "event"), rawString(raw, "error"))
+ case "notify test send failed":
+ return fmt.Sprintf("通知测试发送失败:%s", rawString(raw, "error"))
+ case "request failed":
+ return fmt.Sprintf("API 请求失败(%s %s,状态 %s):%s",
+ rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
+ case "request rejected":
+ return fmt.Sprintf("API 请求被拒绝(%s %s,状态 %s):%s",
+ rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
+ case "api request completed with client error":
+ return fmt.Sprintf("API 请求返回客户端错误(%s %s,状态 %s,耗时 %sms)",
+ rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
+ case "api request failed":
+ return fmt.Sprintf("API 请求返回服务端错误(%s %s,状态 %s,耗时 %sms)",
+ rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
+ case "certificate operation failed":
+ return fmt.Sprintf("证书操作失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+ case "certificate obtained":
+ return fmt.Sprintf("证书已获取:%s(CA %s,到期 %s)",
+ rawString(raw, "domain"), rawString(raw, "ca"), rawString(raw, "expires_at"))
+ case "certificate imported":
+ return fmt.Sprintf("证书已导入:%s(到期 %s)", rawString(raw, "domain"), rawString(raw, "expires_at"))
+ case "certificate deleted":
+ return fmt.Sprintf("证书已删除:%s", rawString(raw, "domain"))
+ case "certificate renew failed":
+ return fmt.Sprintf("证书续签失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+ case "save cert error status failed":
+ return fmt.Sprintf("保存证书错误状态失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+ case "nginx reload after cert apply failed":
+ return fmt.Sprintf("证书申请后 Nginx 重载失败:%s", rawString(raw, "error"))
+ case "nginx reload after cert import failed":
+ return fmt.Sprintf("证书导入后 Nginx 重载失败:%s", rawString(raw, "error"))
+ case "nginx reload after cert delete failed":
+ return fmt.Sprintf("证书删除后 Nginx 重载失败:%s", rawString(raw, "error"))
+ case "nginx reload after cert renew failed":
+ return fmt.Sprintf("证书续签后 Nginx 重载失败:%s", rawString(raw, "error"))
+ case "save acme ca setting failed":
+ return fmt.Sprintf("保存 ACME 颁发机构设置失败:%s", rawString(raw, "error"))
+ case "save acme email failed":
+ return fmt.Sprintf("保存 ACME 邮箱失败:%s", rawString(raw, "error"))
+ case "remove cert files failed":
+ return fmt.Sprintf("删除证书文件失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
+ default:
+ return appendSystemAttrs(msg, raw)
+ }
+}
+
+func appendSystemAttrs(msg string, raw map[string]any) string {
+ if strings.Contains(msg, "首次启动已创建管理员账户") {
+ username := rawString(raw, "username")
+ password := rawString(raw, "password")
+ if username != "" && password != "" {
+ return fmt.Sprintf("%s(用户名:%s,密码:%s)", msg, username, password)
+ }
+ }
+ if err := rawString(raw, "error"); err != "" && !strings.Contains(msg, err) {
+ return fmt.Sprintf("%s:%s", msg, err)
+ }
+ return msg
+}
+
+func looksChinese(msg string) bool {
+ for _, r := range msg {
+ if r >= 0x4e00 && r <= 0x9fff {
+ return true
+ }
+ }
+ return false
+}
+
+func rawString(raw map[string]any, key string) string {
+ v, ok := raw[key]
+ if !ok || v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return t
+ case float64:
+ if t == float64(int64(t)) {
+ return fmt.Sprintf("%d", int64(t))
+ }
+ return fmt.Sprintf("%v", t)
+ case int:
+ return fmt.Sprintf("%d", t)
+ case int64:
+ return fmt.Sprintf("%d", t)
+ case bool:
+ if t {
+ return "true"
+ }
+ return "false"
+ default:
+ return fmt.Sprintf("%v", t)
+ }
+}
diff --git a/internal/logstore/display_test.go b/internal/logstore/display_test.go
new file mode 100644
index 0000000..1748046
--- /dev/null
+++ b/internal/logstore/display_test.go
@@ -0,0 +1,54 @@
+package logstore
+
+import "testing"
+
+func TestLocalizeSystemMessage(t *testing.T) {
+ raw := map[string]any{
+ "msg": "application started",
+ "listen": ":6893",
+ }
+ entry := localizeSystemEntry(SystemEntry{
+ Level: "INFO",
+ Module: "SYSTEM",
+ Message: "application started",
+ }, raw)
+ if entry.Message != "应用已启动,监听 :6893" {
+ t.Fatalf("unexpected message: %q", entry.Message)
+ }
+ if entry.Module != "系统" {
+ t.Fatalf("unexpected module: %q", entry.Module)
+ }
+}
+
+func TestLocalizeSystemMessageAdminBootstrap(t *testing.T) {
+ raw := map[string]any{
+ "msg": "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+ "username": "admin",
+ "password": "secret",
+ }
+ entry := localizeSystemEntry(SystemEntry{
+ Message: "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
+ Module: "SYSTEM",
+ }, raw)
+ want := "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码(用户名:admin,密码:secret)"
+ if entry.Message != want {
+ t.Fatalf("unexpected message: %q", entry.Message)
+ }
+}
+
+func TestLocalizeSystemMessageAPIRejected(t *testing.T) {
+ raw := map[string]any{
+ "msg": "request rejected",
+ "method": "POST",
+ "path": "/api/settings/notify/test",
+ "status": 400,
+ "message": "Telegram Bot Token 未配置",
+ }
+ entry := localizeSystemEntry(SystemEntry{Message: "request rejected", Module: "API"}, raw)
+ if entry.Message != "API 请求被拒绝(POST /api/settings/notify/test,状态 400):Telegram Bot Token 未配置" {
+ t.Fatalf("unexpected message: %q", entry.Message)
+ }
+ if entry.Module != "API" {
+ t.Fatalf("unexpected module: %q", entry.Module)
+ }
+}
diff --git a/internal/logstore/hourly_test.go b/internal/logstore/hourly_test.go
new file mode 100644
index 0000000..5b93140
--- /dev/null
+++ b/internal/logstore/hourly_test.go
@@ -0,0 +1,52 @@
+package logstore
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestHourlyAccessCountsCalendarDay(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "access.log")
+ now := time.Now()
+ todayMorning := time.Date(now.Year(), now.Month(), now.Day(), 8, 15, 0, 0, now.Location())
+ todayNoon := time.Date(now.Year(), now.Month(), now.Day(), 11, 30, 0, 0, now.Location())
+ yesterday := todayMorning.Add(-24 * time.Hour)
+ lines := []string{
+ formatAccessLine(todayMorning, "/"),
+ formatAccessLine(todayMorning.Add(30*time.Minute), "/"),
+ formatAccessLine(todayNoon, "/"),
+ formatAccessLine(yesterday, "/"),
+ }
+ if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got, labels := HourlyAccessCounts(path)
+ if len(got) != 12 || len(labels) != 12 {
+ t.Fatalf("expected 12 buckets, got %d counts / %d labels", len(got), len(labels))
+ }
+ if labels[0] != "00:00" || labels[11] != "22:00" {
+ t.Fatalf("unexpected labels: %v", labels)
+ }
+ sum := 0
+ for _, n := range got {
+ sum += n
+ }
+ if sum != 3 {
+ t.Fatalf("expected 3 requests today, got %d (%v)", sum, got)
+ }
+ currentBucket := now.Hour() / 2
+ for i := currentBucket + 1; i < 12; i++ {
+ if got[i] != 0 {
+ t.Fatalf("future bucket %d should be 0, got %d", i, got[i])
+ }
+ }
+}
+
+func formatAccessLine(at time.Time, path string) string {
+ return at.Format(time.RFC3339) + " app.example.com GET " + path + " 200 0.010 1.2.3.4 127.0.0.1:8080"
+}
diff --git a/internal/logstore/reader.go b/internal/logstore/reader.go
new file mode 100644
index 0000000..4c48c36
--- /dev/null
+++ b/internal/logstore/reader.go
@@ -0,0 +1,423 @@
+package logstore
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "os"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type AccessEntry struct {
+ Time string `json:"time"`
+ Domain string `json:"domain"`
+ ServerPort int `json:"server_port,omitempty"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Status int `json:"status"`
+ ResponseTime float64 `json:"response_time"`
+ ClientIP string `json:"client_ip"`
+ Upstream string `json:"upstream"`
+ RequestLength int64 `json:"request_length,omitempty"`
+ BytesSent int64 `json:"bytes_sent,omitempty"`
+}
+
+type SystemEntry struct {
+ Time string `json:"time"`
+ Level string `json:"level"`
+ Module string `json:"module"`
+ Message string `json:"message"`
+}
+
+var accessRe = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
+var accessReWithBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
+var accessReWithPort = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
+var accessReWithPortAndBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
+
+func ParseAccess(line string) (AccessEntry, bool) {
+ return parseAccess(line)
+}
+
+func ReadAccess(path string, limit int, keyword string, status int) ([]AccessEntry, error) {
+ lines, err := tailLines(path, limit*4)
+ if err != nil {
+ return nil, err
+ }
+ var out []AccessEntry
+ for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+ entry, ok := parseAccess(lines[i])
+ if !ok {
+ continue
+ }
+ if keyword != "" && !strings.Contains(strings.ToLower(entry.Domain+" "+entry.Path), strings.ToLower(keyword)) {
+ continue
+ }
+ if status > 0 && entry.Status != status {
+ continue
+ }
+ out = append(out, entry)
+ }
+ return out, nil
+}
+
+func AccessHostFilter(hosts []string) LineFilter {
+ if len(hosts) == 0 {
+ return nil
+ }
+ endpoints := make([]AccessEndpoint, 0, len(hosts))
+ for _, host := range hosts {
+ endpoints = append(endpoints, AccessEndpoint{Host: host, Port: 0})
+ }
+ return AccessEndpointFilter(endpoints)
+}
+
+type AccessEndpoint struct {
+ Host string
+ Port int
+}
+
+func AccessEndpointFilter(endpoints []AccessEndpoint) LineFilter {
+ if len(endpoints) == 0 {
+ return nil
+ }
+ exact := make(map[string]bool, len(endpoints))
+ hosts := make(map[string]bool, len(endpoints))
+ for _, ep := range endpoints {
+ host := normalizeAccessHost(ep.Host)
+ if host == "" {
+ continue
+ }
+ hosts[host] = true
+ if ep.Port > 0 {
+ exact[endpointMatchKey(host, ep.Port)] = true
+ }
+ }
+ if len(hosts) == 0 {
+ return nil
+ }
+ return func(line string) bool {
+ entry, ok := parseAccess(line)
+ if !ok {
+ return false
+ }
+ host := normalizeAccessHost(entry.Domain)
+ if entry.ServerPort > 0 {
+ return exact[endpointMatchKey(host, entry.ServerPort)]
+ }
+ return hosts[host]
+ }
+}
+
+func parseAccess(line string) (AccessEntry, bool) {
+ line = strings.TrimSpace(line)
+ if m := accessReWithPortAndBytes.FindStringSubmatch(line); len(m) == 12 {
+ status, _ := strconv.Atoi(m[6])
+ rt, _ := strconv.ParseFloat(m[7], 64)
+ port, _ := strconv.Atoi(m[3])
+ reqLen, _ := strconv.ParseInt(m[10], 10, 64)
+ bytesSent, _ := strconv.ParseInt(m[11], 10, 64)
+ return AccessEntry{
+ Time: m[1],
+ Domain: m[2],
+ ServerPort: port,
+ Method: m[4],
+ Path: m[5],
+ Status: status,
+ ResponseTime: rt,
+ ClientIP: m[8],
+ Upstream: m[9],
+ RequestLength: reqLen,
+ BytesSent: bytesSent,
+ }, true
+ }
+ if m := accessReWithPort.FindStringSubmatch(line); len(m) == 10 {
+ status, _ := strconv.Atoi(m[6])
+ rt, _ := strconv.ParseFloat(m[7], 64)
+ port, _ := strconv.Atoi(m[3])
+ return AccessEntry{
+ Time: m[1],
+ Domain: m[2],
+ ServerPort: port,
+ Method: m[4],
+ Path: m[5],
+ Status: status,
+ ResponseTime: rt,
+ ClientIP: m[8],
+ Upstream: m[9],
+ }, true
+ }
+ if m := accessReWithBytes.FindStringSubmatch(line); len(m) == 11 {
+ status, _ := strconv.Atoi(m[5])
+ rt, _ := strconv.ParseFloat(m[6], 64)
+ reqLen, _ := strconv.ParseInt(m[9], 10, 64)
+ bytesSent, _ := strconv.ParseInt(m[10], 10, 64)
+ return AccessEntry{
+ Time: m[1],
+ Domain: m[2],
+ Method: m[3],
+ Path: m[4],
+ Status: status,
+ ResponseTime: rt,
+ ClientIP: m[7],
+ Upstream: m[8],
+ RequestLength: reqLen,
+ BytesSent: bytesSent,
+ }, true
+ }
+ m := accessRe.FindStringSubmatch(line)
+ if len(m) != 9 {
+ return AccessEntry{}, false
+ }
+ status, _ := strconv.Atoi(m[5])
+ rt, _ := strconv.ParseFloat(m[6], 64)
+ return AccessEntry{
+ Time: m[1],
+ Domain: m[2],
+ Method: m[3],
+ Path: m[4],
+ Status: status,
+ ResponseTime: rt,
+ ClientIP: m[7],
+ Upstream: m[8],
+ }, true
+}
+
+func normalizeAccessHost(host string) string {
+ host = strings.ToLower(strings.TrimSpace(host))
+ if i := strings.Index(host, ":"); i > 0 {
+ host = host[:i]
+ }
+ return host
+}
+
+func endpointMatchKey(host string, port int) string {
+ return fmt.Sprintf("%s:%d", normalizeAccessHost(host), port)
+}
+
+func ReadError(path string, limit int, keyword string) ([]string, error) {
+ lines, err := tailLines(path, limit*2)
+ if err != nil {
+ return nil, err
+ }
+ var out []string
+ for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+ line := strings.TrimSpace(lines[i])
+ if line == "" {
+ continue
+ }
+ if keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(keyword)) {
+ continue
+ }
+ out = append(out, line)
+ }
+ return out, nil
+}
+
+func isNoiseSystemEntry(entry SystemEntry) bool {
+ return entry.Module == "HTTP" && strings.EqualFold(entry.Level, "INFO")
+}
+
+func ReadSystem(path string, limit int, level string, keyword string) ([]SystemEntry, error) {
+ lines, err := tailLines(path, limit*20)
+ if err != nil {
+ return nil, err
+ }
+ var out []SystemEntry
+ for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
+ entry, ok := parseSystem(lines[i])
+ if !ok {
+ continue
+ }
+ if isNoiseSystemEntry(entry) {
+ continue
+ }
+ if level != "" && !strings.EqualFold(entry.Level, level) {
+ continue
+ }
+ if keyword != "" && !strings.Contains(strings.ToLower(entry.Message+" "+entry.Module), strings.ToLower(keyword)) {
+ continue
+ }
+ out = append(out, entry)
+ }
+ return out, nil
+}
+
+func parseSystem(line string) (SystemEntry, bool) {
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(line), &raw); err != nil {
+ return SystemEntry{}, false
+ }
+ entry := SystemEntry{
+ Time: asString(raw["time"]),
+ Level: asString(raw["level"]),
+ Message: asString(raw["msg"]),
+ Module: asString(raw["module"]),
+ }
+ if entry.Time == "" {
+ entry.Time = time.Now().UTC().Format(time.RFC3339)
+ }
+ entry = localizeSystemEntry(entry, raw)
+ return entry, entry.Message != ""
+}
+
+const hourlyBucketCount = 12
+
+func isDashboardAccess(entry AccessEntry) bool {
+ switch entry.Path {
+ case "/favicon.ico", "/robots.txt":
+ return false
+ }
+ if strings.HasPrefix(entry.Path, "/fonu-errors/") {
+ return false
+ }
+ return true
+}
+
+func isSameCalendarDay(t, day time.Time) bool {
+ lt := t.In(day.Location())
+ dy, dm, dd := day.Date()
+ ty, tm, td := lt.Date()
+ return ty == dy && tm == dm && td == dd
+}
+
+// HourlyAccessCounts returns today's request counts on a fixed 0–24h axis (12 two-hour buckets).
+// Future buckets (after the current time) stay zero.
+func HourlyAccessCounts(path string) ([]int, []string) {
+ return hourlyAccessCountsForDay(path, time.Now())
+}
+
+func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
+ now := time.Now()
+ currentBucket := now.Hour() / 2
+ if !isSameCalendarDay(day, now) {
+ currentBucket = hourlyBucketCount - 1
+ }
+ counts := make([]int, hourlyBucketCount)
+ labels := calendarHourlyLabels()
+
+ file, err := os.Open(path)
+ if err != nil {
+ return counts, labels
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ entry, ok := parseAccess(scanner.Text())
+ if !ok || !isDashboardAccess(entry) {
+ continue
+ }
+ t, ok := parseAccessTime(entry.Time)
+ if !ok || !isSameCalendarDay(t, day) {
+ continue
+ }
+ bucket := t.In(day.Location()).Hour() / 2
+ if bucket < 0 || bucket > currentBucket {
+ continue
+ }
+ counts[bucket]++
+ }
+ return counts, labels
+}
+
+func calendarHourlyLabels() []string {
+ labels := make([]string, hourlyBucketCount)
+ for i := 0; i < hourlyBucketCount; i++ {
+ labels[i] = fmt.Sprintf("%02d:00", i*2)
+ }
+ return labels
+}
+
+func parseAccessTime(raw string) (time.Time, bool) {
+ raw = strings.TrimSpace(raw)
+ for _, layout := range []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02T15:04:05Z07:00",
+ "2006-01-02T15:04:05Z0700",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ } {
+ if t, err := time.Parse(layout, raw); err == nil {
+ return t, true
+ }
+ }
+ return time.Time{}, false
+}
+
+func isLocalToday(t time.Time, now time.Time) bool {
+ return isSameCalendarDay(t, now)
+}
+
+func CountTodayAccess(path string) (total int, errors int, avgMs float64) {
+ return CountAccessForDay(path, time.Now())
+}
+
+func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs float64) {
+ file, err := os.Open(path)
+ if err != nil {
+ return 0, 0, 0
+ }
+ defer file.Close()
+
+ var sum float64
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ for scanner.Scan() {
+ entry, ok := parseAccess(scanner.Text())
+ if !ok || !isDashboardAccess(entry) {
+ continue
+ }
+ t, ok := parseAccessTime(entry.Time)
+ if !ok || !isSameCalendarDay(t, day) {
+ continue
+ }
+ total++
+ sum += entry.ResponseTime
+ if entry.Status >= 400 {
+ errors++
+ }
+ }
+ if total > 0 {
+ avgMs = sum / float64(total) * 1000
+ }
+ return total, errors, avgMs
+}
+
+func tailLines(path string, max int) ([]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ var lines []string
+ for scanner.Scan() {
+ lines = append(lines, scanner.Text())
+ if len(lines) > max {
+ lines = lines[1:]
+ }
+ }
+ return lines, scanner.Err()
+}
+
+func asString(v any) string {
+ if v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return t
+ default:
+ return ""
+ }
+}
diff --git a/internal/logstore/stream.go b/internal/logstore/stream.go
new file mode 100644
index 0000000..3063b48
--- /dev/null
+++ b/internal/logstore/stream.go
@@ -0,0 +1,78 @@
+package logstore
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "strings"
+ "time"
+)
+
+type LineFilter func(line string) bool
+
+func StreamFile(ctx context.Context, path string, w io.Writer, flush func() error, tail int, filter LineFilter) error {
+ file, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ fmt.Fprintf(w, "event: info\ndata: 日志文件尚未生成\n\n")
+ _ = flush()
+ return nil
+ }
+ return err
+ }
+ defer file.Close()
+
+ if tail > 0 {
+ lines, err := tailLines(path, tail)
+ if err != nil {
+ return err
+ }
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+ if filter != nil && !filter(line) {
+ continue
+ }
+ fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line+"\n"))
+ if err := flush(); err != nil {
+ return err
+ }
+ }
+ }
+
+ if _, err := file.Seek(0, io.SeekEnd); err != nil {
+ return err
+ }
+ reader := bufio.NewReader(file)
+ for {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+ return err
+ }
+ if filter != nil && !filter(strings.TrimRight(line, "\r\n")) {
+ continue
+ }
+ fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line))
+ if err := flush(); err != nil {
+ return err
+ }
+ }
+}
+
+func trimSSE(line string) string {
+ line = line[:len(line)-1]
+ if len(line) > 0 && line[len(line)-1] == '\r' {
+ line = line[:len(line)-1]
+ }
+ return line
+}
diff --git a/internal/logstore/writer.go b/internal/logstore/writer.go
new file mode 100644
index 0000000..80b4c28
--- /dev/null
+++ b/internal/logstore/writer.go
@@ -0,0 +1,22 @@
+package logstore
+
+import (
+ "io"
+ "os"
+ "path/filepath"
+
+ "gopkg.in/natefinch/lumberjack.v2"
+)
+
+func NewAppLogWriter(logsDir string) io.Writer {
+ if err := os.MkdirAll(logsDir, 0o755); err != nil {
+ return os.Stdout
+ }
+ return &lumberjack.Logger{
+ Filename: filepath.Join(logsDir, "app.log"),
+ MaxSize: 20,
+ MaxBackups: 10,
+ MaxAge: 30,
+ Compress: true,
+ }
+}
diff --git a/internal/nginx/assets/429.png b/internal/nginx/assets/429.png
new file mode 100644
index 0000000..ab1071c
Binary files /dev/null and b/internal/nginx/assets/429.png differ
diff --git a/internal/nginx/assets/error.png b/internal/nginx/assets/error.png
new file mode 100644
index 0000000..f68b754
Binary files /dev/null and b/internal/nginx/assets/error.png differ
diff --git a/internal/nginx/certs.go b/internal/nginx/certs.go
new file mode 100644
index 0000000..7a5ec65
--- /dev/null
+++ b/internal/nginx/certs.go
@@ -0,0 +1,164 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func osReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+type CertSource struct {
+ Domains []string
+ CertPath string
+ KeyPath string
+}
+
+func FindCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
+ return findCertificateForHosts(certsDir, hostnames, certs)
+}
+
+func HasCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) bool {
+ return certUsable(findCertificateForHosts(certsDir, hostnames, certs))
+}
+
+func findCertificateForHosts(certsDir string, hostnames []string, certs []CertSource) *certFiles {
+ for _, hostname := range hostnames {
+ for _, source := range certs {
+ if !hostCoveredByCert(hostname, source.Domains) {
+ continue
+ }
+ if cert := certFromSource(source); cert != nil {
+ return cert
+ }
+ for _, domain := range source.Domains {
+ if cert := certAt(certsDir, storageDirName(domain)); cert != nil && certUsable(cert) {
+ return cert
+ }
+ }
+ }
+ if cert := findCertificate(certsDir, hostname); cert != nil && certUsable(cert) {
+ return cert
+ }
+ }
+ return scanCertDirectories(certsDir, hostnames)
+}
+
+func storageDirName(domain string) string {
+ domain = strings.TrimSpace(domain)
+ if strings.HasPrefix(domain, "*.") {
+ return "wildcard." + strings.TrimPrefix(domain, "*.")
+ }
+ return domain
+}
+
+func certFromSource(source CertSource) *certFiles {
+ cert := &certFiles{
+ CertPath: absNginxPath(source.CertPath),
+ KeyPath: absNginxPath(source.KeyPath),
+ }
+ if !certUsable(cert) {
+ return nil
+ }
+ return cert
+}
+
+func hostCoveredByCert(hostname string, domains []string) bool {
+ hostname = strings.ToLower(strings.TrimSpace(hostname))
+ for _, domain := range domains {
+ domain = strings.ToLower(strings.TrimSpace(domain))
+ if domain == "" {
+ continue
+ }
+ if domain == hostname {
+ return true
+ }
+ if strings.HasPrefix(domain, "*.") {
+ suffix := strings.TrimPrefix(domain, "*.")
+ if hostname == suffix || strings.HasSuffix(hostname, "."+suffix) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func scanCertDirectories(certsDir string, hostnames []string) *certFiles {
+ entries, err := osReadDir(certsDir)
+ if err != nil {
+ return nil
+ }
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ pattern := dirNameToPattern(entry.Name())
+ for _, hostname := range hostnames {
+ if !hostCoveredByCert(hostname, []string{pattern, entry.Name()}) {
+ continue
+ }
+ if cert := certAt(certsDir, pattern); cert != nil && certUsable(cert) {
+ return cert
+ }
+ if cert := certAt(certsDir, entry.Name()); cert != nil && certUsable(cert) {
+ return cert
+ }
+ }
+ }
+ return nil
+}
+
+func dirNameToPattern(name string) string {
+ if strings.HasPrefix(name, "wildcard.") {
+ return "*." + strings.TrimPrefix(name, "wildcard.")
+ }
+ return name
+}
+
+func certSummary(certs []CertSource) string {
+ seen := map[string]struct{}{}
+ var domains []string
+ for _, cert := range certs {
+ for _, domain := range cert.Domains {
+ if domain == "" {
+ continue
+ }
+ if _, ok := seen[domain]; ok {
+ continue
+ }
+ seen[domain] = struct{}{}
+ domains = append(domains, domain)
+ }
+ }
+ if len(domains) == 0 {
+ return "无"
+ }
+ return strings.Join(domains, ", ")
+}
+
+func HTTPSCoverageError(hostname string, certs []CertSource) error {
+ return fmt.Errorf("域名 %s 未匹配到可用证书(当前证书覆盖:%s),请检查拼写或先在证书页导入/申请", hostname, certSummary(certs))
+}
+
+func ValidateHTTPSRules(rules []proxy.Rule, certs []CertSource, certsDir string) error {
+ for _, rule := range rules {
+ if !rule.Enabled || !rule.HTTPSEnabled {
+ continue
+ }
+ for _, group := range rule.PortGroups() {
+ cert := findCertificateForHosts(certsDir, group.Hostnames, certs)
+ if certUsable(cert) {
+ continue
+ }
+ if len(group.Hostnames) == 0 {
+ return fmt.Errorf("启用 HTTPS 的规则缺少前端域名")
+ }
+ return HTTPSCoverageError(group.Hostnames[0], certs)
+ }
+ }
+ return nil
+}
diff --git a/internal/nginx/certs_fallback_test.go b/internal/nginx/certs_fallback_test.go
new file mode 100644
index 0000000..002b677
--- /dev/null
+++ b/internal/nginx/certs_fallback_test.go
@@ -0,0 +1,35 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestFindCertificateForHostsFallsBackToDiskWhenDBPathsMissing(t *testing.T) {
+ dir := t.TempDir()
+ certDir := filepath.Join(dir, "certs", "wildcard.example.com")
+ if err := os.MkdirAll(certDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ certPath := filepath.Join(certDir, "fullchain.pem")
+ keyPath := filepath.Join(certDir, "privatekey.pem")
+ if err := os.WriteFile(certPath, []byte("cert"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(keyPath, []byte("key"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cert := findCertificateForHosts(filepath.Join(dir, "certs"), []string{"app.example.com"}, []CertSource{{
+ Domains: []string{"example.com", "*.example.com"},
+ CertPath: "/data/certs/wildcard.example.com/missing.pem",
+ KeyPath: "/data/certs/wildcard.example.com/missing-key.pem",
+ }})
+ if cert == nil || !certUsable(cert) {
+ t.Fatal("expected disk certificate fallback")
+ }
+ if filepath.Clean(cert.CertPath) != filepath.Clean(certPath) {
+ t.Fatalf("unexpected cert path: %s", cert.CertPath)
+ }
+}
diff --git a/internal/nginx/certs_test.go b/internal/nginx/certs_test.go
new file mode 100644
index 0000000..eef4d8a
--- /dev/null
+++ b/internal/nginx/certs_test.go
@@ -0,0 +1,13 @@
+package nginx
+
+import "testing"
+
+func TestHostCoveredByCert(t *testing.T) {
+ domains := []string{"example.com", "*.example.com"}
+ if !hostCoveredByCert("app.example.com", domains) {
+ t.Fatal("expected wildcard to cover subdomain")
+ }
+ if hostCoveredByCert("s.muksi.com", domains) {
+ t.Fatal("expected typo domain to be rejected")
+ }
+}
diff --git a/internal/nginx/cleanup_unix.go b/internal/nginx/cleanup_unix.go
new file mode 100644
index 0000000..6ad3dae
--- /dev/null
+++ b/internal/nginx/cleanup_unix.go
@@ -0,0 +1,32 @@
+//go:build !windows
+
+package nginx
+
+import (
+ "context"
+ "os/exec"
+ "syscall"
+ "time"
+)
+
+func prepareStartPlatform(m *Manager, ctx context.Context) {
+ stopOrphanMasters(ctx, m)
+}
+
+func stopOrphanMasters(ctx context.Context, m *Manager) {
+ pid, err := readPIDFile(m.cfg.NginxPIDFile)
+ if err == nil && isPIDAlive(pid) {
+ _ = terminateProcess(pid, syscall.SIGTERM)
+ waitProcessExit(pid, nginxTerminateWait)
+ if isPIDAlive(pid) {
+ _ = terminateProcess(pid, syscall.SIGKILL)
+ waitProcessExit(pid, time.Second)
+ }
+ }
+ _ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
+ removePIDFile(m.cfg.NginxPIDFile)
+ if _, err := exec.LookPath("pkill"); err == nil {
+ _ = exec.Command("pkill", "-x", "nginx").Run()
+ time.Sleep(200 * time.Millisecond)
+ }
+}
diff --git a/internal/nginx/cleanup_windows.go b/internal/nginx/cleanup_windows.go
new file mode 100644
index 0000000..b564c0c
--- /dev/null
+++ b/internal/nginx/cleanup_windows.go
@@ -0,0 +1,11 @@
+//go:build windows
+
+package nginx
+
+import "context"
+
+func prepareStartPlatform(m *Manager, ctx context.Context) {
+ stopOrphanMasters(m.cfg.NginxDir())
+ _ = m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "")
+ removePIDFile(m.cfg.NginxPIDFile)
+}
diff --git a/internal/nginx/comments.go b/internal/nginx/comments.go
new file mode 100644
index 0000000..44dce75
--- /dev/null
+++ b/internal/nginx/comments.go
@@ -0,0 +1,155 @@
+package nginx
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func writeCommentLine(b *strings.Builder, indent int, text string) {
+ b.WriteString(strings.Repeat(" ", indent) + "# " + text + "\n")
+}
+
+func ruleDisplayName(rule proxy.Rule) string {
+ name := strings.TrimSpace(rule.Name)
+ if name != "" {
+ return name
+ }
+ if host := rule.PrimaryHost(); host != "" {
+ return host
+ }
+ return fmt.Sprintf("规则 #%d", rule.ID)
+}
+
+func writeRulePreambleComments(b *strings.Builder, rule proxy.Rule) {
+ sec := rule.Security.Normalize()
+ writeCommentLine(b, 0, fmt.Sprintf("规则:%s(ID %d)", ruleDisplayName(rule), rule.ID))
+ writeCommentLine(b, 0, fmt.Sprintf("上游:%s", rule.Upstream))
+ if sec.RateLimitEnabled() || sec.ConnLimitEnabled() {
+ writeCommentLine(b, 0, "限流 zone 在 http 块中自动生成,本段 location 内引用")
+ }
+ b.WriteString("\n")
+}
+
+func writeServerBlockHeaderComment(b *strings.Builder, rule proxy.Rule, serverNames string, port int, kind string) {
+ writeCommentLine(b, 0, fmt.Sprintf("规则:%s", ruleDisplayName(rule)))
+ writeCommentLine(b, 0, fmt.Sprintf("域名:%s(端口 %d)", serverNames, port))
+ switch kind {
+ case "https":
+ writeCommentLine(b, 0, "HTTPS 反代")
+ case "redirect":
+ writeCommentLine(b, 0, "HTTP 跳转 HTTPS")
+ default:
+ writeCommentLine(b, 0, "HTTP 反代")
+ }
+ b.WriteString("\n")
+}
+
+func writeListenComments(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
+ proto := "HTTP"
+ if ssl {
+ proto = "HTTPS"
+ }
+ if ipv4 {
+ writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv4 :%d", proto, port))
+ }
+ if ipv6 {
+ writeCommentLine(b, 4, fmt.Sprintf("监听 %s IPv6 :%d", proto, port))
+ }
+}
+
+func writeTLSProtocolComment(b *strings.Builder, sec proxy.SecurityConfig) {
+ if sec.TLSMin13Only {
+ writeCommentLine(b, 4, "TLS:仅 TLS 1.3")
+ } else {
+ writeCommentLine(b, 4, "TLS:TLS 1.2 / TLS 1.3")
+ }
+}
+
+func writeSecurityHeadersComment(b *strings.Builder) {
+ writeCommentLine(b, 4, "安全响应头:HSTS / X-Content-Type-Options / X-Frame-Options")
+}
+
+func writeErrorPagesComment(b *strings.Builder) {
+ writeCommentLine(b, 4, "Fonu 自定义错误页(403/404/429/5xx)")
+}
+
+func writeLocationHeaderComment(b *strings.Builder, upstream string) {
+ writeCommentLine(b, 4, fmt.Sprintf("反代 location / → %s", upstream))
+}
+
+func writeLocationSecurityComments(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
+ sec := rule.Security.Normalize()
+
+ if len(sec.IPBlacklist) > 0 {
+ writeCommentLine(b, 8, "IP 黑名单")
+ }
+ if sec.ChinaOnly {
+ if opts.ChinaCIDRAvailable {
+ writeCommentLine(b, 8, "仅中国大陆:内网 IP 与白名单 IP 段自动豁免")
+ } else {
+ writeCommentLine(b, 8, "仅中国大陆:中国 IP 段未就绪,当前仅允许内网 IP")
+ }
+ }
+ if sec.WhitelistEnabled() {
+ writeCommentLine(b, 8, "IP 白名单模式:仅允许以下 IP,其余拒绝")
+ }
+ if sec.ConnLimitEnabled() {
+ writeCommentLine(b, 8, fmt.Sprintf("连接数限制:每 IP 最多 %d 并发", sec.ConnLimit.Max))
+ }
+ if sec.RateLimitEnabled() {
+ writeCommentLine(b, 8, fmt.Sprintf("请求限流:%d 次/秒,突发 %d(超出返回 429)", sec.RateLimit.Rate, sec.RateLimit.Burst))
+ }
+ if sec.BasicAuthEnabled() {
+ writeCommentLine(b, 8, "Basic Auth:浏览器弹窗认证")
+ }
+}
+
+func writeProxyBodyComments(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
+ writeCommentLine(b, 8, "反向代理与 WebSocket 头")
+ if sec.ProxyHostUpstream {
+ writeCommentLine(b, 8, "Host 头:使用上游地址($proxy_host)")
+ }
+ if strings.HasPrefix(upstream, "https://") {
+ if sec.ProxySSLVerifyOff {
+ writeCommentLine(b, 8, "上游 HTTPS:跳过证书校验")
+ } else {
+ writeCommentLine(b, 8, "上游 HTTPS:启用 SNI")
+ }
+ }
+}
+
+func writeLimitZoneComments(b *strings.Builder, rule proxy.Rule) {
+ sec := rule.Security.Normalize()
+ if sec.RateLimitEnabled() {
+ writeCommentLine(b, 4, fmt.Sprintf("规则 %s:请求限流 zone(%d 次/秒)", ruleDisplayName(rule), sec.RateLimit.Rate))
+ }
+ if sec.ConnLimitEnabled() {
+ writeCommentLine(b, 4, fmt.Sprintf("规则 %s:连接数 zone(每 IP %d 并发)", ruleDisplayName(rule), sec.ConnLimit.Max))
+ }
+}
+
+func writeGlobalAllowListComment(b *strings.Builder) {
+ writeCommentLine(b, 4, "全局 IP 白名单(含内网段默认放行,优先于黑名单,可豁免仅中国大陆限制)")
+}
+
+func writeGlobalDenyListComment(b *strings.Builder) {
+ writeCommentLine(b, 4, "全局 IP 黑名单(系统设置,优先于所有规则)")
+}
+
+func writeRealIPComment(b *strings.Builder, cfg TrustedProxyConfig) {
+ if len(cfg.effectiveCIDRs()) == 0 {
+ writeCommentLine(b, 4, "未启用信任代理:客户端 IP 取 $remote_addr")
+ return
+ }
+ writeCommentLine(b, 4, fmt.Sprintf("信任代理:从 %s 解析真实客户端 IP", cfg.effectiveHeader()))
+}
+
+func writeChinaGeoComment(b *strings.Builder) {
+ writeCommentLine(b, 4, "仅中国大陆:内网 IP 判定与中国 IP 段库")
+}
+
+func writeChinaBypassComment(b *strings.Builder, rule proxy.Rule) {
+ writeCommentLine(b, 4, fmt.Sprintf("规则 %s:仅中国大陆 — 白名单 IP 豁免段", ruleDisplayName(rule)))
+}
diff --git a/internal/nginx/config_validate.go b/internal/nginx/config_validate.go
new file mode 100644
index 0000000..d2e5bb0
--- /dev/null
+++ b/internal/nginx/config_validate.go
@@ -0,0 +1,75 @@
+package nginx
+
+import (
+ "fmt"
+ "strings"
+)
+
+func certUsable(cert *certFiles) bool {
+ if cert == nil {
+ return false
+ }
+ if strings.TrimSpace(cert.CertPath) == "" || strings.TrimSpace(cert.KeyPath) == "" {
+ return false
+ }
+ return fileExists(cert.CertPath) && fileExists(cert.KeyPath)
+}
+
+func assertValidSSLBlocks(content string) error {
+ inServer := false
+ depth := 0
+ serverSSL := false
+ serverCert := false
+
+ for _, raw := range strings.Split(content, "\n") {
+ line := strings.TrimSpace(raw)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ if !inServer {
+ if strings.HasPrefix(line, "server") && strings.Contains(line, "{") {
+ inServer = true
+ depth = braceDelta(line)
+ serverSSL = false
+ serverCert = false
+ }
+ continue
+ }
+
+ depth += braceDelta(line)
+ if isSSLListenLine(line) {
+ serverSSL = true
+ }
+ if isSSLCertificateLine(line) {
+ serverCert = true
+ }
+
+ if depth <= 0 {
+ if serverSSL && !serverCert {
+ return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
+ }
+ inServer = false
+ }
+ }
+
+ if inServer && serverSSL && !serverCert {
+ return fmt.Errorf("检测到未配置证书的 HTTPS 监听,请为域名申请或导入证书,或关闭 HTTPS")
+ }
+ return nil
+}
+
+func braceDelta(line string) int {
+ return strings.Count(line, "{") - strings.Count(line, "}")
+}
+
+func isSSLListenLine(line string) bool {
+ if !strings.HasPrefix(line, "listen ") {
+ return false
+ }
+ return strings.Contains(line, " ssl;") || strings.HasSuffix(line, " ssl")
+}
+
+func isSSLCertificateLine(line string) bool {
+ return strings.HasPrefix(line, "ssl_certificate ") && !strings.HasPrefix(line, "ssl_certificate_key ")
+}
diff --git a/internal/nginx/config_validate_test.go b/internal/nginx/config_validate_test.go
new file mode 100644
index 0000000..fe39093
--- /dev/null
+++ b/internal/nginx/config_validate_test.go
@@ -0,0 +1,38 @@
+package nginx
+
+import "testing"
+
+func TestAssertValidSSLBlocksRejectsListenSSLWithoutCertificate(t *testing.T) {
+ content := `http {
+ server {
+ listen 8017 ssl;
+ listen [::]:8017 ssl;
+ server_name app.example.com;
+ location / {
+ proxy_pass http://127.0.0.1:1;
+ }
+ }
+}
+`
+ if err := assertValidSSLBlocks(content); err == nil {
+ t.Fatal("expected invalid ssl block to fail validation")
+ }
+}
+
+func TestAssertValidSSLBlocksAcceptsCertificateAfterLocation(t *testing.T) {
+ content := `http {
+ server {
+ listen 8017 ssl;
+ server_name app.example.com;
+ location / {
+ proxy_pass http://127.0.0.1:1;
+ }
+ ssl_certificate /data/certs/wildcard.example.com/fullchain.pem;
+ ssl_certificate_key /data/certs/wildcard.example.com/privatekey.pem;
+ }
+}
+`
+ if err := assertValidSSLBlocks(content); err != nil {
+ t.Fatalf("expected config with certificate directives to pass: %v", err)
+ }
+}
diff --git a/internal/nginx/custom.go b/internal/nginx/custom.go
new file mode 100644
index 0000000..c16c1b1
--- /dev/null
+++ b/internal/nginx/custom.go
@@ -0,0 +1,259 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+const maxCustomBackups = 10
+
+type BackupEntry struct {
+ Name string `json:"name"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func EnsureCustomDirs(cfg config.Config) error {
+ for _, dir := range []string{
+ cfg.NginxCustomDir(),
+ cfg.NginxRulesDir(),
+ cfg.NginxGlobalBackupsDir(),
+ } {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func ReadGlobalCustom(cfg config.Config) (string, error) {
+ data, err := os.ReadFile(cfg.NginxGlobalCustomPath())
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", err
+ }
+ return string(data), nil
+}
+
+func ReadRuleCustom(cfg config.Config, ruleID int64) (string, error) {
+ data, err := os.ReadFile(cfg.NginxRuleCustomPath(ruleID))
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", err
+ }
+ return string(data), nil
+}
+
+func ruleCustomContent(cfg config.Config, ruleID int64, opts GenerateOptions) string {
+ if opts.RuleCustomOverrides != nil {
+ if override, ok := opts.RuleCustomOverrides[ruleID]; ok {
+ return override
+ }
+ }
+ content, _ := ReadRuleCustom(cfg, ruleID)
+ return content
+}
+
+func globalCustomPath(cfg config.Config, opts GenerateOptions) string {
+ if opts.GlobalCustomPathOverride != "" {
+ return opts.GlobalCustomPathOverride
+ }
+ return cfg.NginxGlobalCustomPath()
+}
+
+func globalCustomShouldInclude(cfg config.Config, opts GenerateOptions) bool {
+ if strings.TrimSpace(opts.GlobalCustomOverride) != "" {
+ return true
+ }
+ if opts.GlobalCustomPathOverride != "" {
+ data, err := os.ReadFile(opts.GlobalCustomPathOverride)
+ return err == nil && strings.TrimSpace(string(data)) != ""
+ }
+ content, _ := ReadGlobalCustom(cfg)
+ return strings.TrimSpace(content) != ""
+}
+
+func BackupFile(srcPath, backupDir string) (string, error) {
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return "", err
+ }
+ data, err := os.ReadFile(srcPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", nil
+ }
+ return "", err
+ }
+ name := time.Now().UTC().Format("20060102T150405.000Z") + ".conf"
+ dst := filepath.Join(backupDir, name)
+ if err := os.WriteFile(dst, data, 0o644); err != nil {
+ return "", err
+ }
+ if err := pruneBackups(backupDir, maxCustomBackups); err != nil {
+ return "", err
+ }
+ return name, nil
+}
+
+func ListBackups(backupDir string) ([]BackupEntry, error) {
+ if err := os.MkdirAll(backupDir, 0o755); err != nil {
+ return nil, err
+ }
+ entries, err := os.ReadDir(backupDir)
+ if err != nil {
+ return nil, err
+ }
+ var out []BackupEntry
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".conf") {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ continue
+ }
+ out = append(out, BackupEntry{Name: entry.Name(), CreatedAt: info.ModTime().UTC()})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].Name > out[j].Name
+ })
+ return out, nil
+}
+
+func RestoreBackup(backupDir, backupName, targetPath string) error {
+ src := filepath.Join(backupDir, backupName)
+ if _, err := os.Stat(src); err != nil {
+ return fmt.Errorf("备份不存在")
+ }
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
+ return err
+ }
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(targetPath, data, 0o644)
+}
+
+func WriteGlobalCustom(cfg config.Config, content string) error {
+ if err := EnsureCustomDirs(cfg); err != nil {
+ return err
+ }
+ return os.WriteFile(cfg.NginxGlobalCustomPath(), []byte(content), 0o644)
+}
+
+func WriteRuleCustom(cfg config.Config, ruleID int64, content string) error {
+ if err := os.MkdirAll(cfg.NginxRulesDir(), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(cfg.NginxRuleCustomPath(ruleID), []byte(content), 0o644)
+}
+
+func RemoveRuleCustom(cfg config.Config, ruleID int64) error {
+ path := cfg.NginxRuleCustomPath(ruleID)
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ backupDir := cfg.NginxRuleBackupsDir(ruleID)
+ _ = os.RemoveAll(backupDir)
+ return nil
+}
+
+func SaveGlobalCustomWithBackup(cfg config.Config, content string) (string, error) {
+ path := cfg.NginxGlobalCustomPath()
+ var backupName string
+ if _, err := os.Stat(path); err == nil {
+ name, err := BackupFile(path, cfg.NginxGlobalBackupsDir())
+ if err != nil {
+ return "", err
+ }
+ backupName = name
+ }
+ if err := WriteGlobalCustom(cfg, content); err != nil {
+ return "", err
+ }
+ return backupName, nil
+}
+
+func SaveRuleCustomWithBackup(cfg config.Config, ruleID int64, content string) (string, error) {
+ path := cfg.NginxRuleCustomPath(ruleID)
+ var backupName string
+ if _, err := os.Stat(path); err == nil {
+ name, err := BackupFile(path, cfg.NginxRuleBackupsDir(ruleID))
+ if err != nil {
+ return "", err
+ }
+ backupName = name
+ }
+ if err := WriteRuleCustom(cfg, ruleID, content); err != nil {
+ return "", err
+ }
+ return backupName, nil
+}
+
+func pruneBackups(dir string, keep int) error {
+ entries, err := ListBackups(dir)
+ if err != nil {
+ return err
+ }
+ for i := keep; i < len(entries); i++ {
+ _ = os.Remove(filepath.Join(dir, entries[i].Name))
+ }
+ return nil
+}
+
+func DefaultGlobalHTTPSnippet() string {
+ return `# 全局 http 块片段(自动生成)
+# 上传大小限制(全局默认 50M,单条规则可在 server 块覆盖)
+client_max_body_size 50m;
+`
+}
+
+func GenerateGlobalFramework(cfg config.Config) string {
+ return fmt.Sprintf(`# 以下部分由 Fonu 自动生成,请勿在此编辑
+
+# 主进程
+worker_processes auto;
+# 错误日志
+error_log %s warn;
+# PID 文件
+pid %s;
+
+# 事件模型
+events {
+ worker_connections 1024;
+}
+
+http {
+ # MIME 类型
+ include %s;
+ default_type application/octet-stream;
+
+ # 访问日志格式与路径
+ log_format fonu_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
+ access_log %s fonu_access;
+
+ # 传输优化
+ sendfile on;
+ keepalive_timeout 65;
+
+ # WebSocket 升级映射
+ # map $http_upgrade $connection_upgrade { ... }
+
+ # limit_req_zone / geo / real_ip 等由规则与系统设置动态生成
+ # 全局自定义片段插入在上述动态块之后、各 server {} 之前
+}
+`, absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")),
+ absNginxPath(cfg.NginxPIDFile),
+ absNginxPath(cfg.NginxMimeTypes),
+ absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")))
+}
diff --git a/internal/nginx/custom_test.go b/internal/nginx/custom_test.go
new file mode 100644
index 0000000..7beea67
--- /dev/null
+++ b/internal/nginx/custom_test.go
@@ -0,0 +1,136 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateRuleBlocks(t *testing.T) {
+ cfg := config.Config{DataDir: t.TempDir()}
+ rule := proxy.Rule{
+ ID: 1,
+ Upstream: "http://127.0.0.1:8080",
+ ListenPort: 8011,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "example.com"}},
+ HTTPSEnabled: false,
+ Enabled: true,
+ }
+ out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "server_name example.com") {
+ t.Fatalf("missing server_name: %s", out)
+ }
+ if !strings.Contains(out, "proxy_pass http://127.0.0.1:8080") {
+ t.Fatalf("missing proxy_pass: %s", out)
+ }
+}
+
+func TestGenerateWithCustomRuleAndGlobal(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Config{DataDir: dir}
+ if err := EnsureCustomDirs(cfg); err != nil {
+ t.Fatal(err)
+ }
+ customGlobal := "gzip on;\n"
+ if err := WriteGlobalCustom(cfg, customGlobal); err != nil {
+ t.Fatal(err)
+ }
+ customRule := "server {\n listen 8011;\n server_name custom.test;\n return 444;\n}\n"
+ if err := WriteRuleCustom(cfg, 7, customRule); err != nil {
+ t.Fatal(err)
+ }
+
+ rules := []proxy.Rule{{
+ ID: 7,
+ Upstream: "http://127.0.0.1:1",
+ ListenPort: 8011,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "ignored.test"}},
+ Enabled: true,
+ NginxMode: "custom",
+ }}
+ out, err := Generate(cfg, rules, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out, "include "+strings.ReplaceAll(absNginxPath(cfg.NginxGlobalCustomPath()), "\\", "/")) &&
+ !strings.Contains(out, absNginxPath(cfg.NginxGlobalCustomPath())) {
+ t.Fatalf("missing global include: %s", out)
+ }
+ if !strings.Contains(out, "server_name custom.test") {
+ t.Fatalf("missing custom rule block: %s", out)
+ }
+ if strings.Contains(out, "ignored.test") {
+ t.Fatalf("auto-generated host leaked into custom mode: %s", out)
+ }
+}
+
+func TestBackupFileKeepsRecent(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "src.conf")
+ if err := os.WriteFile(src, []byte("v1"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ backupDir := filepath.Join(dir, "backups")
+ if _, err := BackupFile(src, backupDir); err != nil {
+ t.Fatal(err)
+ }
+ time.Sleep(2 * time.Millisecond)
+ if err := os.WriteFile(src, []byte("v2"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := BackupFile(src, backupDir); err != nil {
+ t.Fatal(err)
+ }
+ entries, err := ListBackups(backupDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("backups=%d want 2", len(entries))
+ }
+ if err := RestoreBackup(backupDir, entries[1].Name, src); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "v1" {
+ t.Fatalf("restored=%q want v1", string(data))
+ }
+}
+
+func TestSaveRuleCustomWithBackup(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Config{DataDir: dir}
+ if err := WriteRuleCustom(cfg, 3, "first"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SaveRuleCustomWithBackup(cfg, 3, "second"); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(cfg.NginxRuleCustomPath(3))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != "second" {
+ t.Fatalf("content=%q", string(data))
+ }
+ backups, err := ListBackups(cfg.NginxRuleBackupsDir(3))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(backups) != 1 {
+ t.Fatalf("backups=%d want 1", len(backups))
+ }
+}
diff --git a/internal/nginx/error_assets_embed.go b/internal/nginx/error_assets_embed.go
new file mode 100644
index 0000000..41f88d2
--- /dev/null
+++ b/internal/nginx/error_assets_embed.go
@@ -0,0 +1,8 @@
+package nginx
+
+import "embed"
+
+// Bundled for Docker/runtime when web/assets is not on disk.
+//
+//go:embed assets/error.png assets/429.png
+var errorAssetFS embed.FS
diff --git a/internal/nginx/errors.go b/internal/nginx/errors.go
new file mode 100644
index 0000000..23a6243
--- /dev/null
+++ b/internal/nginx/errors.go
@@ -0,0 +1,146 @@
+package nginx
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func EnsureErrorPages(cfg config.Config) error {
+ dir := cfg.ErrorsDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ ensureNginxReadable(dir, true)
+
+ pages := []struct {
+ file, sprite, bgPos, title, msg string
+ code int
+ }{
+ {"403.html", "s403", "0% 0%", "访问被拒绝", "你没有权限访问此资源。", 403},
+ {"404.html", "s404", "100% 0%", "页面不存在", "请求的地址不存在或已被移除。", 404},
+ {"500.html", "s500", "0% 100%", "服务异常", "服务器遇到错误,请稍后再试。", 500},
+ {"503.html", "s503", "100% 100%", "服务维护中", "服务暂时不可用,请稍后再试。", 503},
+ {"429.html", "429", "", "请求过于频繁", "你的请求次数过多,请稍后再试。", 429},
+ }
+ for _, p := range pages {
+ html := errorPageHTML(p.code, p.title, p.msg, p.sprite, p.bgPos)
+ target := filepath.Join(dir, p.file)
+ if err := os.WriteFile(target, []byte(html), 0o644); err != nil {
+ return err
+ }
+ ensureNginxReadable(target, false)
+ }
+
+ for _, name := range []string{"error.png", "429.png"} {
+ if err := ensureErrorAsset(dir, name); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func ensureErrorAsset(dir, name string) error {
+ target := filepath.Join(dir, name)
+ if src := findErrorAsset(name); src != "" {
+ if err := copyFileIfChanged(src, target); err != nil {
+ return err
+ }
+ } else if err := writeEmbeddedErrorAsset(name, target); err != nil {
+ return err
+ }
+ if err := verifyErrorAsset(target); err != nil {
+ return err
+ }
+ ensureNginxReadable(target, false)
+ return nil
+}
+
+func verifyErrorAsset(path string) error {
+ info, err := os.Stat(path)
+ if err != nil {
+ return fmt.Errorf("error asset missing after write: %s: %w", path, err)
+ }
+ if info.Size() == 0 {
+ return fmt.Errorf("error asset is empty: %s", path)
+ }
+ return nil
+}
+
+func writeEmbeddedErrorAsset(name, target string) error {
+ data, err := errorAssetFS.ReadFile("assets/" + name)
+ if err != nil {
+ return fmt.Errorf("embedded error asset %s: %w", name, err)
+ }
+ return os.WriteFile(target, data, 0o644)
+}
+
+func findErrorAsset(name string) string {
+ candidates := []string{
+ filepath.Join("web", "assets", "image", name),
+ filepath.Join("/app", "web", "assets", "image", name),
+ filepath.Join("internal", "nginx", "assets", name),
+ }
+ for _, c := range candidates {
+ if _, err := os.Stat(c); err == nil {
+ return c
+ }
+ }
+ return ""
+}
+
+func copyFileIfChanged(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ return err
+}
+
+func errorPageHTML(code int, title, message, sprite, bgPos string) string {
+ var imgBlock string
+ if sprite == "429" {
+ imgBlock = `
`
+ } else {
+ imgBlock = fmt.Sprintf(``, code)
+ }
+ return fmt.Sprintf(`
+
+
+
+
+%d - %s
+
+
+
+
+%s
+%d
+%s
+%s
+
+
+`, code, title, bgPos, imgBlock, code, title, message)
+}
diff --git a/internal/nginx/errors_test.go b/internal/nginx/errors_test.go
new file mode 100644
index 0000000..67aa8d9
--- /dev/null
+++ b/internal/nginx/errors_test.go
@@ -0,0 +1,45 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func TestEnsureErrorPagesWritesAssets(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Config{DataDir: dir}
+
+ if err := EnsureErrorPages(cfg); err != nil {
+ t.Fatal(err)
+ }
+
+ errorsDir := cfg.ErrorsDir()
+ for _, name := range []string{"403.html", "404.html", "error.png", "429.png"} {
+ path := filepath.Join(errorsDir, name)
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("missing %s: %v", name, err)
+ }
+ if info.Size() == 0 {
+ t.Fatalf("%s is empty", name)
+ }
+ }
+
+ html, err := os.ReadFile(filepath.Join(errorsDir, "403.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(html)
+ for _, want := range []string{
+ "/fonu-errors/error.png",
+ "background:url(/fonu-errors/error.png)",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("403.html missing %q:\n%s", want, body)
+ }
+ }
+}
diff --git a/internal/nginx/fileperm.go b/internal/nginx/fileperm.go
new file mode 100644
index 0000000..6fc4cb0
--- /dev/null
+++ b/internal/nginx/fileperm.go
@@ -0,0 +1,21 @@
+package nginx
+
+import (
+ "os"
+ "runtime"
+)
+
+// Debian/Ubuntu nginx worker user.
+const nginxWorkerUID = 33
+const nginxWorkerGID = 33
+
+func ensureNginxReadable(path string, dir bool) {
+ mode := os.FileMode(0o644)
+ if dir {
+ mode = 0o755
+ }
+ _ = os.Chmod(path, mode)
+ if runtime.GOOS == "linux" {
+ _ = os.Chown(path, nginxWorkerUID, nginxWorkerGID)
+ }
+}
diff --git a/internal/nginx/generate.go b/internal/nginx/generate.go
new file mode 100644
index 0000000..48e1018
--- /dev/null
+++ b/internal/nginx/generate.go
@@ -0,0 +1,273 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func Generate(cfg config.Config, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) {
+ var b strings.Builder
+
+ b.WriteString(`worker_processes auto;
+error_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "error.log")) + ` warn;
+pid ` + absNginxPath(cfg.NginxPIDFile) + `;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include ` + absNginxPath(cfg.NginxMimeTypes) + `;
+ default_type application/octet-stream;
+
+ log_format fonu_access '$time_iso8601 $host $server_port $request_method $request_uri $status $request_time $remote_addr $upstream_addr $request_length $bytes_sent';
+ access_log ` + absNginxPath(filepath.Join(cfg.LogsDir(), "access.log")) + ` fonu_access;
+
+ sendfile on;
+ keepalive_timeout 65;
+
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ limit_req_status 429;
+ limit_conn_status 503;
+
+`)
+
+ writeRealIPDirectives(&b, opts.TrustedProxy)
+ writeGlobalAllowList(&b, opts.GlobalIPWhitelist)
+ writeGlobalDenyList(&b, opts.GlobalIPBlacklist)
+ writeLimitZones(&b, rules)
+ writeChinaGeoBlocks(&b, cfg, opts, rules)
+ writeChinaOnlyBypassGeo(&b, rules, opts)
+ writeGlobalCustomInclude(&b, cfg, opts)
+
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+ if rule.NginxMode == "custom" {
+ if err := writeCustomRuleBlocks(&b, cfg, rule.ID, opts); err != nil {
+ return "", err
+ }
+ continue
+ }
+ if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil {
+ return "", err
+ }
+ }
+
+ b.WriteString("}\n")
+ content := b.String()
+ if err := assertValidSSLBlocks(content); err != nil {
+ return "", err
+ }
+ return content, nil
+}
+
+func GenerateRuleBlocks(cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) (string, error) {
+ var b strings.Builder
+ writeRulePreambleComments(&b, rule)
+ if err := writeRuleBlocks(&b, cfg, rule, certs, opts); err != nil {
+ return "", err
+ }
+ content := b.String()
+ if err := assertValidSSLBlocks(content); err != nil {
+ return "", err
+ }
+ return content, nil
+}
+
+func writeGlobalCustomInclude(b *strings.Builder, cfg config.Config, opts GenerateOptions) {
+ if strings.TrimSpace(opts.GlobalCustomOverride) != "" {
+ writeIndentedHTTPSnippet(b, opts.GlobalCustomOverride)
+ return
+ }
+ if globalCustomShouldInclude(cfg, opts) {
+ path := globalCustomPath(cfg, opts)
+ b.WriteString(fmt.Sprintf(" include %s;\n\n", absNginxPath(path)))
+ return
+ }
+ writeIndentedHTTPSnippet(b, DefaultGlobalHTTPSnippet())
+}
+
+func writeIndentedHTTPSnippet(b *strings.Builder, content string) {
+ for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
+ b.WriteString(" " + line + "\n")
+ }
+ b.WriteString("\n")
+}
+
+func writeCustomRuleBlocks(b *strings.Builder, cfg config.Config, ruleID int64, opts GenerateOptions) error {
+ content := strings.TrimSpace(ruleCustomContent(cfg, ruleID, opts))
+ if content == "" {
+ return fmt.Errorf("规则 %d 的自定义 Nginx 配置为空", ruleID)
+ }
+ if !strings.HasSuffix(content, "\n") {
+ content += "\n"
+ }
+ b.WriteString(content)
+ return nil
+}
+
+func writeRuleBlocks(b *strings.Builder, cfg config.Config, rule proxy.Rule, certs []CertSource, opts GenerateOptions) error {
+ for _, group := range rule.PortGroups() {
+ if len(group.Hostnames) == 0 {
+ continue
+ }
+ serverNames := strings.Join(group.Hostnames, " ")
+ cert := findCertificateForHosts(cfg.CertsDir(), group.Hostnames, certs)
+ useHTTPS := rule.HTTPSEnabled && certUsable(cert)
+
+ wroteBlock := false
+ if useHTTPS {
+ if !appendSSLServerBlock(b, cfg, group.Port, serverNames, cert, rule, opts) {
+ return fmt.Errorf("HTTPS 证书文件不可用,请重新导入证书或关闭 HTTPS")
+ }
+ wroteBlock = true
+ }
+ if rule.HTTPRedirect && useHTTPS {
+ writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "redirect")
+ b.WriteString("server {\n")
+ writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
+ writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
+ b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames))
+ writeErrorPages(b, cfg)
+ writeCommentLine(b, 4, "301 跳转至 HTTPS")
+ b.WriteString(" return 301 https://$host:$server_port$request_uri;\n")
+ b.WriteString("}\n")
+ wroteBlock = true
+ }
+
+ if !wroteBlock {
+ writeServerBlockHeaderComment(b, rule, serverNames, group.Port, "http")
+ b.WriteString("server {\n")
+ writeListenComments(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
+ writeListenDirectives(b, group.Port, false, rule.ListenIPv4, rule.ListenIPv6)
+ b.WriteString(fmt.Sprintf(" server_name %s;\n", serverNames))
+ writeErrorPages(b, cfg)
+ writeLocationWithSecurity(b, cfg, rule, opts)
+ b.WriteString("}\n")
+ }
+ }
+ return nil
+}
+
+func appendSSLServerBlock(b *strings.Builder, cfg config.Config, port int, serverNames string, cert *certFiles, rule proxy.Rule, opts GenerateOptions) bool {
+ if !certUsable(cert) {
+ return false
+ }
+ var block strings.Builder
+ writeServerBlockHeaderComment(&block, rule, serverNames, port, "https")
+ block.WriteString("server {\n")
+ writeListenComments(&block, port, true, rule.ListenIPv4, rule.ListenIPv6)
+ writeListenDirectives(&block, port, true, rule.ListenIPv4, rule.ListenIPv6)
+ block.WriteString(fmt.Sprintf(" server_name %s;\n\n", serverNames))
+ writeCommentLine(&block, 4, "SSL 证书")
+ block.WriteString(fmt.Sprintf(" ssl_certificate %s;\n", absNginxPath(cert.CertPath)))
+ block.WriteString(fmt.Sprintf(" ssl_certificate_key %s;\n", absNginxPath(cert.KeyPath)))
+ writeTLSProtocols(&block, rule.Security)
+ writeServerSecurityHeaders(&block, rule.Security, true)
+ writeErrorPages(&block, cfg)
+ writeLocationWithSecurity(&block, cfg, rule, opts)
+ block.WriteString("}\n")
+ b.WriteString(block.String())
+ return true
+}
+
+func writeLocationWithSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) {
+ writeLocationHeaderComment(b, rule.Upstream)
+ b.WriteString(" location / {\n")
+ writeLocationSecurity(b, cfg, rule, opts)
+ writeProxyLocationBody(b, rule.Upstream, rule.Security)
+ b.WriteString(" }\n")
+}
+
+func writeProxyLocationBody(b *strings.Builder, upstream string, sec proxy.SecurityConfig) {
+ sec = sec.Normalize()
+ writeProxyBodyComments(b, upstream, sec)
+ hostHeader := "$host"
+ if sec.ProxyHostUpstream {
+ hostHeader = "$proxy_host"
+ }
+ sslBlock := ""
+ if strings.HasPrefix(upstream, "https://") {
+ if sec.ProxySSLVerifyOff {
+ sslBlock = ` proxy_ssl_verify off;
+ proxy_ssl_server_name on;
+`
+ } else {
+ sslBlock = ` proxy_ssl_server_name on;
+`
+ }
+ }
+ b.WriteString(fmt.Sprintf(`%s proxy_pass %s;
+ proxy_http_version 1.1;
+ proxy_set_header Host %s;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header X-Real-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+`, sslBlock, upstream, hostHeader))
+}
+
+func writeListenDirectives(b *strings.Builder, port int, ssl bool, ipv4 bool, ipv6 bool) {
+ sslSuffix := ""
+ if ssl {
+ sslSuffix = " ssl"
+ }
+ if ipv4 {
+ b.WriteString(fmt.Sprintf(" listen %d%s;\n", port, sslSuffix))
+ }
+ if ipv6 {
+ b.WriteString(fmt.Sprintf(" listen [::]:%d%s;\n", port, sslSuffix))
+ }
+}
+
+type certFiles struct {
+ CertPath string
+ KeyPath string
+}
+
+func findCertificate(certsDir, domain string) *certFiles {
+ parts := strings.Split(domain, ".")
+ for i := 0; i < len(parts)-1; i++ {
+ wildcard := "*." + strings.Join(parts[i:], ".")
+ if c := certAt(certsDir, wildcard); c != nil {
+ return c
+ }
+ }
+ if c := certAt(certsDir, domain); c != nil {
+ return c
+ }
+ return nil
+}
+
+func certAt(certsDir, name string) *certFiles {
+ safeName := strings.ReplaceAll(name, "*", "wildcard")
+ dir := filepath.Join(certsDir, safeName)
+ certPath := filepath.Join(dir, "fullchain.pem")
+ keyPath := filepath.Join(dir, "privatekey.pem")
+ if fileExists(certPath) && fileExists(keyPath) {
+ return &certFiles{CertPath: absNginxPath(certPath), KeyPath: absNginxPath(keyPath)}
+ }
+ return nil
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
diff --git a/internal/nginx/generate_china_bypass_test.go b/internal/nginx/generate_china_bypass_test.go
new file mode 100644
index 0000000..9002d36
--- /dev/null
+++ b/internal/nginx/generate_china_bypass_test.go
@@ -0,0 +1,56 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestChinaOnlyBypassGeoWithoutUserWhitelist(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf")
+ if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx.pid"),
+ NginxMimeTypes: filepath.Join(dir, "mime.types"),
+ }
+ rules := []proxy.Rule{{
+ ID: 3,
+ ListenPort: 8080,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://127.0.0.1:3000",
+ Enabled: true,
+ HTTPSEnabled: false,
+ Security: proxy.SecurityConfig{ChinaOnly: true},
+ }}
+
+ content, err := Generate(cfg, rules, nil, GenerateOptions{ChinaCIDRAvailable: true})
+ if err != nil {
+ t.Fatalf("generate: %v", err)
+ }
+ for _, want := range []string{
+ "geo $fonu_client_ip $fonu_rule_3_china_bypass",
+ "192.168.0.0/16 1;",
+ "10.0.0.0/8 1;",
+ "if ($fonu_rule_3_china_bypass = 1)",
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("missing %q in:\n%s", want, content)
+ }
+ }
+}
diff --git a/internal/nginx/generate_china_cidr_path_test.go b/internal/nginx/generate_china_cidr_path_test.go
new file mode 100644
index 0000000..c45b094
--- /dev/null
+++ b/internal/nginx/generate_china_cidr_path_test.go
@@ -0,0 +1,59 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateChinaCIDRUsesFinalPathWhenOverrideCleared(t *testing.T) {
+ dir := t.TempDir()
+ cidrDir := filepath.Join(dir, "nginx")
+ if err := os.MkdirAll(cidrDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ finalPath := filepath.Join(cidrDir, "china_cidr.conf")
+ if err := os.WriteFile(finalPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx.pid"),
+ NginxMimeTypes: filepath.Join(dir, "mime.types"),
+ }
+ if err := os.WriteFile(cfg.NginxMimeTypes, []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ rules := []proxy.Rule{{
+ ID: 1,
+ ListenPort: 8080,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://127.0.0.1:3000",
+ Enabled: true,
+ Security: proxy.SecurityConfig{ChinaOnly: true},
+ }}
+
+ opts := GenerateOptions{
+ ChinaCIDRAvailable: true,
+ ChinaCIDRPathOverride: filepath.Join(cidrDir, "china_cidr.conf.tmp"),
+ }
+ opts.ChinaCIDRPathOverride = ""
+
+ content, err := Generate(cfg, rules, nil, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(content, "china_cidr.conf.tmp") {
+ t.Fatalf("expected final china cidr path, got tmp reference:\n%s", content)
+ }
+ if !strings.Contains(content, "china_cidr.conf") {
+ t.Fatalf("missing china_cidr.conf include:\n%s", content)
+ }
+}
diff --git a/internal/nginx/generate_comments_test.go b/internal/nginx/generate_comments_test.go
new file mode 100644
index 0000000..0d1b61e
--- /dev/null
+++ b/internal/nginx/generate_comments_test.go
@@ -0,0 +1,49 @@
+package nginx
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateRuleBlocksIncludesComments(t *testing.T) {
+ cfg := config.Config{DataDir: t.TempDir()}
+ rule := proxy.Rule{
+ ID: 3,
+ Name: "fonu",
+ Upstream: "http://127.0.0.1:8080",
+ ListenPort: 8011,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "s.example.com"}},
+ HTTPSEnabled: false,
+ Enabled: true,
+ Security: proxy.SecurityConfig{
+ IPBlacklist: []string{"192.168.8.2"},
+ IPWhitelistMode: true,
+ IPWhitelist: []string{"192.168.8.1"},
+ RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20},
+ ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20},
+ },
+ }
+ out, err := GenerateRuleBlocks(cfg, rule, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "# 规则:fonu",
+ "# 上游:http://127.0.0.1:8080",
+ "# 限流 zone 在 http 块中自动生成",
+ "# IP 黑名单",
+ "# IP 白名单模式",
+ "# 请求限流:10 次/秒,突发 20",
+ "# 连接数限制:每 IP 最多 20 并发",
+ "# 反代 location /",
+ "# 反向代理与 WebSocket 头",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing comment %q in:\n%s", want, out)
+ }
+ }
+}
diff --git a/internal/nginx/generate_https_no_cert_test.go b/internal/nginx/generate_https_no_cert_test.go
new file mode 100644
index 0000000..bad142b
--- /dev/null
+++ b/internal/nginx/generate_https_no_cert_test.go
@@ -0,0 +1,38 @@
+package nginx
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateHTTPSWithoutCertUsesHTTPOnly(t *testing.T) {
+ cfg := config.Config{
+ DataDir: t.TempDir(),
+ NginxPIDFile: t.TempDir() + "/nginx.pid",
+ NginxMimeTypes: t.TempDir() + "/mime.types",
+ }
+ rules := []proxy.Rule{{
+ ListenPort: 8011,
+ ListenIPv4: true,
+ ListenIPv6: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://192.168.8.3:6893",
+ HTTPSEnabled: true,
+ HTTPRedirect: true,
+ Enabled: true,
+ }}
+
+ content, err := Generate(cfg, rules, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate failed: %v", err)
+ }
+ if strings.Contains(content, " ssl") {
+ t.Fatalf("expected no ssl listen without certificate:\n%s", content)
+ }
+ if !strings.Contains(content, "proxy_pass http://192.168.8.3:6893") {
+ t.Fatalf("expected http proxy fallback:\n%s", content)
+ }
+}
diff --git a/internal/nginx/generate_https_with_cert_test.go b/internal/nginx/generate_https_with_cert_test.go
new file mode 100644
index 0000000..3852b33
--- /dev/null
+++ b/internal/nginx/generate_https_with_cert_test.go
@@ -0,0 +1,56 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateHTTPSWithCertIncludesCertificateDirectives(t *testing.T) {
+ dir := t.TempDir()
+ certDir := filepath.Join(dir, "certs", "wildcard.example.com")
+ if err := os.MkdirAll(certDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx.pid"),
+ NginxMimeTypes: filepath.Join(dir, "mime.types"),
+ }
+ rules := []proxy.Rule{{
+ ListenPort: 8011,
+ ListenIPv4: true,
+ ListenIPv6: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://192.168.8.3:6893",
+ HTTPSEnabled: true,
+ HTTPRedirect: true,
+ Enabled: true,
+ }}
+
+ content, err := Generate(cfg, rules, []CertSource{{
+ Domains: []string{"*.example.com", "example.com"},
+ CertPath: filepath.Join(certDir, "fullchain.pem"),
+ KeyPath: filepath.Join(certDir, "privatekey.pem"),
+ }}, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate failed: %v", err)
+ }
+ if !strings.Contains(content, "listen 8011 ssl;") {
+ t.Fatalf("expected ssl listen:\n%s", content)
+ }
+ if !strings.Contains(content, "ssl_certificate") || !strings.Contains(content, "ssl_certificate_key") {
+ t.Fatalf("expected certificate directives:\n%s", content)
+ }
+}
diff --git a/internal/nginx/generate_multi_test.go b/internal/nginx/generate_multi_test.go
new file mode 100644
index 0000000..3d6039b
--- /dev/null
+++ b/internal/nginx/generate_multi_test.go
@@ -0,0 +1,51 @@
+package nginx
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateMultiHostCustomPort(t *testing.T) {
+ cfg := config.Config{
+ DataDir: t.TempDir(),
+ NginxPIDFile: t.TempDir() + "/nginx.pid",
+ NginxMimeTypes: t.TempDir() + "/mime.types",
+ }
+ port6893 := 6893
+ rules := []proxy.Rule{{
+ ID: 1,
+ Upstream: "http://192.168.8.3:6893",
+ ListenPort: 8011,
+ ListenIPv4: true,
+ ListenIPv6: true,
+ Hosts: []proxy.Host{
+ {Hostname: "1.example.com"},
+ {Hostname: "2.example.com"},
+ {Hostname: "example.org"},
+ {Hostname: "example.com", ListenPort: &port6893},
+ },
+ HTTPSEnabled: false,
+ HTTPRedirect: false,
+ Enabled: true,
+ }}
+
+ content, err := Generate(cfg, rules, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate failed: %v", err)
+ }
+ for _, want := range []string{
+ "listen 8011;",
+ "listen [::]:8011;",
+ "server_name 1.example.com 2.example.com example.org",
+ "listen 6893;",
+ "listen [::]:6893;",
+ "server_name example.com",
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("missing %q in generated config:\n%s", want, content)
+ }
+ }
+}
diff --git a/internal/nginx/generate_order_test.go b/internal/nginx/generate_order_test.go
new file mode 100644
index 0000000..36010fb
--- /dev/null
+++ b/internal/nginx/generate_order_test.go
@@ -0,0 +1,61 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateHTTPSBlockBeforeHTTPRedirect(t *testing.T) {
+ dir := t.TempDir()
+ certDir := filepath.Join(dir, "certs", "wildcard.example.com")
+ if err := os.MkdirAll(certDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(certDir, "fullchain.pem"), []byte("cert"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(certDir, "privatekey.pem"), []byte("key"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
+ NginxMimeTypes: filepath.Join(dir, "mime.types"),
+ }
+ rules := []proxy.Rule{{
+ ListenPort: 8017,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://127.0.0.1:1",
+ HTTPSEnabled: true,
+ HTTPRedirect: true,
+ Enabled: true,
+ }}
+ certs := []CertSource{{
+ Domains: []string{"*.example.com"},
+ CertPath: filepath.Join(certDir, "fullchain.pem"),
+ KeyPath: filepath.Join(certDir, "privatekey.pem"),
+ }}
+
+ content, err := Generate(cfg, rules, certs, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate: %v", err)
+ }
+ sslIdx := strings.Index(content, "listen 8017 ssl;")
+ redirectIdx := strings.Index(content, "return 301 https://")
+ if sslIdx < 0 || redirectIdx < 0 {
+ t.Fatalf("missing expected directives:\n%s", content)
+ }
+ if sslIdx > redirectIdx {
+ t.Fatalf("expected ssl server block before http redirect:\n%s", content)
+ }
+}
diff --git a/internal/nginx/generate_security_test.go b/internal/nginx/generate_security_test.go
new file mode 100644
index 0000000..df84d19
--- /dev/null
+++ b/internal/nginx/generate_security_test.go
@@ -0,0 +1,95 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateSecurityDirectives(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "mime.types"), []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cidrPath := filepath.Join(dir, "nginx", "china_cidr.conf")
+ if err := os.MkdirAll(filepath.Dir(cidrPath), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(cidrPath, []byte("1.0.0.0/8 1;\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx.pid"),
+ NginxMimeTypes: filepath.Join(dir, "mime.types"),
+ }
+ rules := []proxy.Rule{{
+ ID: 7,
+ ListenPort: 8080,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ Upstream: "http://127.0.0.1:3000",
+ Enabled: true,
+ HTTPSEnabled: false,
+ Security: proxy.SecurityConfig{
+ IPBlacklist: []string{"1.2.3.4", "10.0.0.0/8"},
+ IPWhitelistMode: true,
+ IPWhitelist: []string{"192.168.1.0/24"},
+ ChinaOnly: true,
+ BasicAuth: &proxy.BasicAuthConfig{
+ Enabled: true,
+ Username: "user",
+ PasswordHash: "$2a$10$abcdefghijklmnopqrstuv",
+ },
+ RateLimit: &proxy.RateLimitConfig{Enabled: true, Rate: 10, Burst: 20},
+ ConnLimit: &proxy.ConnLimitConfig{Enabled: true, Max: 20},
+ },
+ }}
+
+ opts := GenerateOptions{
+ GlobalIPWhitelist: []string{"203.0.113.10"},
+ GlobalIPBlacklist: []string{"8.8.8.8"},
+ ChinaCIDRAvailable: true,
+ }
+ content, err := Generate(cfg, rules, nil, opts)
+ if err != nil {
+ t.Fatalf("generate: %v", err)
+ }
+ checks := []string{
+ "allow 10.0.0.0/8;",
+ "allow 203.0.113.10;",
+ "deny 8.8.8.8;",
+ "limit_req_zone $binary_remote_addr zone=fonu_rule_7_req",
+ "limit_conn_zone $binary_remote_addr zone=fonu_rule_7_conn",
+ "deny 1.2.3.4;",
+ "geo $fonu_client_ip $fonu_is_private",
+ "geo $fonu_client_ip $fonu_rule_7_china_bypass",
+ "192.168.0.0/16 1;",
+ "192.168.1.0/24 1;",
+ "203.0.113.10 1;",
+ "set $fonu_china_block 0;",
+ "if ($fonu_is_private = 1)",
+ "if ($fonu_rule_7_china_bypass = 1)",
+ "allow 192.168.1.0/24;",
+ "deny all;",
+ "auth_basic",
+ "limit_req zone=fonu_rule_7_req burst=20",
+ "limit_conn fonu_rule_7_conn 20",
+ "error_page 403 /fonu-errors/403.html",
+ "location = /fonu-errors/error.png",
+ "location = /fonu-errors/429.png",
+ "X-Forwarded-Host $host",
+ "X-Forwarded-Port $server_port",
+ "X-Real-Proto $scheme",
+ }
+ for _, want := range checks {
+ if !strings.Contains(content, want) {
+ t.Fatalf("missing %q in:\n%s", want, content)
+ }
+ }
+}
diff --git a/internal/nginx/generate_seed_mix_test.go b/internal/nginx/generate_seed_mix_test.go
new file mode 100644
index 0000000..3d6618b
--- /dev/null
+++ b/internal/nginx/generate_seed_mix_test.go
@@ -0,0 +1,45 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateMixedRulesWithoutCerts(t *testing.T) {
+ dir := t.TempDir()
+ mimePath := filepath.Join(dir, "mime.types")
+ if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxPIDFile: filepath.Join(dir, "nginx.pid"),
+ NginxMimeTypes: mimePath,
+ }
+
+ rules := []proxy.Rule{
+ {
+ ID: 1, Upstream: "http://192.168.1.10:5666", ListenPort: 443, ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
+ HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
+ },
+ {
+ ID: 2, Upstream: "http://192.168.8.3:6893", ListenPort: 8011, ListenIPv4: true, ListenIPv6: true,
+ Hosts: []proxy.Host{{Hostname: "app.example.com"}},
+ HTTPSEnabled: true, HTTPRedirect: true, Enabled: true,
+ },
+ }
+
+ content, err := Generate(cfg, rules, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate failed: %v", err)
+ }
+ if err := assertValidSSLBlocks(content); err != nil {
+ t.Fatalf("invalid ssl blocks: %v\n%s", err, content)
+ }
+}
diff --git a/internal/nginx/generate_test.go b/internal/nginx/generate_test.go
new file mode 100644
index 0000000..a63c38e
--- /dev/null
+++ b/internal/nginx/generate_test.go
@@ -0,0 +1,44 @@
+package nginx
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestGenerateIncludesWebSocketAndProxy(t *testing.T) {
+ cfg := config.Config{
+ DataDir: t.TempDir(),
+ NginxPIDFile: t.TempDir() + "/nginx.pid",
+ NginxMimeTypes: t.TempDir() + "/mime.types",
+ }
+ rules := []proxy.Rule{{
+ ID: 1,
+ Upstream: "http://192.168.1.10:5666",
+ ListenPort: 80,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
+ HTTPSEnabled: false,
+ HTTPRedirect: false,
+ Enabled: true,
+ }}
+
+ content, err := Generate(cfg, rules, nil, GenerateOptions{})
+ if err != nil {
+ t.Fatalf("generate failed: %v", err)
+ }
+ for _, want := range []string{
+ "client_max_body_size 50m;",
+ "listen 80;",
+ "server_name nas.example.com",
+ "proxy_pass http://192.168.1.10:5666",
+ "proxy_set_header Upgrade $http_upgrade",
+ "proxy_buffering off",
+ } {
+ if !strings.Contains(content, want) {
+ t.Fatalf("missing %q in generated config", want)
+ }
+ }
+}
diff --git a/internal/nginx/global_framework_test.go b/internal/nginx/global_framework_test.go
new file mode 100644
index 0000000..2d8bb43
--- /dev/null
+++ b/internal/nginx/global_framework_test.go
@@ -0,0 +1,37 @@
+package nginx
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func TestGenerateGlobalFrameworkIncludesComments(t *testing.T) {
+ cfg := config.Config{DataDir: t.TempDir()}
+ out := GenerateGlobalFramework(cfg)
+ for _, want := range []string{
+ "# 以下部分由 Fonu 自动生成",
+ "# 主进程",
+ "# MIME 类型",
+ "# 访问日志格式与路径",
+ "# 传输优化",
+ } {
+ if !strings.Contains(out, want) {
+ t.Fatalf("missing comment %q in:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "client_max_body_size") {
+ t.Fatalf("framework should not include client_max_body_size:\n%s", out)
+ }
+}
+
+func TestDefaultGlobalHTTPSnippet(t *testing.T) {
+ out := DefaultGlobalHTTPSnippet()
+ if !strings.Contains(out, "client_max_body_size 50m;") {
+ t.Fatalf("missing upload limit in snippet:\n%s", out)
+ }
+ if !strings.Contains(out, "# 上传大小限制") {
+ t.Fatalf("missing comment in snippet:\n%s", out)
+ }
+}
diff --git a/internal/nginx/htpasswd.go b/internal/nginx/htpasswd.go
new file mode 100644
index 0000000..65e407a
--- /dev/null
+++ b/internal/nginx/htpasswd.go
@@ -0,0 +1,70 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+// nginxHtpasswdHash normalizes bcrypt hashes for nginx/apr auth_basic.
+// Go's bcrypt uses the $2a$ prefix; some nginx builds only accept $2y$.
+func nginxHtpasswdHash(hash string) string {
+ if strings.HasPrefix(hash, "$2a$") {
+ return "$2y$" + hash[4:]
+ }
+ return hash
+}
+
+func HtpasswdDir(cfg config.Config) string {
+ return filepath.Join(cfg.NginxDir(), "htpasswd")
+}
+
+func HtpasswdPath(cfg config.Config, ruleID int64) string {
+ return filepath.Join(HtpasswdDir(cfg), fmt.Sprintf("rule_%d.conf", ruleID))
+}
+
+func SyncHtpasswdFiles(cfg config.Config, rules []proxy.Rule) error {
+ dir := HtpasswdDir(cfg)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ ensureNginxReadable(dir, true)
+
+ active := map[int64]bool{}
+ for _, rule := range rules {
+ path := HtpasswdPath(cfg, rule.ID)
+ if rule.Security.BasicAuthEnabled() {
+ active[rule.ID] = true
+ line := fmt.Sprintf("%s:%s\n", rule.Security.BasicAuth.Username, nginxHtpasswdHash(rule.Security.BasicAuth.PasswordHash))
+ if err := os.WriteFile(path, []byte(line), 0o644); err != nil {
+ return err
+ }
+ ensureNginxReadable(path, false)
+ }
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ var id int64
+ if _, err := fmt.Sscanf(entry.Name(), "rule_%d.conf", &id); err != nil {
+ continue
+ }
+ if !active[id] {
+ _ = os.Remove(filepath.Join(dir, entry.Name()))
+ }
+ }
+ return nil
+}
diff --git a/internal/nginx/htpasswd_hash_test.go b/internal/nginx/htpasswd_hash_test.go
new file mode 100644
index 0000000..a245e10
--- /dev/null
+++ b/internal/nginx/htpasswd_hash_test.go
@@ -0,0 +1,19 @@
+package nginx
+
+import "testing"
+
+func TestNginxHtpasswdHashNormalizesBcryptPrefix(t *testing.T) {
+ in := "$2a$10$abcdefghijklmnopqrstuv"
+ got := nginxHtpasswdHash(in)
+ want := "$2y$10$abcdefghijklmnopqrstuv"
+ if got != want {
+ t.Fatalf("got %q want %q", got, want)
+ }
+}
+
+func TestNginxHtpasswdHashLeavesOtherFormats(t *testing.T) {
+ in := "$apr1$abc123"
+ if got := nginxHtpasswdHash(in); got != in {
+ t.Fatalf("got %q want %q", got, in)
+ }
+}
diff --git a/internal/nginx/htpasswd_test.go b/internal/nginx/htpasswd_test.go
new file mode 100644
index 0000000..dbe3fed
--- /dev/null
+++ b/internal/nginx/htpasswd_test.go
@@ -0,0 +1,60 @@
+package nginx
+
+import (
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestSyncHtpasswdFilesWritesBasicAuth(t *testing.T) {
+ cfg := config.Config{DataDir: t.TempDir()}
+ rules := []proxy.Rule{{
+ ID: 3,
+ Enabled: true,
+ Security: proxy.SecurityConfig{
+ BasicAuth: &proxy.BasicAuthConfig{
+ Enabled: true,
+ Username: "admin",
+ PasswordHash: "$2a$10$abcdefghijklmnopqrstuvwx.yz012345678901234567890",
+ },
+ },
+ }}
+
+ if err := SyncHtpasswdFiles(cfg, rules); err != nil {
+ t.Fatalf("sync htpasswd: %v", err)
+ }
+
+ path := HtpasswdPath(cfg, 3)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("read htpasswd: %v", err)
+ }
+ if !strings.HasPrefix(string(data), "admin:$2y$10$") {
+ t.Fatalf("unexpected htpasswd content: %q", data)
+ }
+ if runtime.GOOS == "linux" {
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat htpasswd: %v", err)
+ }
+ if info.Mode().Perm() != 0o644 {
+ t.Fatalf("expected htpasswd mode 0644, got %o", info.Mode().Perm())
+ }
+ }
+
+ stripped := rules[0].Security.ForAPI()
+ rules[0].Security = stripped
+ if rules[0].Security.BasicAuthEnabled() {
+ t.Fatal("ForAPI should strip password hash for API responses")
+ }
+ if err := SyncHtpasswdFiles(cfg, rules); err != nil {
+ t.Fatalf("sync stripped htpasswd: %v", err)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("expected htpasswd removed when hash stripped, stat err=%v", err)
+ }
+}
diff --git a/internal/nginx/nginx.go b/internal/nginx/nginx.go
new file mode 100644
index 0000000..149b8c5
--- /dev/null
+++ b/internal/nginx/nginx.go
@@ -0,0 +1,417 @@
+package nginx
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+const (
+ nginxCmdTimeout = 8 * time.Second
+ nginxStartWait = 5 * time.Second
+ nginxTerminateWait = 3 * time.Second
+)
+
+type Manager struct {
+ cfg config.Config
+ logger *slog.Logger
+ opts GenerateOptions
+}
+
+type ApplyResult struct {
+ Reloaded bool
+ Message string
+}
+
+func NewManager(cfg config.Config, logger *slog.Logger) *Manager {
+ return &Manager{cfg: cfg, logger: logger}
+}
+
+func (m *Manager) SetGenerateOptions(opts GenerateOptions) {
+ m.opts = opts
+}
+
+func (m *Manager) CurrentOptions() GenerateOptions {
+ return m.opts
+}
+
+func (m *Manager) EnsureDirs() error {
+ for _, dir := range []string{m.cfg.NginxDir(), m.cfg.LogsDir(), m.cfg.CertsDir()} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+ workDirs := []string{
+ "logs",
+ "client_body_temp",
+ "proxy_temp",
+ "fastcgi_temp",
+ "uwsgi_temp",
+ "scgi_temp",
+ filepath.Join("temp", "client_body_temp"),
+ filepath.Join("temp", "proxy_temp"),
+ filepath.Join("temp", "fastcgi_temp"),
+ filepath.Join("temp", "uwsgi_temp"),
+ filepath.Join("temp", "scgi_temp"),
+ }
+ for _, name := range workDirs {
+ if err := os.MkdirAll(filepath.Join(m.cfg.NginxDir(), name), 0o755); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (m *Manager) nginxGlobalArgs() []string {
+ prefix := absNginxPath(m.cfg.NginxDir())
+ if !strings.HasSuffix(prefix, "/") {
+ prefix += "/"
+ }
+ errorLog := absNginxPath(filepath.Join(m.cfg.LogsDir(), "error.log"))
+ return []string{"-p", prefix, "-e", errorLog}
+}
+
+func (m *Manager) available() bool {
+ _, err := exec.LookPath(m.cfg.NginxBin)
+ return err == nil
+}
+
+func (m *Manager) Apply(ctx context.Context, rules []proxy.Rule, certs []CertSource) (ApplyResult, error) {
+ if err := m.EnsureDirs(); err != nil {
+ return ApplyResult{}, err
+ }
+
+ if err := EnsureErrorPages(m.cfg); err != nil {
+ return ApplyResult{}, err
+ }
+ if err := EnsureCustomDirs(m.cfg); err != nil {
+ return ApplyResult{}, err
+ }
+ if err := SyncHtpasswdFiles(m.cfg, rules); err != nil {
+ return ApplyResult{}, err
+ }
+
+ opts := m.opts
+ // Path override is only for pre-rename validation during China CIDR updates.
+ opts.ChinaCIDRPathOverride = ""
+ opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
+
+ content, err := Generate(m.cfg, rules, certs, opts)
+ if err != nil {
+ return ApplyResult{}, err
+ }
+
+ tmpPath := m.cfg.NginxConfigPath() + ".tmp"
+ if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
+ return ApplyResult{}, err
+ }
+
+ if err := ValidateConfigSyntax(content); err != nil {
+ _ = os.Remove(tmpPath)
+ return ApplyResult{}, err
+ }
+ if m.available() {
+ if err := m.validate(ctx, tmpPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return ApplyResult{}, err
+ }
+ } else {
+ m.logger.Warn("nginx binary not found, applied syntax-only validation", "bin", m.cfg.NginxBin)
+ }
+
+ currentPath := m.cfg.NginxConfigPath()
+ backupPath := currentPath + ".bak"
+ if _, err := os.Stat(currentPath); err == nil {
+ if err := copyFile(currentPath, backupPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return ApplyResult{}, err
+ }
+ }
+
+ if err := os.Rename(tmpPath, currentPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return ApplyResult{}, err
+ }
+
+ if !m.available() {
+ return ApplyResult{Message: "Nginx 未安装,配置已保存"}, nil
+ }
+
+ running, err := m.isRunning()
+ if err != nil {
+ return ApplyResult{}, err
+ }
+
+ if !running {
+ if err := m.start(ctx); err != nil {
+ return ApplyResult{}, err
+ }
+ return ApplyResult{Reloaded: true, Message: "Nginx 已启动"}, nil
+ }
+
+ if err := m.reload(ctx); err != nil {
+ m.logger.Warn("nginx reload failed, trying restart", "error", err)
+ if err := m.forceRestart(ctx); err != nil {
+ return ApplyResult{}, err
+ }
+ return ApplyResult{Reloaded: true, Message: "Nginx 已重新启动"}, nil
+ }
+ return ApplyResult{Reloaded: true, Message: "Nginx 已重载"}, nil
+}
+
+func (m *Manager) ValidateOnly(ctx context.Context, rules []proxy.Rule, certs []CertSource) error {
+ return m.ValidateOnlyWithOptions(ctx, rules, certs, m.opts)
+}
+
+func (m *Manager) ValidateOnlyWithOptions(ctx context.Context, rules []proxy.Rule, certs []CertSource, opts GenerateOptions) error {
+ if opts.ChinaCIDRPathOverride != "" {
+ opts.ChinaCIDRAvailable = true
+ } else {
+ opts.ChinaCIDRAvailable = chinaCIDRExists(m.cfg)
+ }
+ content, err := Generate(m.cfg, rules, certs, opts)
+ if err != nil {
+ return err
+ }
+ if err := ValidateConfigSyntax(content); err != nil {
+ return err
+ }
+ if !m.available() {
+ m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
+ return nil
+ }
+ tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
+ if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
+ return err
+ }
+ defer os.Remove(tmpPath)
+ return m.validate(ctx, tmpPath)
+}
+
+func (m *Manager) validate(ctx context.Context, configPath string) error {
+ tmpErr := filepath.Join(m.cfg.NginxDir(), "validate.error.log")
+ args := []string{"-e", absNginxPath(tmpErr), "-t", "-c", configPath}
+ err := m.runNginx(ctx, args, "Nginx 配置校验失败")
+ _ = os.Remove(tmpErr)
+ return err
+}
+
+func (m *Manager) start(ctx context.Context) error {
+ running, err := m.isRunning()
+ if err != nil {
+ return err
+ }
+ if running {
+ return m.reload(ctx)
+ }
+ if m.trySilentReload(ctx) {
+ m.logger.Info("nginx reloaded existing master")
+ return nil
+ }
+ prepareStartPlatform(m, ctx)
+ if err := m.runNginxStart(ctx, []string{"-c", m.cfg.NginxConfigPath()}, "Nginx 启动失败"); err != nil {
+ if strings.Contains(err.Error(), "conflicting server name") && m.trySilentReload(ctx) {
+ m.logger.Warn("nginx start skipped duplicate master, reloaded instead")
+ return nil
+ }
+ return err
+ }
+ m.logger.Info("nginx started")
+ return nil
+}
+
+func (m *Manager) trySilentReload(ctx context.Context) bool {
+ ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
+ defer cancel()
+
+ fullArgs := append(m.nginxGlobalArgs(), "-c", m.cfg.NginxConfigPath(), "-s", "reload")
+ cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
+ return cmd.Run() == nil
+}
+
+func (m *Manager) reload(ctx context.Context) error {
+ pid, err := readPIDFile(m.cfg.NginxPIDFile)
+ if err == nil && isPIDAlive(pid) {
+ if err := reloadProcess(pid); err == nil {
+ m.logger.Info("nginx reloaded")
+ return nil
+ }
+ m.logger.Warn("nginx signal reload failed, falling back to cli", "pid", pid, "error", err)
+ } else if err != nil && !os.IsNotExist(err) {
+ removePIDFile(m.cfg.NginxPIDFile)
+ }
+
+ if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "reload"}, "Nginx 重载失败"); err != nil {
+ return err
+ }
+ m.logger.Info("nginx reloaded")
+ return nil
+}
+
+func (m *Manager) forceRestart(ctx context.Context) error {
+ m.terminateMaster()
+ removePIDFile(m.cfg.NginxPIDFile)
+ prepareStartPlatform(m, ctx)
+ return m.start(ctx)
+}
+
+func (m *Manager) terminateMaster() {
+ pid, err := readPIDFile(m.cfg.NginxPIDFile)
+ if err != nil || !isPIDAlive(pid) {
+ return
+ }
+ _ = terminateProcess(pid, syscall.SIGTERM)
+ waitProcessExit(pid, nginxTerminateWait)
+ if isPIDAlive(pid) {
+ _ = terminateProcess(pid, syscall.SIGKILL)
+ waitProcessExit(pid, time.Second)
+ }
+}
+
+func (m *Manager) Stop(ctx context.Context) error {
+ if !m.isRunningQuick() {
+ return nil
+ }
+ if err := m.runNginx(ctx, []string{"-c", m.cfg.NginxConfigPath(), "-s", "quit"}, "Nginx 停止失败"); err != nil {
+ return err
+ }
+ m.logger.Info("nginx stopped")
+ return nil
+}
+
+func (m *Manager) runNginx(ctx context.Context, args []string, prefix string) error {
+ ctx, cancel := context.WithTimeout(ctx, nginxCmdTimeout)
+ defer cancel()
+
+ fullArgs := append(m.nginxGlobalArgs(), args...)
+ cmd := exec.CommandContext(ctx, m.cfg.NginxBin, fullArgs...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = err.Error()
+ }
+ if ctx.Err() == context.DeadlineExceeded {
+ msg = "操作超时"
+ }
+ m.logger.Error("nginx command failed", "args", strings.Join(fullArgs, " "), "error", msg)
+ return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
+ }
+ return nil
+}
+
+// runNginxStart launches nginx without waiting for the master process to exit.
+// On Windows the master stays in the foreground and would block cmd.Run().
+func (m *Manager) runNginxStart(ctx context.Context, args []string, prefix string) error {
+ fullArgs := append(m.nginxGlobalArgs(), args...)
+ cmd := exec.Command(m.cfg.NginxBin, fullArgs...)
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("%s:%s", prefix, err.Error())
+ }
+
+ waitDone := make(chan error, 1)
+ go func() { waitDone <- cmd.Wait() }()
+
+ deadline := time.Now().Add(nginxStartWait)
+ for time.Now().Before(deadline) {
+ if running, err := m.isRunning(); err == nil && running {
+ return nil
+ }
+ select {
+ case err := <-waitDone:
+ if running, checkErr := m.isRunning(); checkErr == nil && running {
+ return nil
+ }
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" && err != nil {
+ msg = err.Error()
+ }
+ if msg == "" {
+ msg = "进程已退出"
+ }
+ m.logger.Error("nginx start failed", "args", strings.Join(fullArgs, " "), "error", msg)
+ return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
+ default:
+ }
+ if ctx.Err() != nil {
+ return fmt.Errorf("%s:%s", prefix, "操作超时")
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+ if running, err := m.isRunning(); err == nil && running {
+ return nil
+ }
+ msg := strings.TrimSpace(stderr.String())
+ if msg == "" {
+ msg = "未检测到运行中的进程"
+ }
+ return fmt.Errorf("%s:%s", prefix, sanitizeNginxError(msg))
+}
+
+func (m *Manager) isRunning() (bool, error) {
+ pid, err := readPIDFile(m.cfg.NginxPIDFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false, nil
+ }
+ removePIDFile(m.cfg.NginxPIDFile)
+ return false, nil
+ }
+ if !isPIDAlive(pid) {
+ removePIDFile(m.cfg.NginxPIDFile)
+ return false, nil
+ }
+ return true, nil
+}
+
+func (m *Manager) isRunningQuick() bool {
+ ok, _ := m.isRunning()
+ return ok
+}
+
+func (m *Manager) ValidateContent(ctx context.Context, content string) error {
+ if err := ValidateConfigSyntax(content); err != nil {
+ return err
+ }
+ if !m.available() {
+ m.logger.Warn("nginx binary not found, syntax-only validation", "bin", m.cfg.NginxBin)
+ return nil
+ }
+ tmpPath := filepath.Join(m.cfg.NginxDir(), "validate.tmp.conf")
+ if err := os.WriteFile(tmpPath, []byte(content), 0o644); err != nil {
+ return err
+ }
+ defer os.Remove(tmpPath)
+ return m.validate(ctx, tmpPath)
+}
+
+func ChinaCIDRExists(cfg config.Config) bool {
+ return chinaCIDRExists(cfg)
+}
+
+func copyFile(src, dst string) error {
+ data, err := os.ReadFile(src)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(dst, data, 0o644)
+}
+
+func sanitizeNginxError(msg string) string {
+ msg = strings.ReplaceAll(msg, "\r\n", " ")
+ msg = strings.ReplaceAll(msg, "\n", " ")
+ return strings.TrimSpace(msg)
+}
diff --git a/internal/nginx/nginx_integration_test.go b/internal/nginx/nginx_integration_test.go
new file mode 100644
index 0000000..509d7c5
--- /dev/null
+++ b/internal/nginx/nginx_integration_test.go
@@ -0,0 +1,58 @@
+package nginx
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestApplyWithNginxIfAvailable(t *testing.T) {
+ if _, err := exec.LookPath("nginx"); err != nil {
+ t.Skip("nginx not installed")
+ }
+
+ dir := t.TempDir()
+ mimePath := filepath.Join(dir, "mime.types")
+ if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatalf("write mime.types: %v", err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxBin: "nginx",
+ NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
+ NginxMimeTypes: mimePath,
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ mgr := NewManager(cfg, logger)
+
+ rules := []proxy.Rule{{
+ ID: 1,
+ Upstream: "http://127.0.0.1:65535",
+ ListenPort: 18080,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
+ HTTPSEnabled: false,
+ HTTPRedirect: false,
+ Enabled: true,
+ }}
+
+ if _, err := mgr.Apply(context.Background(), rules, nil); err != nil {
+ t.Fatalf("apply failed: %v", err)
+ }
+ defer mgr.Stop(context.Background())
+
+ tmp := filepath.Join(dir, "nginx", "bad.conf")
+ if err := os.WriteFile(tmp, []byte("this is not valid nginx config"), 0o644); err != nil {
+ t.Fatalf("write bad config: %v", err)
+ }
+ if err := mgr.validate(context.Background(), tmp); err == nil {
+ t.Fatal("expected invalid config to fail validation")
+ }
+}
diff --git a/internal/nginx/nginx_unavailable_test.go b/internal/nginx/nginx_unavailable_test.go
new file mode 100644
index 0000000..953ce3f
--- /dev/null
+++ b/internal/nginx/nginx_unavailable_test.go
@@ -0,0 +1,50 @@
+package nginx
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestApplyWithoutNginxBinary(t *testing.T) {
+ dir := t.TempDir()
+ mimePath := filepath.Join(dir, "mime.types")
+ if err := os.WriteFile(mimePath, []byte("types { text/html html; }\n"), 0o644); err != nil {
+ t.Fatalf("write mime.types: %v", err)
+ }
+
+ cfg := config.Config{
+ DataDir: dir,
+ NginxBin: "fonu-missing-nginx-binary",
+ NginxPIDFile: filepath.Join(dir, "nginx", "nginx.pid"),
+ NginxMimeTypes: mimePath,
+ }
+ logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
+ mgr := NewManager(cfg, logger)
+
+ rules := []proxy.Rule{{
+ ID: 1,
+ Upstream: "http://127.0.0.1:65535",
+ ListenPort: 8011,
+ ListenIPv4: true,
+ Hosts: []proxy.Host{{Hostname: "nas.example.com"}},
+ HTTPSEnabled: false,
+ Enabled: true,
+ }}
+
+ result, err := mgr.Apply(context.Background(), rules, nil)
+ if err != nil {
+ t.Fatalf("apply failed: %v", err)
+ }
+ if result.Message == "" {
+ t.Fatal("expected skip message")
+ }
+ if _, err := os.Stat(cfg.NginxConfigPath()); err != nil {
+ t.Fatalf("config not written: %v", err)
+ }
+}
diff --git a/internal/nginx/paths.go b/internal/nginx/paths.go
new file mode 100644
index 0000000..8e515d1
--- /dev/null
+++ b/internal/nginx/paths.go
@@ -0,0 +1,14 @@
+package nginx
+
+import "path/filepath"
+
+func absNginxPath(path string) string {
+ if path == "" {
+ return ""
+ }
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ return filepath.ToSlash(path)
+ }
+ return filepath.ToSlash(abs)
+}
diff --git a/internal/nginx/pid.go b/internal/nginx/pid.go
new file mode 100644
index 0000000..30176af
--- /dev/null
+++ b/internal/nginx/pid.go
@@ -0,0 +1,39 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func readPIDFile(path string) (int, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return 0, err
+ }
+ pidStr := strings.TrimSpace(string(data))
+ if pidStr == "" {
+ return 0, fmt.Errorf("empty pid file")
+ }
+ pid, err := strconv.Atoi(pidStr)
+ if err != nil || pid <= 0 {
+ return 0, fmt.Errorf("invalid pid: %q", pidStr)
+ }
+ return pid, nil
+}
+
+func removePIDFile(path string) {
+ _ = os.Remove(path)
+}
+
+func waitProcessExit(pid int, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if !isPIDAlive(pid) {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
diff --git a/internal/nginx/pid_alive_test.go b/internal/nginx/pid_alive_test.go
new file mode 100644
index 0000000..4b62565
--- /dev/null
+++ b/internal/nginx/pid_alive_test.go
@@ -0,0 +1,13 @@
+package nginx
+
+import (
+ "os"
+ "testing"
+)
+
+func TestIsPIDAliveCurrentProcess(t *testing.T) {
+ pid := os.Getpid()
+ if !isPIDAlive(pid) {
+ t.Fatalf("expected current pid %d to be alive", pid)
+ }
+}
diff --git a/internal/nginx/pid_export.go b/internal/nginx/pid_export.go
new file mode 100644
index 0000000..0e01587
--- /dev/null
+++ b/internal/nginx/pid_export.go
@@ -0,0 +1,11 @@
+package nginx
+
+// ReadPID reads a PID from the given pid file.
+func ReadPID(path string) (int, error) {
+ return readPIDFile(path)
+}
+
+// IsPIDAlive reports whether the process with the given PID is running.
+func IsPIDAlive(pid int) bool {
+ return isPIDAlive(pid)
+}
diff --git a/internal/nginx/pid_other.go b/internal/nginx/pid_other.go
new file mode 100644
index 0000000..46a8716
--- /dev/null
+++ b/internal/nginx/pid_other.go
@@ -0,0 +1,25 @@
+//go:build !linux && !darwin && !freebsd && !windows
+
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "syscall"
+)
+
+func reloadProcess(pid int) error {
+ return fmt.Errorf("signal reload unsupported")
+}
+
+func terminateProcess(pid int, sig syscall.Signal) error {
+ return fmt.Errorf("signal terminate unsupported")
+}
+
+func isPIDAlive(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ return proc.Signal(syscall.Signal(0)) == nil
+}
diff --git a/internal/nginx/pid_test.go b/internal/nginx/pid_test.go
new file mode 100644
index 0000000..57fdacc
--- /dev/null
+++ b/internal/nginx/pid_test.go
@@ -0,0 +1,53 @@
+package nginx
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/config"
+)
+
+func TestReadPIDFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "nginx.pid")
+
+ if _, err := readPIDFile(path); !os.IsNotExist(err) {
+ t.Fatalf("expected not exist, got %v", err)
+ }
+
+ if err := os.WriteFile(path, []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readPIDFile(path); err == nil {
+ t.Fatal("expected error for empty pid file")
+ }
+
+ if err := os.WriteFile(path, []byte("12345\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ pid, err := readPIDFile(path)
+ if err != nil || pid != 12345 {
+ t.Fatalf("expected pid 12345, got %d err=%v", pid, err)
+ }
+}
+
+func TestIsRunningStalePIDFile(t *testing.T) {
+ dir := t.TempDir()
+ pidPath := filepath.Join(dir, "nginx.pid")
+ if err := os.WriteFile(pidPath, []byte(""), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ mgr := &Manager{cfg: config.Config{NginxPIDFile: pidPath}}
+ running, err := mgr.isRunning()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if running {
+ t.Fatal("expected stale empty pid file to be treated as not running")
+ }
+ if _, err := os.Stat(pidPath); !os.IsNotExist(err) {
+ t.Fatal("expected stale pid file to be removed")
+ }
+}
diff --git a/internal/nginx/pid_unix.go b/internal/nginx/pid_unix.go
new file mode 100644
index 0000000..505ca38
--- /dev/null
+++ b/internal/nginx/pid_unix.go
@@ -0,0 +1,24 @@
+//go:build linux || darwin || freebsd
+
+package nginx
+
+import (
+ "os"
+ "syscall"
+)
+
+func reloadProcess(pid int) error {
+ return syscall.Kill(pid, syscall.SIGHUP)
+}
+
+func terminateProcess(pid int, sig syscall.Signal) error {
+ return syscall.Kill(pid, sig)
+}
+
+func isPIDAlive(pid int) bool {
+ proc, err := os.FindProcess(pid)
+ if err != nil {
+ return false
+ }
+ return proc.Signal(syscall.Signal(0)) == nil
+}
diff --git a/internal/nginx/pid_windows.go b/internal/nginx/pid_windows.go
new file mode 100644
index 0000000..9fc8bb2
--- /dev/null
+++ b/internal/nginx/pid_windows.go
@@ -0,0 +1,59 @@
+//go:build windows
+
+package nginx
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+
+ "golang.org/x/sys/windows"
+)
+
+const stillActive = 259
+
+func reloadProcess(pid int) error {
+ return fmt.Errorf("signal reload unsupported on windows")
+}
+
+func terminateProcess(pid int, sig syscall.Signal) error {
+ args := []string{"/PID", strconv.Itoa(pid)}
+ if sig == syscall.SIGKILL {
+ args = append([]string{"/F"}, args...)
+ }
+ cmd := exec.Command("taskkill", args...)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("taskkill: %w", err)
+ }
+ return nil
+}
+
+func isPIDAlive(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
+ if err != nil {
+ return false
+ }
+ defer windows.CloseHandle(handle)
+
+ var exitCode uint32
+ if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil {
+ return false
+ }
+ return exitCode == stillActive
+}
+
+func stopOrphanMasters(nginxDir string) {
+ marker := strings.ToLower(filepath.ToSlash(absNginxPath(nginxDir)))
+ marker = strings.ReplaceAll(marker, "'", "''")
+ script := fmt.Sprintf(
+ `Get-CimInstance Win32_Process -Filter "Name='nginx.exe'" | Where-Object { $_.CommandLine -like '*%s*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }`,
+ marker,
+ )
+ _ = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script).Run()
+}
diff --git a/internal/nginx/realip.go b/internal/nginx/realip.go
new file mode 100644
index 0000000..b57f88b
--- /dev/null
+++ b/internal/nginx/realip.go
@@ -0,0 +1,99 @@
+package nginx
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+type TrustedProxyConfig struct {
+ Enabled bool `json:"enabled"`
+ Preset string `json:"preset"` // "", "cloudflare", "custom"
+ CIDRs []string `json:"cidrs,omitempty"`
+ Header string `json:"header,omitempty"` // CF-Connecting-IP, X-Real-IP, X-Forwarded-For
+ Recursive bool `json:"recursive,omitempty"`
+}
+
+var cloudflareIPv4 = []string{
+ "173.245.48.0/20",
+ "103.21.244.0/22",
+ "103.22.200.0/22",
+ "103.31.4.0/22",
+ "141.101.64.0/18",
+ "108.162.192.0/18",
+ "190.93.240.0/20",
+ "188.114.96.0/20",
+ "197.234.240.0/22",
+ "198.41.128.0/17",
+ "162.158.0.0/15",
+ "104.16.0.0/13",
+ "104.24.0.0/14",
+ "172.64.0.0/13",
+ "131.0.72.0/22",
+}
+
+var cloudflareIPv6 = []string{
+ "2400:cb00::/32",
+ "2606:4700::/32",
+ "2803:f800::/32",
+ "2405:b500::/32",
+ "2405:8100::/32",
+ "2a06:98c0::/29",
+ "2c0f:f248::/32",
+}
+
+func ParseTrustedProxyJSON(raw string) TrustedProxyConfig {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "{}" {
+ return TrustedProxyConfig{}
+ }
+ var cfg TrustedProxyConfig
+ _ = json.Unmarshal([]byte(raw), &cfg)
+ return cfg
+}
+
+func (c TrustedProxyConfig) effectiveCIDRs() []string {
+ if !c.Enabled {
+ return nil
+ }
+ switch c.Preset {
+ case "cloudflare":
+ out := make([]string, 0, len(cloudflareIPv4)+len(cloudflareIPv6))
+ out = append(out, cloudflareIPv4...)
+ out = append(out, cloudflareIPv6...)
+ return out
+ default:
+ return c.CIDRs
+ }
+}
+
+func (c TrustedProxyConfig) effectiveHeader() string {
+ if c.Header != "" {
+ return c.Header
+ }
+ if c.Preset == "cloudflare" {
+ return "CF-Connecting-IP"
+ }
+ return "X-Forwarded-For"
+}
+
+func writeRealIPDirectives(b *strings.Builder, cfg TrustedProxyConfig) {
+ writeRealIPComment(b, cfg)
+ cidrs := cfg.effectiveCIDRs()
+ if len(cidrs) == 0 {
+ b.WriteString(" map $remote_addr $fonu_client_ip {\n")
+ b.WriteString(" default $remote_addr;\n")
+ b.WriteString(" }\n\n")
+ return
+ }
+ for _, cidr := range cidrs {
+ b.WriteString(" set_real_ip_from " + cidr + ";\n")
+ }
+ header := cfg.effectiveHeader()
+ b.WriteString(" real_ip_header " + header + ";\n")
+ if cfg.Recursive || header == "X-Forwarded-For" {
+ b.WriteString(" real_ip_recursive on;\n")
+ }
+ b.WriteString("\n map $remote_addr $fonu_client_ip {\n")
+ b.WriteString(" default $remote_addr;\n")
+ b.WriteString(" }\n\n")
+}
diff --git a/internal/nginx/security.go b/internal/nginx/security.go
new file mode 100644
index 0000000..b42847d
--- /dev/null
+++ b/internal/nginx/security.go
@@ -0,0 +1,257 @@
+package nginx
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+type GenerateOptions struct {
+ TrustedProxy TrustedProxyConfig
+ GlobalIPBlacklist []string
+ GlobalIPWhitelist []string
+ ChinaCIDRAvailable bool
+ ChinaCIDRPathOverride string
+ GlobalCustomOverride string
+ GlobalCustomPathOverride string
+ RuleCustomOverrides map[int64]string
+}
+
+func writeGlobalAllowList(b *strings.Builder, cidrs []string) {
+ merged := proxy.ChinaBypassCIDRs(cidrs)
+ if len(merged) == 0 {
+ return
+ }
+ writeGlobalAllowListComment(b)
+ for _, cidr := range merged {
+ b.WriteString(fmt.Sprintf(" allow %s;\n", cidr))
+ }
+ b.WriteString("\n")
+}
+
+func writeGlobalDenyList(b *strings.Builder, cidrs []string) {
+ if len(cidrs) == 0 {
+ return
+ }
+ writeGlobalDenyListComment(b)
+ for _, cidr := range cidrs {
+ b.WriteString(fmt.Sprintf(" deny %s;\n", cidr))
+ }
+ b.WriteString("\n")
+}
+
+func writeLimitZones(b *strings.Builder, rules []proxy.Rule) {
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+ if rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled() {
+ writeLimitZoneComments(b, rule)
+ }
+ if rule.Security.RateLimitEnabled() {
+ rl := rule.Security.RateLimit
+ b.WriteString(fmt.Sprintf(" limit_req_zone $binary_remote_addr zone=fonu_rule_%d_req:10m rate=%dr/s;\n", rule.ID, rl.Rate))
+ }
+ if rule.Security.ConnLimitEnabled() {
+ b.WriteString(fmt.Sprintf(" limit_conn_zone $binary_remote_addr zone=fonu_rule_%d_conn:10m;\n", rule.ID))
+ }
+ }
+ if hasLimitZones(rules) {
+ b.WriteString("\n")
+ }
+}
+
+func hasLimitZones(rules []proxy.Rule) bool {
+ for _, rule := range rules {
+ if rule.Enabled && (rule.Security.RateLimitEnabled() || rule.Security.ConnLimitEnabled()) {
+ return true
+ }
+ }
+ return false
+}
+
+func writePrivateIPGeo(b *strings.Builder) {
+ b.WriteString(` geo $fonu_client_ip $fonu_is_private {
+ default 0;
+ 10.0.0.0/8 1;
+ 127.0.0.0/8 1;
+ 172.16.0.0/12 1;
+ 192.168.0.0/16 1;
+ ::1/128 1;
+ fc00::/7 1;
+ }
+
+`)
+}
+
+func writeChinaGeoBlocks(b *strings.Builder, cfg config.Config, opts GenerateOptions, rules []proxy.Rule) {
+ needsChina := false
+ for _, rule := range rules {
+ if rule.Enabled && rule.Security.ChinaOnly {
+ needsChina = true
+ break
+ }
+ }
+ if !needsChina {
+ return
+ }
+ writeChinaGeoComment(b)
+ writePrivateIPGeo(b)
+ if !opts.ChinaCIDRAvailable {
+ return
+ }
+ cidrPath := absNginxPath(cfg.ChinaCIDRPath())
+ if opts.ChinaCIDRPathOverride != "" {
+ cidrPath = absNginxPath(opts.ChinaCIDRPathOverride)
+ }
+ b.WriteString(fmt.Sprintf(` geo $fonu_client_ip $fonu_is_china {
+ default 0;
+ include %s;
+ }
+
+`, cidrPath))
+}
+
+func writeChinaOnlyBypassGeo(b *strings.Builder, rules []proxy.Rule, opts GenerateOptions) {
+ if !opts.ChinaCIDRAvailable {
+ return
+ }
+ for _, rule := range rules {
+ if !rule.Enabled || !rule.Security.ChinaOnly {
+ continue
+ }
+ sec := rule.Security.Normalize()
+ cidrs := chinaBypassCIDRs(sec, opts.GlobalIPWhitelist)
+ writeChinaBypassComment(b, rule)
+ b.WriteString(fmt.Sprintf(" geo $fonu_client_ip $fonu_rule_%d_china_bypass {\n", rule.ID))
+ b.WriteString(" default 0;\n")
+ for _, cidr := range cidrs {
+ b.WriteString(fmt.Sprintf(" %s 1;\n", cidr))
+ }
+ b.WriteString(" }\n\n")
+ }
+}
+
+func writeChinaOnlyCheck(b *strings.Builder, rule proxy.Rule, opts GenerateOptions) {
+ sec := rule.Security.Normalize()
+ if !sec.ChinaOnly {
+ return
+ }
+ if !opts.ChinaCIDRAvailable {
+ b.WriteString(` if ($fonu_is_private = 0) {
+ return 403;
+ }
+`)
+ return
+ }
+ b.WriteString(` set $fonu_china_block 0;
+ if ($fonu_is_china = 0) {
+ set $fonu_china_block 1;
+ }
+ if ($fonu_is_private = 1) {
+ set $fonu_china_block 0;
+ }
+`)
+ b.WriteString(fmt.Sprintf(` if ($fonu_rule_%d_china_bypass = 1) {
+ set $fonu_china_block 0;
+ }
+`, rule.ID))
+ b.WriteString(` if ($fonu_china_block = 1) {
+ return 403;
+ }
+`)
+}
+
+func writeServerSecurityHeaders(b *strings.Builder, sec proxy.SecurityConfig, https bool) {
+ if !https || !sec.SecurityHeaders {
+ return
+ }
+ writeSecurityHeadersComment(b)
+ b.WriteString(` add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+
+`)
+}
+
+func writeTLSProtocols(b *strings.Builder, sec proxy.SecurityConfig) {
+ writeTLSProtocolComment(b, sec)
+ if sec.TLSMin13Only {
+ b.WriteString(" ssl_protocols TLSv1.3;\n\n")
+ } else {
+ b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n\n")
+ }
+}
+
+func writeErrorPages(b *strings.Builder, cfg config.Config) {
+ errorsDir := absNginxPath(cfg.ErrorsDir())
+ writeErrorPagesComment(b)
+ // HTML is served via internal error_page redirect; images are fetched by the browser
+ // as separate requests and must not use the internal-only location.
+ b.WriteString(` error_page 403 /fonu-errors/403.html;
+ error_page 404 /fonu-errors/404.html;
+ error_page 429 /fonu-errors/429.html;
+ error_page 500 /fonu-errors/500.html;
+ error_page 502 503 /fonu-errors/503.html;
+
+ location = /fonu-errors/error.png {
+ alias ` + errorsDir + `/error.png;
+ }
+ location = /fonu-errors/429.png {
+ alias ` + errorsDir + `/429.png;
+ }
+
+ location ^~ /fonu-errors/ {
+ internal;
+ alias ` + errorsDir + `/;
+ }
+
+`)
+}
+
+func writeLocationSecurity(b *strings.Builder, cfg config.Config, rule proxy.Rule, opts GenerateOptions) {
+ sec := rule.Security.Normalize()
+ writeLocationSecurityComments(b, rule, opts)
+
+ for _, cidr := range sec.IPBlacklist {
+ b.WriteString(fmt.Sprintf(" deny %s;\n", cidr))
+ }
+
+ writeChinaOnlyCheck(b, rule, opts)
+
+ if sec.WhitelistEnabled() {
+ for _, cidr := range sec.IPWhitelist {
+ b.WriteString(fmt.Sprintf(" allow %s;\n", cidr))
+ }
+ b.WriteString(" deny all;\n")
+ }
+
+ if sec.ConnLimitEnabled() {
+ b.WriteString(fmt.Sprintf(" limit_conn fonu_rule_%d_conn %d;\n", rule.ID, sec.ConnLimit.Max))
+ }
+ if sec.RateLimitEnabled() {
+ b.WriteString(fmt.Sprintf(" limit_req zone=fonu_rule_%d_req burst=%d nodelay;\n", rule.ID, sec.RateLimit.Burst))
+ }
+
+ if sec.BasicAuthEnabled() {
+ path := absNginxPath(HtpasswdPath(cfg, rule.ID))
+ b.WriteString(fmt.Sprintf(` auth_basic "Restricted";
+ auth_basic_user_file %s;
+`, path))
+ }
+}
+
+func chinaBypassCIDRs(sec proxy.SecurityConfig, globalWhitelist []string) []string {
+ extra := make([]string, 0, len(globalWhitelist)+len(sec.IPWhitelist))
+ extra = append(extra, globalWhitelist...)
+ extra = append(extra, sec.IPWhitelist...)
+ return proxy.ChinaBypassCIDRs(extra)
+}
+
+func chinaCIDRExists(cfg config.Config) bool {
+ info, err := os.Stat(cfg.ChinaCIDRPath())
+ return err == nil && !info.IsDir() && info.Size() > 0
+}
diff --git a/internal/nginx/syntax.go b/internal/nginx/syntax.go
new file mode 100644
index 0000000..a60e5f9
--- /dev/null
+++ b/internal/nginx/syntax.go
@@ -0,0 +1,68 @@
+package nginx
+
+import (
+ "fmt"
+ "strings"
+)
+
+// ValidateConfigSyntax performs a lightweight syntax check when nginx -t is unavailable.
+func ValidateConfigSyntax(content string) error {
+ depth := 0
+ for i, raw := range strings.Split(content, "\n") {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ line := stripInlineComment(trimmed)
+ if line == "" {
+ continue
+ }
+ depth += strings.Count(line, "{") - strings.Count(line, "}")
+ if depth < 0 {
+ return fmt.Errorf("第 %d 行:多余的 `}`", i+1)
+ }
+ if err := validateConfigLine(line, i+1); err != nil {
+ return err
+ }
+ }
+ if depth != 0 {
+ return fmt.Errorf("配置块大括号未配对")
+ }
+ return nil
+}
+
+func stripInlineComment(line string) string {
+ inSingle := false
+ inDouble := false
+ for i := 0; i < len(line); i++ {
+ ch := line[i]
+ switch ch {
+ case '\'':
+ if !inDouble {
+ inSingle = !inSingle
+ }
+ case '"':
+ if !inSingle {
+ inDouble = !inDouble
+ }
+ case '#':
+ if !inSingle && !inDouble {
+ return strings.TrimSpace(line[:i])
+ }
+ }
+ }
+ return strings.TrimSpace(line)
+}
+
+func validateConfigLine(line string, lineNo int) error {
+ if line == "}" || line == "{" {
+ return nil
+ }
+ if strings.HasSuffix(line, "{") {
+ return nil
+ }
+ if strings.HasSuffix(line, ";") {
+ return nil
+ }
+ return fmt.Errorf("第 %d 行:无效的 Nginx 语法(应以 ; 或 { 结尾)", lineNo)
+}
diff --git a/internal/nginx/syntax_test.go b/internal/nginx/syntax_test.go
new file mode 100644
index 0000000..b37d3b6
--- /dev/null
+++ b/internal/nginx/syntax_test.go
@@ -0,0 +1,44 @@
+package nginx
+
+import "testing"
+
+func TestValidateConfigSyntax(t *testing.T) {
+ valid := `server {
+ listen 80;
+ location / {
+ proxy_pass http://127.0.0.1:8080;
+ }
+}`
+ if err := ValidateConfigSyntax(valid); err != nil {
+ t.Fatalf("expected valid config: %v", err)
+ }
+
+ cases := []struct {
+ name string
+ content string
+ }{
+ {"garbage line", "server {\n 1 1\n}\n"},
+ {"missing semicolon", "server {\n listen 80\n}\n"},
+ {"unbalanced brace", "server {\n listen 80;\n"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if err := ValidateConfigSyntax(tc.content); err == nil {
+ t.Fatalf("expected error for %q", tc.name)
+ }
+ })
+ }
+
+ t.Run("allows comment lines", func(t *testing.T) {
+ content := `# 规则:测试
+server {
+ # 监听 80
+ listen 80;
+}
+# HTTP 跳转
+`
+ if err := ValidateConfigSyntax(content); err != nil {
+ t.Fatalf("expected comments to pass: %v", err)
+ }
+ })
+}
diff --git a/internal/notify/config.go b/internal/notify/config.go
new file mode 100644
index 0000000..1b6ffd5
--- /dev/null
+++ b/internal/notify/config.go
@@ -0,0 +1,128 @@
+package notify
+
+const (
+ EventDDNSIPChange = "ddns_ip_change"
+ EventDDNSFailure = "ddns_failure"
+ EventCertExpiry = "cert_expiry"
+ EventCertRenewSuccess = "cert_renew_success"
+ EventCertRenewFailure = "cert_renew_failure"
+ EventIPFrequentAccess = "ip_frequent_access"
+ EventLoginFailure = "login_failure"
+ EventNginxReloadFailure = "nginx_reload_failure"
+
+ MaskedSecret = "********"
+
+ DefaultBarkServer = "https://api.day.app"
+ DefaultIPFrequentThreshold = 100
+ DefaultIPFrequentWindowSec = 60
+ DefaultIPFrequentAlertCooldown = 15 * 60 // seconds
+ DefaultLoginFailureThreshold = 5
+ DefaultLoginFailureWindowSec = 300
+ DefaultLoginFailureAlertCooldown = 15 * 60 // seconds
+)
+
+type NotifyType string
+
+const (
+ NotifyTypeEmail NotifyType = "email"
+ NotifyTypeWebhook NotifyType = "webhook"
+ NotifyTypeTelegram NotifyType = "telegram"
+)
+
+type WebhookProvider string
+
+const (
+ WebhookBark WebhookProvider = "bark"
+ WebhookNtfy WebhookProvider = "ntfy"
+ WebhookGotify WebhookProvider = "gotify"
+ WebhookCustom WebhookProvider = "custom"
+)
+
+type EmailConfig struct {
+ Host string `json:"host"`
+ Port int `json:"port"`
+ Username string `json:"username"`
+ From string `json:"from"`
+ To []string `json:"to"`
+ TLS bool `json:"tls"`
+ HasPassword bool `json:"has_password"`
+}
+
+type WebhookConfig struct {
+ Provider WebhookProvider `json:"provider"`
+ Server string `json:"server"`
+ Key string `json:"key"`
+ Topic string `json:"topic"`
+ URL string `json:"url"`
+ HasSecret bool `json:"has_secret"`
+}
+
+type TelegramConfig struct {
+ ChatID string `json:"chat_id"`
+ ProxyURL string `json:"proxy_url"`
+ HasBotToken bool `json:"has_bot_token"`
+}
+
+type Config struct {
+ Type NotifyType `json:"type"`
+ Email EmailConfig `json:"email"`
+ Webhook WebhookConfig `json:"webhook"`
+ Telegram TelegramConfig `json:"telegram"`
+ OnDDNSIPChange bool `json:"on_ddns_ip_change"`
+ OnDDNSFailure bool `json:"on_ddns_failure"`
+ OnCertExpiry bool `json:"on_cert_expiry"`
+ OnCertRenewSuccess bool `json:"on_cert_renew_success"`
+ OnCertRenewFailure bool `json:"on_cert_renew_failure"`
+ OnIPFrequentAccess bool `json:"on_ip_frequent_access"`
+ OnLoginFailure bool `json:"on_login_failure"`
+ OnNginxReloadFailure bool `json:"on_nginx_reload_failure"`
+ IPFrequentThreshold int `json:"ip_frequent_threshold"`
+ IPFrequentWindowSec int `json:"ip_frequent_window_sec"`
+ LoginFailureThreshold int `json:"login_failure_threshold"`
+ LoginFailureWindowSec int `json:"login_failure_window_sec"`
+}
+
+type RuntimeConfig struct {
+ Config
+ SMTPPassword string
+ WebhookSecret string
+ TelegramToken string
+}
+
+type SaveInput struct {
+ Type NotifyType
+ Email EmailConfig
+ SMTPPassword string
+ Webhook WebhookConfig
+ WebhookSecret string
+ Telegram TelegramConfig
+ TelegramToken string
+ OnDDNSIPChange bool
+ OnDDNSFailure bool
+ OnCertExpiry bool
+ OnCertRenewSuccess bool
+ OnCertRenewFailure bool
+ OnIPFrequentAccess bool
+ OnLoginFailure bool
+ OnNginxReloadFailure bool
+ IPFrequentThreshold int
+ IPFrequentWindowSec int
+ LoginFailureThreshold int
+ LoginFailureWindowSec int
+}
+
+type TestInput struct {
+ Config
+ SMTPPassword string `json:"smtp_password"`
+ WebhookSecret string `json:"webhook_secret"`
+ TelegramToken string `json:"telegram_token"`
+}
+
+func (c Config) Runtime(password, webhookSecret, telegramToken string) RuntimeConfig {
+ return RuntimeConfig{
+ Config: c,
+ SMTPPassword: password,
+ WebhookSecret: webhookSecret,
+ TelegramToken: telegramToken,
+ }
+}
diff --git a/internal/notify/format.go b/internal/notify/format.go
new file mode 100644
index 0000000..5a1c7b8
--- /dev/null
+++ b/internal/notify/format.go
@@ -0,0 +1,130 @@
+package notify
+
+import (
+ "strings"
+ "time"
+)
+
+func formatFooter(at time.Time) string {
+ return "Fonu · " + at.Format("2006-01-02 15:04:05")
+}
+
+type eventMeta struct {
+ icon string
+ category string
+}
+
+type AlertContent struct {
+ Event string
+ Subject string
+ PushTitle string
+ PlainBody string
+ HTMLBody string
+ Category string
+ Icon string
+ Detail string
+ Timestamp time.Time
+}
+
+func FormatAlert(event, title, message string) AlertContent {
+ return FormatAlertAt(event, title, message, time.Now())
+}
+
+func FormatAlertAt(event, title, message string, at time.Time) AlertContent {
+ meta := eventMetaFor(event)
+ title = strings.TrimSpace(title)
+ message = strings.TrimSpace(message)
+ pushTitle := strings.TrimSpace(meta.icon + " " + title)
+ subject := "[Fonu] " + pushTitle
+ footer := formatFooter(at)
+
+ detail := message
+ if detail == "" {
+ detail = "暂无更多详情"
+ }
+
+ plain := strings.Join([]string{
+ pushTitle,
+ strings.Repeat("─", 28),
+ "",
+ detail,
+ "",
+ strings.Repeat("─", 28),
+ footer,
+ }, "\n")
+
+ html := formatTelegramHTML(pushTitle, detail, at)
+
+ return AlertContent{
+ Event: event,
+ Subject: subject,
+ PushTitle: pushTitle,
+ PlainBody: plain,
+ HTMLBody: html,
+ Category: meta.category,
+ Icon: meta.icon,
+ Detail: detail,
+ Timestamp: at,
+ }
+}
+
+func eventMetaFor(event string) eventMeta {
+ switch event {
+ case EventDDNSIPChange:
+ return eventMeta{"🌐", "DDNS"}
+ case EventDDNSFailure:
+ return eventMeta{"⚠️", "DDNS"}
+ case EventCertExpiry:
+ return eventMeta{"📅", "证书"}
+ case EventCertRenewSuccess:
+ return eventMeta{"✅", "证书"}
+ case EventCertRenewFailure:
+ return eventMeta{"❌", "证书"}
+ case EventIPFrequentAccess:
+ return eventMeta{"🛡️", "安全"}
+ case EventLoginFailure:
+ return eventMeta{"🔐", "安全"}
+ case EventNginxReloadFailure:
+ return eventMeta{"⚙️", "系统"}
+ case "test":
+ return eventMeta{"🔔", "系统"}
+ default:
+ return eventMeta{"📣", "告警"}
+ }
+}
+
+func formatTelegramHTML(title, detail string, at time.Time) string {
+ lines := []string{
+ "" + escapeHTML(title) + "",
+ "",
+ "" + escapeHTML(detail) + "
",
+ "",
+ "" + escapeHTML(formatFooter(at)) + "",
+ }
+ return strings.Join(lines, "\n")
+}
+
+func escapeHTML(s string) string {
+ replacer := strings.NewReplacer(
+ "&", "&",
+ "<", "<",
+ ">", ">",
+ )
+ return replacer.Replace(s)
+}
+
+func (c AlertContent) webhookPayload() map[string]string {
+ return map[string]string{
+ "event": c.Event,
+ "category": c.Category,
+ "icon": c.Icon,
+ "title": strings.TrimPrefix(c.PushTitle, c.Icon+" "),
+ "message": c.Detail,
+ "formatted": c.PlainBody,
+ "time": c.Timestamp.UTC().Format(time.RFC3339),
+ }
+}
+
+func (c AlertContent) barkMessage() string {
+ return strings.Join([]string{c.Detail, "", "────────", formatFooter(c.Timestamp)}, "\n")
+}
diff --git a/internal/notify/format_test.go b/internal/notify/format_test.go
new file mode 100644
index 0000000..15ba970
--- /dev/null
+++ b/internal/notify/format_test.go
@@ -0,0 +1,28 @@
+package notify
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFormatAlert(t *testing.T) {
+ at := time.Date(2026, 9, 18, 15, 49, 0, 0, time.FixedZone("CST", 8*3600))
+ content := FormatAlertAt(EventDDNSIPChange, "DDNS IP 已变更", "example.com\nIPv4: 1.2.3.4 → 5.6.7.8", at)
+
+ if content.Subject != "[Fonu] 🌐 DDNS IP 已变更" {
+ t.Fatalf("unexpected subject: %s", content.Subject)
+ }
+ if !strings.Contains(content.PlainBody, "example.com") {
+ t.Fatalf("missing detail: %s", content.PlainBody)
+ }
+ if !strings.Contains(content.PlainBody, "Fonu · 2026-09-18 15:49:00") {
+ t.Fatalf("missing footer: %s", content.PlainBody)
+ }
+ if !strings.Contains(content.HTMLBody, "") || !strings.Contains(content.HTMLBody, "") {
+ t.Fatalf("unexpected html: %s", content.HTMLBody)
+ }
+ if content.webhookPayload()["category"] != "DDNS" {
+ t.Fatalf("unexpected webhook category: %v", content.webhookPayload())
+ }
+}
diff --git a/internal/notify/push.go b/internal/notify/push.go
new file mode 100644
index 0000000..5584ef4
--- /dev/null
+++ b/internal/notify/push.go
@@ -0,0 +1,154 @@
+package notify
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+func SendWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+ switch cfg.Webhook.Provider {
+ case WebhookBark:
+ return sendBark(ctx, client, cfg, content.PushTitle, content.barkMessage())
+ case WebhookNtfy:
+ return sendNtfy(ctx, client, cfg, content.PushTitle, content.PlainBody)
+ case WebhookGotify:
+ return sendGotify(ctx, client, cfg, content.PushTitle, content.PlainBody)
+ case WebhookCustom:
+ return sendCustomWebhook(ctx, client, cfg, content)
+ default:
+ return fmt.Errorf("不支持的 Webhook 预设")
+ }
+}
+
+func webhookSecret(cfg RuntimeConfig) string {
+ if strings.TrimSpace(cfg.WebhookSecret) != "" {
+ return strings.TrimSpace(cfg.WebhookSecret)
+ }
+ return strings.TrimSpace(cfg.Webhook.Key)
+}
+
+func sendBark(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+ key := webhookSecret(cfg)
+ if key == "" {
+ return fmt.Errorf("Bark Device Key 未配置")
+ }
+ server := normalizeServerURL(cfg.Webhook.Server)
+ if server == "" {
+ server = DefaultBarkServer
+ }
+ endpoint := fmt.Sprintf("%s/%s/%s/%s", server, url.PathEscape(key), url.PathEscape(title), url.PathEscape(message))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
+ if err != nil {
+ return err
+ }
+ return doRequest(client, req)
+}
+
+func sendNtfy(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+ topic := strings.TrimSpace(cfg.Webhook.Topic)
+ if topic == "" {
+ return fmt.Errorf("ntfy Topic 未配置")
+ }
+ server := normalizeServerURL(cfg.Webhook.Server)
+ if server == "" {
+ server = "https://ntfy.sh"
+ }
+ endpoint := joinURL(server, topic)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(message))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Title", title)
+ req.Header.Set("Content-Type", "text/plain; charset=utf-8")
+ if token := webhookSecret(cfg); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ return doRequest(client, req)
+}
+
+func sendGotify(ctx context.Context, client *http.Client, cfg RuntimeConfig, title, message string) error {
+ token := webhookSecret(cfg)
+ if token == "" {
+ return fmt.Errorf("Gotify App Token 未配置")
+ }
+ server := normalizeServerURL(cfg.Webhook.Server)
+ if server == "" {
+ return fmt.Errorf("Gotify 服务地址未配置")
+ }
+ endpoint := joinURL(server, "message") + "?token=" + url.QueryEscape(token)
+ payload := map[string]any{
+ "title": title,
+ "message": message,
+ "priority": 5,
+ }
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ return doRequest(client, req)
+}
+
+func sendCustomWebhook(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+ target := strings.TrimSpace(cfg.Webhook.URL)
+ if target == "" {
+ return fmt.Errorf("Webhook URL 未配置")
+ }
+ body, _ := json.Marshal(content.webhookPayload())
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ return doRequest(client, req)
+}
+
+func doRequest(client *http.Client, req *http.Request) error {
+ if client == nil {
+ client = &http.Client{Timeout: 10 * time.Second}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ if len(body) > 0 {
+ return fmt.Errorf("请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ return fmt.Errorf("请求失败 (%d)", resp.StatusCode)
+ }
+ return nil
+}
+
+func normalizeServerURL(server string) string {
+ server = strings.TrimSpace(server)
+ server = strings.TrimRight(server, "/")
+ if server == "" {
+ return ""
+ }
+ if !strings.Contains(server, "://") {
+ server = "https://" + server
+ }
+ return server
+}
+
+func joinURL(base string, parts ...string) string {
+ base = strings.TrimRight(normalizeServerURL(base), "/")
+ for _, part := range parts {
+ part = strings.Trim(part, "/")
+ if part != "" {
+ base += "/" + part
+ }
+ }
+ return base
+}
diff --git a/internal/notify/push_test.go b/internal/notify/push_test.go
new file mode 100644
index 0000000..8602db7
--- /dev/null
+++ b/internal/notify/push_test.go
@@ -0,0 +1,73 @@
+package notify
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestSendCustomWebhook(t *testing.T) {
+ var body string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ raw, _ := io.ReadAll(r.Body)
+ body = string(raw)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ cfg := RuntimeConfig{
+ Config: Config{
+ Type: NotifyTypeWebhook,
+ Webhook: WebhookConfig{
+ Provider: WebhookCustom,
+ URL: server.URL,
+ },
+ },
+ }
+ content := FormatAlert("test", "title", "message")
+ if err := SendWebhook(context.Background(), server.Client(), cfg, content); err != nil {
+ t.Fatalf("send custom webhook: %v", err)
+ }
+ if !strings.Contains(body, `"title":"title"`) || !strings.Contains(body, `"message":"message"`) {
+ t.Fatalf("unexpected body: %s", body)
+ }
+ if !strings.Contains(body, `"formatted":`) {
+ t.Fatalf("missing formatted body: %s", body)
+ }
+}
+
+func TestSendNtfyUsesTopic(t *testing.T) {
+ var gotPath string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ cfg := RuntimeConfig{
+ Config: Config{
+ Type: NotifyTypeWebhook,
+ Webhook: WebhookConfig{
+ Provider: WebhookNtfy,
+ Server: server.URL,
+ Topic: "alerts",
+ },
+ },
+ }
+ if err := SendWebhook(context.Background(), server.Client(), cfg, FormatAlert("test", "title", "message")); err != nil {
+ t.Fatalf("send ntfy: %v", err)
+ }
+ if gotPath != "/alerts" {
+ t.Fatalf("expected /alerts got %s", gotPath)
+ }
+}
+
+func TestJoinURL(t *testing.T) {
+ got := joinURL("https://ntfy.sh", "topic")
+ if got != "https://ntfy.sh/topic" {
+ t.Fatalf("unexpected url: %s", got)
+ }
+}
diff --git a/internal/notify/service.go b/internal/notify/service.go
new file mode 100644
index 0000000..5cc20a2
--- /dev/null
+++ b/internal/notify/service.go
@@ -0,0 +1,248 @@
+package notify
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+type Service struct {
+ settings *settings.Store
+ store *Store
+ logger *slog.Logger
+ client *http.Client
+
+ accessMu sync.Mutex
+ accessHits map[string][]time.Time
+ accessAlerted map[string]time.Time
+
+ loginMu sync.Mutex
+ loginHits map[string][]time.Time
+ loginAlerted map[string]time.Time
+}
+
+func New(settingsStore *settings.Store, secretBox *secret.Box, logger *slog.Logger) *Service {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Service{
+ settings: settingsStore,
+ store: NewStore(settingsStore, secretBox),
+ logger: logger,
+ client: &http.Client{Timeout: 10 * time.Second},
+ accessHits: map[string][]time.Time{},
+ accessAlerted: map[string]time.Time{},
+ loginHits: map[string][]time.Time{},
+ loginAlerted: map[string]time.Time{},
+ }
+}
+
+func (s *Service) Alert(ctx context.Context, event, title, message string) {
+ if s == nil || s.store == nil {
+ return
+ }
+ cfg, err := s.store.LoadRuntime(ctx)
+ if err != nil {
+ s.logger.Warn("notify load config failed", "error", err.Error())
+ return
+ }
+ if !s.enabledFor(cfg, event) {
+ return
+ }
+ if err := s.send(ctx, cfg, event, title, message); err != nil {
+ s.logger.Warn("notify send failed", "event", event, "error", err.Error())
+ }
+}
+
+func (s *Service) RecordAccessHit(ctx context.Context, ip string) {
+ s.recordThresholdHit(ctx, ip, EventIPFrequentAccess, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+ enabled = s.enabledFor(cfg, EventIPFrequentAccess)
+ threshold = cfg.IPFrequentThreshold
+ if threshold <= 0 {
+ threshold = DefaultIPFrequentThreshold
+ }
+ windowSec = cfg.IPFrequentWindowSec
+ if windowSec <= 0 {
+ windowSec = DefaultIPFrequentWindowSec
+ }
+ return threshold, windowSec, DefaultIPFrequentAlertCooldown, enabled
+ }, &s.accessMu, s.accessHits, s.accessAlerted, func(ip string, windowSec, count, threshold int) string {
+ return fmt.Sprintf("IP %s 在 %d 秒内访问 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+ }, "IP 频繁访问")
+}
+
+func (s *Service) RecordLoginFailure(ctx context.Context, ip string) {
+ s.recordThresholdHit(ctx, ip, EventLoginFailure, func(cfg RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool) {
+ enabled = s.enabledFor(cfg, EventLoginFailure)
+ threshold = cfg.LoginFailureThreshold
+ if threshold <= 0 {
+ threshold = DefaultLoginFailureThreshold
+ }
+ windowSec = cfg.LoginFailureWindowSec
+ if windowSec <= 0 {
+ windowSec = DefaultLoginFailureWindowSec
+ }
+ return threshold, windowSec, DefaultLoginFailureAlertCooldown, enabled
+ }, &s.loginMu, s.loginHits, s.loginAlerted, func(ip string, windowSec, count, threshold int) string {
+ return fmt.Sprintf("IP %s 在 %d 秒内登录失败 %d 次(阈值 %d)", ip, windowSec, count, threshold)
+ }, "登录异常")
+}
+
+func (s *Service) recordThresholdHit(
+ ctx context.Context,
+ ip string,
+ event string,
+ cfgFn func(RuntimeConfig) (threshold, windowSec, cooldownSec int, enabled bool),
+ mu *sync.Mutex,
+ hits map[string][]time.Time,
+ alerted map[string]time.Time,
+ messageFn func(ip string, windowSec, count, threshold int) string,
+ title string,
+) {
+ if s == nil || s.store == nil {
+ return
+ }
+ ip = strings.TrimSpace(ip)
+ if ip == "" || ip == "-" {
+ return
+ }
+
+ cfg, err := s.store.LoadRuntime(ctx)
+ if err != nil {
+ return
+ }
+ threshold, windowSec, cooldownSec, enabled := cfgFn(cfg)
+ if !enabled {
+ return
+ }
+
+ window := time.Duration(windowSec) * time.Second
+ now := time.Now()
+ cutoff := now.Add(-window)
+
+ mu.Lock()
+ prev := hits[ip]
+ pruned := make([]time.Time, 0, len(prev)+1)
+ for _, at := range prev {
+ if !at.Before(cutoff) {
+ pruned = append(pruned, at)
+ }
+ }
+ pruned = append(pruned, now)
+ hits[ip] = pruned
+
+ count := len(pruned)
+ lastAlert := alerted[ip]
+ cooldown := time.Duration(cooldownSec) * time.Second
+ shouldAlert := count >= threshold && now.Sub(lastAlert) >= cooldown
+ if shouldAlert {
+ alerted[ip] = now
+ }
+ mu.Unlock()
+
+ if !shouldAlert {
+ return
+ }
+ s.Alert(ctx, event, title, messageFn(ip, windowSec, count, threshold))
+}
+
+func (s *Service) SendTest(ctx context.Context, in TestInput) error {
+ runtime, err := s.resolveTestRuntime(ctx, in)
+ if err != nil {
+ return err
+ }
+ if runtime.Type == "" {
+ return fmt.Errorf("请先选择通知方式")
+ }
+ if err := s.send(ctx, runtime, "test", "测试通知", "配置验证通过,当前通知通道可正常使用。"); err != nil {
+ s.logger.Warn("notify test send failed", "error", err.Error())
+ return err
+ }
+ return nil
+}
+
+func (s *Service) resolveTestRuntime(ctx context.Context, in TestInput) (RuntimeConfig, error) {
+ smtpPassword := strings.TrimSpace(in.SMTPPassword)
+ webhookSecret := strings.TrimSpace(in.WebhookSecret)
+ telegramToken := strings.TrimSpace(in.TelegramToken)
+
+ if key := strings.TrimSpace(in.Webhook.Key); key != "" && key != MaskedSecret {
+ webhookSecret = key
+ }
+
+ needStored := smtpPassword == "" || smtpPassword == MaskedSecret ||
+ webhookSecret == "" || webhookSecret == MaskedSecret ||
+ telegramToken == "" || telegramToken == MaskedSecret
+
+ if needStored && s.store != nil {
+ stored, err := s.store.LoadRuntime(ctx)
+ if err != nil {
+ return RuntimeConfig{}, err
+ }
+ if (smtpPassword == "" || smtpPassword == MaskedSecret) && in.Email.HasPassword {
+ smtpPassword = stored.SMTPPassword
+ }
+ if (webhookSecret == "" || webhookSecret == MaskedSecret) && in.Webhook.HasSecret {
+ webhookSecret = stored.WebhookSecret
+ }
+ if (telegramToken == "" || telegramToken == MaskedSecret) && in.Telegram.HasBotToken {
+ telegramToken = stored.TelegramToken
+ }
+ }
+
+ return in.Config.Runtime(smtpPassword, webhookSecret, telegramToken), nil
+}
+
+func (s *Service) Store() *Store {
+ if s == nil {
+ return nil
+ }
+ return s.store
+}
+
+func (s *Service) send(ctx context.Context, cfg RuntimeConfig, event, title, message string) error {
+ content := FormatAlert(event, title, message)
+ switch cfg.Type {
+ case NotifyTypeEmail:
+ return SendEmail(cfg, content.Subject, content.PlainBody)
+ case NotifyTypeWebhook:
+ return SendWebhook(ctx, s.client, cfg, content)
+ case NotifyTypeTelegram:
+ return SendTelegram(ctx, s.client, cfg, content)
+ default:
+ return nil
+ }
+}
+
+func (s *Service) enabledFor(cfg RuntimeConfig, event string) bool {
+ if cfg.Type == "" {
+ return false
+ }
+ switch event {
+ case EventDDNSIPChange:
+ return cfg.OnDDNSIPChange
+ case EventDDNSFailure:
+ return cfg.OnDDNSFailure
+ case EventCertExpiry:
+ return cfg.OnCertExpiry
+ case EventCertRenewSuccess:
+ return cfg.OnCertRenewSuccess
+ case EventCertRenewFailure:
+ return cfg.OnCertRenewFailure
+ case EventIPFrequentAccess:
+ return cfg.OnIPFrequentAccess
+ case EventLoginFailure:
+ return cfg.OnLoginFailure
+ case EventNginxReloadFailure:
+ return cfg.OnNginxReloadFailure
+ default:
+ return true
+ }
+}
diff --git a/internal/notify/service_test.go b/internal/notify/service_test.go
new file mode 100644
index 0000000..8fbc6b1
--- /dev/null
+++ b/internal/notify/service_test.go
@@ -0,0 +1,47 @@
+package notify
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+)
+
+func TestTestInputDecodesSecretFields(t *testing.T) {
+ raw := `{
+ "type": "telegram",
+ "telegram": {"chat_id": "743955235", "proxy_url": "http://127.0.0.1:7890", "has_bot_token": false},
+ "telegram_token": "123456:ABC-DEF"
+ }`
+ var in TestInput
+ if err := json.Unmarshal([]byte(raw), &in); err != nil {
+ t.Fatal(err)
+ }
+ if in.Type != NotifyTypeTelegram {
+ t.Fatalf("unexpected type: %s", in.Type)
+ }
+ if in.TelegramToken != "123456:ABC-DEF" {
+ t.Fatalf("telegram token not decoded: %q", in.TelegramToken)
+ }
+ if in.Telegram.ChatID != "743955235" {
+ t.Fatalf("chat id not decoded: %q", in.Telegram.ChatID)
+ }
+}
+
+func TestResolveTestRuntimeUsesPlainToken(t *testing.T) {
+ svc := &Service{}
+ runtime, err := svc.resolveTestRuntime(context.Background(), TestInput{
+ Config: Config{
+ Type: NotifyTypeTelegram,
+ Telegram: TelegramConfig{
+ ChatID: "1",
+ },
+ },
+ TelegramToken: "plain-token",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if runtime.TelegramToken != "plain-token" {
+ t.Fatalf("expected plain-token, got %q", runtime.TelegramToken)
+ }
+}
diff --git a/internal/notify/smtp.go b/internal/notify/smtp.go
new file mode 100644
index 0000000..7962a26
--- /dev/null
+++ b/internal/notify/smtp.go
@@ -0,0 +1,90 @@
+package notify
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net/smtp"
+ "strings"
+)
+
+func SendEmail(cfg RuntimeConfig, title, message string) error {
+ if strings.TrimSpace(cfg.Email.Host) == "" {
+ return fmt.Errorf("SMTP 主机未配置")
+ }
+ if len(cfg.Email.To) == 0 {
+ return fmt.Errorf("收件人未配置")
+ }
+ if strings.TrimSpace(cfg.SMTPPassword) == "" && strings.TrimSpace(cfg.Email.Username) != "" {
+ return fmt.Errorf("SMTP 密码未配置")
+ }
+
+ from := strings.TrimSpace(cfg.Email.From)
+ if from == "" {
+ from = strings.TrimSpace(cfg.Email.Username)
+ }
+ if from == "" {
+ return fmt.Errorf("发件人未配置")
+ }
+
+ body := buildMailBody(from, cfg.Email.To, title, message)
+ addr := fmt.Sprintf("%s:%d", cfg.Email.Host, cfg.Email.Port)
+ auth := smtp.PlainAuth("", cfg.Email.Username, cfg.SMTPPassword, cfg.Email.Host)
+
+ if cfg.Email.Port == 465 {
+ return sendSMTPS(addr, cfg.Email.Host, auth, from, cfg.Email.To, body)
+ }
+ return smtp.SendMail(addr, auth, from, cfg.Email.To, body)
+}
+
+func buildMailBody(from string, to []string, title, message string) []byte {
+ headers := []string{
+ "From: " + from,
+ "To: " + strings.Join(to, ", "),
+ "Subject: " + title,
+ "MIME-Version: 1.0",
+ "Content-Type: text/plain; charset=UTF-8",
+ }
+ bodyText := strings.TrimSpace(message)
+ if bodyText == "" {
+ bodyText = title
+ }
+ return []byte(strings.Join(headers, "\r\n") + "\r\n\r\n" + bodyText + "\r\n")
+}
+
+func sendSMTPS(addr, host string, auth smtp.Auth, from string, to []string, body []byte) error {
+ tlsCfg := &tls.Config{ServerName: host}
+ conn, err := tls.Dial("tcp", addr, tlsCfg)
+ if err != nil {
+ return err
+ }
+ client, err := smtp.NewClient(conn, host)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+
+ if auth != nil {
+ if err := client.Auth(auth); err != nil {
+ return err
+ }
+ }
+ if err := client.Mail(from); err != nil {
+ return err
+ }
+ for _, rcpt := range to {
+ if err := client.Rcpt(strings.TrimSpace(rcpt)); err != nil {
+ return err
+ }
+ }
+ writer, err := client.Data()
+ if err != nil {
+ return err
+ }
+ if _, err := writer.Write(body); err != nil {
+ return err
+ }
+ if err := writer.Close(); err != nil {
+ return err
+ }
+ return client.Quit()
+}
diff --git a/internal/notify/smtp_test.go b/internal/notify/smtp_test.go
new file mode 100644
index 0000000..2bdb205
--- /dev/null
+++ b/internal/notify/smtp_test.go
@@ -0,0 +1,17 @@
+package notify
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildMailBody(t *testing.T) {
+ content := FormatAlert("test", "标题", "正文")
+ body := string(buildMailBody("from@example.com", []string{"to@example.com"}, content.Subject, content.PlainBody))
+ if !strings.Contains(body, "Subject: [Fonu]") {
+ t.Fatalf("missing subject: %s", body)
+ }
+ if !strings.Contains(body, "正文") {
+ t.Fatalf("missing message: %s", body)
+ }
+}
diff --git a/internal/notify/store.go b/internal/notify/store.go
new file mode 100644
index 0000000..d8a2137
--- /dev/null
+++ b/internal/notify/store.go
@@ -0,0 +1,456 @@
+package notify
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+type Store struct {
+ settings *settings.Store
+ secret *secret.Box
+}
+
+func NewStore(settingsStore *settings.Store, secretBox *secret.Box) *Store {
+ return &Store{settings: settingsStore, secret: secretBox}
+}
+
+func (s *Store) Load(ctx context.Context) (Config, error) {
+ cfg, _, err := s.load(ctx, false)
+ return cfg, err
+}
+
+func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) {
+ cfg, runtime, err := s.load(ctx, true)
+ if err != nil {
+ return RuntimeConfig{}, err
+ }
+ return cfg.Runtime(runtime.smtpPassword, runtime.webhookSecret, runtime.telegramToken), nil
+}
+
+type runtimeSecrets struct {
+ smtpPassword string
+ webhookSecret string
+ telegramToken string
+}
+
+func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) {
+ cfg := Config{
+ Email: EmailConfig{Port: 587, TLS: true},
+ Webhook: WebhookConfig{Provider: WebhookBark, Server: DefaultBarkServer},
+ Telegram: TelegramConfig{},
+ }
+ secrets := runtimeSecrets{}
+
+ typeRaw, _ := s.settings.Get(ctx, settings.KeyNotifyType)
+ cfg.Type = NotifyType(strings.TrimSpace(typeRaw))
+
+ emailRaw, _ := s.settings.Get(ctx, settings.KeyNotifyEmailJSON)
+ if emailRaw != "" {
+ _ = json.Unmarshal([]byte(emailRaw), &cfg.Email)
+ }
+ if cfg.Email.Port <= 0 {
+ cfg.Email.Port = 587
+ }
+
+ webhookRaw, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookJSON)
+ if webhookRaw != "" {
+ _ = json.Unmarshal([]byte(webhookRaw), &cfg.Webhook)
+ }
+ if cfg.Webhook.Server == "" && cfg.Webhook.Provider == WebhookBark {
+ cfg.Webhook.Server = DefaultBarkServer
+ }
+
+ telegramRaw, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramJSON)
+ if telegramRaw != "" {
+ _ = json.Unmarshal([]byte(telegramRaw), &cfg.Telegram)
+ }
+
+ cfg.IPFrequentThreshold = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+ cfg.IPFrequentWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+
+ cfg.LoginFailureThreshold = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+ cfg.LoginFailureWindowSec = intSetting(s.settings, ctx, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+
+ if hasNotifyEventSettings(ctx, s.settings) {
+ cfg.OnDDNSIPChange = flag(s.settings, ctx, settings.KeyNotifyOnDDNSIPChange, true)
+ cfg.OnDDNSFailure = flag(s.settings, ctx, settings.KeyNotifyOnDDNSFailure, true)
+ cfg.OnCertExpiry = flag(s.settings, ctx, settings.KeyNotifyOnCertExpiry, true)
+ cfg.OnCertRenewSuccess = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewSuccess, true)
+ cfg.OnCertRenewFailure = flag(s.settings, ctx, settings.KeyNotifyOnCertRenewFailure, true)
+ cfg.OnIPFrequentAccess = flag(s.settings, ctx, settings.KeyNotifyOnIPFrequentAccess, false)
+ cfg.OnLoginFailure = flag(s.settings, ctx, settings.KeyNotifyOnLoginFailure, false)
+ cfg.OnNginxReloadFailure = flag(s.settings, ctx, settings.KeyNotifyOnNginxReloadFailure, true)
+ } else {
+ legacyDDNS := flag(s.settings, ctx, settings.KeyNotifyOnDDNSError, true)
+ legacyCert := flag(s.settings, ctx, settings.KeyNotifyOnCertError, true)
+ cfg.OnDDNSFailure = legacyDDNS
+ cfg.OnCertExpiry = legacyCert
+ cfg.OnCertRenewFailure = legacyCert
+ cfg.OnDDNSIPChange = true
+ cfg.OnCertRenewSuccess = true
+ cfg.OnNginxReloadFailure = true
+ cfg.OnIPFrequentAccess = false
+ cfg.OnLoginFailure = false
+ }
+
+ smtpEnc, _ := s.settings.Get(ctx, settings.KeyNotifySMTPPassword)
+ if smtpEnc != "" {
+ cfg.Email.HasPassword = true
+ if decrypt && s.secret != nil {
+ plain, err := s.secret.Decrypt(smtpEnc)
+ if err != nil {
+ return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+ }
+ secrets.smtpPassword = plain
+ }
+ }
+
+ webhookEnc, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookSecret)
+ if webhookEnc != "" {
+ cfg.Webhook.HasSecret = true
+ if decrypt && s.secret != nil {
+ plain, err := s.secret.Decrypt(webhookEnc)
+ if err != nil {
+ return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+ }
+ secrets.webhookSecret = plain
+ }
+ }
+
+ telegramEnc, _ := s.settings.Get(ctx, settings.KeyNotifyTelegramToken)
+ if telegramEnc != "" {
+ cfg.Telegram.HasBotToken = true
+ if decrypt && s.secret != nil {
+ plain, err := s.secret.Decrypt(telegramEnc)
+ if err != nil {
+ return Config{}, runtimeSecrets{}, secret.DecryptHint(err)
+ }
+ secrets.telegramToken = plain
+ }
+ }
+
+ if cfg.Type == "" {
+ legacyURL, _ := s.settings.Get(ctx, settings.KeyNotifyWebhookURL)
+ if strings.TrimSpace(legacyURL) != "" {
+ cfg.Type = NotifyTypeWebhook
+ cfg.Webhook.Provider = WebhookCustom
+ cfg.Webhook.URL = strings.TrimSpace(legacyURL)
+ }
+ }
+
+ return cfg, secrets, nil
+}
+
+func flag(store *settings.Store, ctx context.Context, key string, fallback bool) bool {
+ raw, err := store.Get(ctx, key)
+ if err != nil || raw == "" {
+ return fallback
+ }
+ return raw == "1" || strings.EqualFold(raw, "true")
+}
+
+func intSetting(store *settings.Store, ctx context.Context, key string, fallback int) int {
+ raw, err := store.Get(ctx, key)
+ if err != nil || strings.TrimSpace(raw) == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(raw))
+ if err != nil || n <= 0 {
+ return fallback
+ }
+ return n
+}
+
+func hasNotifyEventSettings(ctx context.Context, store *settings.Store) bool {
+ keys := []string{
+ settings.KeyNotifyOnDDNSIPChange,
+ settings.KeyNotifyOnDDNSFailure,
+ settings.KeyNotifyOnCertExpiry,
+ settings.KeyNotifyOnCertRenewSuccess,
+ settings.KeyNotifyOnIPFrequentAccess,
+ settings.KeyNotifyOnCertRenewFailure,
+ settings.KeyNotifyOnLoginFailure,
+ settings.KeyNotifyOnNginxReloadFailure,
+ }
+ for _, key := range keys {
+ raw, err := store.Get(ctx, key)
+ if err == nil && raw != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *Store) Save(ctx context.Context, in SaveInput) error {
+ if err := validateSaveInput(in); err != nil {
+ return err
+ }
+
+ if err := s.settings.Set(ctx, settings.KeyNotifyType, string(in.Type)); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSIPChange, in.OnDDNSIPChange); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnDDNSFailure, in.OnDDNSFailure); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertExpiry, in.OnCertExpiry); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewSuccess, in.OnCertRenewSuccess); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnIPFrequentAccess, in.OnIPFrequentAccess); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnCertRenewFailure, in.OnCertRenewFailure); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnLoginFailure, in.OnLoginFailure); err != nil {
+ return err
+ }
+ if err := s.settings.SetBool(ctx, settings.KeyNotifyOnNginxReloadFailure, in.OnNginxReloadFailure); err != nil {
+ return err
+ }
+ threshold := in.IPFrequentThreshold
+ if threshold <= 0 {
+ threshold = DefaultIPFrequentThreshold
+ }
+ windowSec := in.IPFrequentWindowSec
+ if windowSec <= 0 {
+ windowSec = DefaultIPFrequentWindowSec
+ }
+ if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentThreshold, threshold); err != nil {
+ return err
+ }
+ if err := s.settings.SetInt(ctx, settings.KeyNotifyIPFrequentWindowSec, windowSec); err != nil {
+ return err
+ }
+ loginThreshold := in.LoginFailureThreshold
+ if loginThreshold <= 0 {
+ loginThreshold = DefaultLoginFailureThreshold
+ }
+ loginWindowSec := in.LoginFailureWindowSec
+ if loginWindowSec <= 0 {
+ loginWindowSec = DefaultLoginFailureWindowSec
+ }
+ if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureThreshold, loginThreshold); err != nil {
+ return err
+ }
+ if err := s.settings.SetInt(ctx, settings.KeyNotifyLoginFailureWindowSec, loginWindowSec); err != nil {
+ return err
+ }
+
+ email := in.Email
+ email.HasPassword = email.HasPassword || strings.TrimSpace(in.SMTPPassword) != "" && in.SMTPPassword != MaskedSecret
+ emailJSON, err := json.Marshal(email)
+ if err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyNotifyEmailJSON, string(emailJSON)); err != nil {
+ return err
+ }
+
+ webhook := in.Webhook
+ secretValue := strings.TrimSpace(in.WebhookSecret)
+ if secretValue != "" && secretValue != MaskedSecret {
+ webhook.HasSecret = true
+ } else if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
+ if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+ webhook.HasSecret = true
+ }
+ }
+ webhookJSON, err := json.Marshal(webhook)
+ if err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyNotifyWebhookJSON, string(webhookJSON)); err != nil {
+ return err
+ }
+
+ telegram := in.Telegram
+ if strings.TrimSpace(in.TelegramToken) != "" && in.TelegramToken != MaskedSecret {
+ telegram.HasBotToken = true
+ }
+ telegramJSON, err := json.Marshal(telegram)
+ if err != nil {
+ return err
+ }
+ if err := s.settings.Set(ctx, settings.KeyNotifyTelegramJSON, string(telegramJSON)); err != nil {
+ return err
+ }
+
+ if in.Type == NotifyTypeWebhook && webhook.Provider == WebhookCustom {
+ if err := s.settings.Set(ctx, settings.KeyNotifyWebhookURL, strings.TrimSpace(webhook.URL)); err != nil {
+ return err
+ }
+ }
+
+ if err := s.saveSecret(ctx, settings.KeyNotifySMTPPassword, in.SMTPPassword, in.Email.HasPassword); err != nil {
+ return err
+ }
+
+ webhookPlain := secretValue
+ if webhookPlain == "" || webhookPlain == MaskedSecret {
+ if webhook.Provider == WebhookBark || webhook.Provider == WebhookGotify {
+ if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+ webhookPlain = strings.TrimSpace(webhook.Key)
+ }
+ } else if webhook.Provider == WebhookNtfy {
+ if strings.TrimSpace(webhook.Key) != "" && webhook.Key != MaskedSecret {
+ webhookPlain = strings.TrimSpace(webhook.Key)
+ }
+ }
+ }
+ if err := s.saveSecret(ctx, settings.KeyNotifyWebhookSecret, webhookPlain, webhook.HasSecret); err != nil {
+ return err
+ }
+
+ if err := s.saveSecret(ctx, settings.KeyNotifyTelegramToken, in.TelegramToken, telegram.HasBotToken); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (s *Store) saveSecret(ctx context.Context, key, plain string, hasExisting bool) error {
+ plain = strings.TrimSpace(plain)
+ if plain == "" || plain == MaskedSecret {
+ if !hasExisting {
+ return nil
+ }
+ return nil
+ }
+ if s.secret == nil {
+ return fmt.Errorf("加密模块未初始化")
+ }
+ enc, err := s.secret.Encrypt(plain)
+ if err != nil {
+ return err
+ }
+ return s.settings.Set(ctx, key, enc)
+}
+
+func validateSaveInput(in SaveInput) error {
+ switch in.Type {
+ case "":
+ return nil
+ case NotifyTypeEmail:
+ if strings.TrimSpace(in.Email.Host) == "" {
+ return fmt.Errorf("请填写 SMTP 主机")
+ }
+ if len(in.Email.To) == 0 {
+ return fmt.Errorf("请填写收件人邮箱")
+ }
+ password := strings.TrimSpace(in.SMTPPassword)
+ if password == "" || password == MaskedSecret {
+ if !in.Email.HasPassword {
+ return fmt.Errorf("请填写 SMTP 密码")
+ }
+ }
+ case NotifyTypeWebhook:
+ switch in.Webhook.Provider {
+ case WebhookBark:
+ if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+ return fmt.Errorf("请填写 Bark Device Key")
+ }
+ case WebhookNtfy:
+ if strings.TrimSpace(in.Webhook.Topic) == "" {
+ return fmt.Errorf("请填写 ntfy Topic")
+ }
+ case WebhookGotify:
+ if strings.TrimSpace(in.Webhook.Key) == "" && !in.Webhook.HasSecret {
+ return fmt.Errorf("请填写 Gotify App Token")
+ }
+ case WebhookCustom:
+ if strings.TrimSpace(in.Webhook.URL) == "" {
+ return fmt.Errorf("请填写 Webhook URL")
+ }
+ default:
+ return fmt.Errorf("不支持的 Webhook 预设")
+ }
+ case NotifyTypeTelegram:
+ token := strings.TrimSpace(in.TelegramToken)
+ if token == "" && !in.Telegram.HasBotToken {
+ return fmt.Errorf("请填写 Telegram Bot Token")
+ }
+ if strings.TrimSpace(in.Telegram.ChatID) == "" {
+ return fmt.Errorf("请填写 Telegram Chat ID")
+ }
+ default:
+ return fmt.Errorf("不支持的通知类型")
+ }
+ return nil
+}
+
+func SanitizeSettings(values map[string]string) {
+ delete(values, settings.KeyNotifySMTPPassword)
+ delete(values, settings.KeyNotifyWebhookSecret)
+ delete(values, settings.KeyNotifyTelegramToken)
+}
+
+func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
+ if values[settings.KeyNotifyType] == "" &&
+ values[settings.KeyNotifyEmailJSON] == "" &&
+ values[settings.KeyNotifyWebhookJSON] == "" &&
+ values[settings.KeyNotifyTelegramJSON] == "" &&
+ values[settings.KeyNotifySMTPPassword] == "" &&
+ values[settings.KeyNotifyWebhookSecret] == "" &&
+ values[settings.KeyNotifyTelegramToken] == "" {
+ return SaveInput{}, false
+ }
+
+ in := SaveInput{
+ Type: NotifyType(values[settings.KeyNotifyType]),
+ SMTPPassword: values[settings.KeyNotifySMTPPassword],
+ WebhookSecret: values[settings.KeyNotifyWebhookSecret],
+ TelegramToken: values[settings.KeyNotifyTelegramToken],
+ }
+ if raw := values[settings.KeyNotifyEmailJSON]; raw != "" {
+ _ = json.Unmarshal([]byte(raw), &in.Email)
+ }
+ if raw := values[settings.KeyNotifyWebhookJSON]; raw != "" {
+ _ = json.Unmarshal([]byte(raw), &in.Webhook)
+ }
+ if raw := values[settings.KeyNotifyTelegramJSON]; raw != "" {
+ _ = json.Unmarshal([]byte(raw), &in.Telegram)
+ }
+ in.OnDDNSIPChange = values[settings.KeyNotifyOnDDNSIPChange] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSIPChange], "true")
+ in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSFailure], "true")
+ if !in.OnDDNSFailure {
+ in.OnDDNSFailure = values[settings.KeyNotifyOnDDNSError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnDDNSError], "true")
+ }
+ in.OnCertExpiry = values[settings.KeyNotifyOnCertExpiry] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertExpiry], "true")
+ if !in.OnCertExpiry {
+ in.OnCertExpiry = values[settings.KeyNotifyOnCertError] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertError], "true")
+ }
+ in.OnCertRenewSuccess = values[settings.KeyNotifyOnCertRenewSuccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewSuccess], "true")
+ in.OnIPFrequentAccess = values[settings.KeyNotifyOnIPFrequentAccess] == "1" || strings.EqualFold(values[settings.KeyNotifyOnIPFrequentAccess], "true")
+ in.OnCertRenewFailure = values[settings.KeyNotifyOnCertRenewFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnCertRenewFailure], "true")
+ in.OnLoginFailure = values[settings.KeyNotifyOnLoginFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnLoginFailure], "true")
+ in.OnNginxReloadFailure = values[settings.KeyNotifyOnNginxReloadFailure] == "1" || strings.EqualFold(values[settings.KeyNotifyOnNginxReloadFailure], "true")
+ in.IPFrequentThreshold = intSettingFromMap(values, settings.KeyNotifyIPFrequentThreshold, DefaultIPFrequentThreshold)
+ in.IPFrequentWindowSec = intSettingFromMap(values, settings.KeyNotifyIPFrequentWindowSec, DefaultIPFrequentWindowSec)
+ in.LoginFailureThreshold = intSettingFromMap(values, settings.KeyNotifyLoginFailureThreshold, DefaultLoginFailureThreshold)
+ in.LoginFailureWindowSec = intSettingFromMap(values, settings.KeyNotifyLoginFailureWindowSec, DefaultLoginFailureWindowSec)
+ return in, true
+}
+
+func intSettingFromMap(values map[string]string, key string, fallback int) int {
+ raw := strings.TrimSpace(values[key])
+ if raw == "" {
+ return fallback
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil || n <= 0 {
+ return fallback
+ }
+ return n
+}
diff --git a/internal/notify/store_test.go b/internal/notify/store_test.go
new file mode 100644
index 0000000..236ceee
--- /dev/null
+++ b/internal/notify/store_test.go
@@ -0,0 +1,86 @@
+package notify
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+
+ "github.com/fonu/fonu/internal/db"
+ "github.com/fonu/fonu/internal/secret"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+func TestStoreSaveTelegramPersistsType(t *testing.T) {
+ dir := t.TempDir()
+ conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+ t.Fatal(err)
+ }
+
+ settingsStore := settings.NewStore(conn)
+ box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(settingsStore, box)
+ if err := store.Save(context.Background(), SaveInput{
+ Type: NotifyTypeTelegram,
+ Telegram: TelegramConfig{
+ ChatID: "743955235",
+ ProxyURL: "http://127.0.0.1:7890",
+ HasBotToken: true,
+ },
+ TelegramToken: "123456:ABC-DEF",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ rawType, err := settingsStore.Get(context.Background(), settings.KeyNotifyType)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rawType != "telegram" {
+ t.Fatalf("notify_type=%q, want telegram", rawType)
+ }
+ cfg, err := store.Load(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Type != NotifyTypeTelegram || cfg.Telegram.ChatID != "743955235" {
+ t.Fatalf("unexpected loaded config: %+v", cfg)
+ }
+}
+
+func TestStoreMigratesLegacyWebhookURL(t *testing.T) {
+ dir := t.TempDir()
+ conn, err := db.Open(filepath.Join(dir, "fonu.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ if err := db.Migrate(context.Background(), conn, filepath.Join("..", "..", "migrations")); err != nil {
+ t.Fatal(err)
+ }
+
+ settingsStore := settings.NewStore(conn)
+ if err := settingsStore.Set(context.Background(), settings.KeyNotifyWebhookURL, "https://example.com/hook"); err != nil {
+ t.Fatal(err)
+ }
+
+ box, err := secret.NewBox("test-secret-key-32bytes-long!!!")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(settingsStore, box)
+ cfg, err := store.Load(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Type != NotifyTypeWebhook || cfg.Webhook.Provider != WebhookCustom || cfg.Webhook.URL != "https://example.com/hook" {
+ t.Fatalf("unexpected migrated config: %+v", cfg)
+ }
+}
diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go
new file mode 100644
index 0000000..2fb7813
--- /dev/null
+++ b/internal/notify/telegram.go
@@ -0,0 +1,69 @@
+package notify
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+func SendTelegram(ctx context.Context, client *http.Client, cfg RuntimeConfig, content AlertContent) error {
+ token := strings.TrimSpace(cfg.TelegramToken)
+ if token == "" {
+ return fmt.Errorf("Telegram Bot Token 未配置")
+ }
+ chatID := strings.TrimSpace(cfg.Telegram.ChatID)
+ if chatID == "" {
+ return fmt.Errorf("Telegram Chat ID 未配置")
+ }
+
+ payload := map[string]string{
+ "chat_id": chatID,
+ "text": content.HTMLBody,
+ "parse_mode": "HTML",
+ }
+ body, _ := json.Marshal(payload)
+ endpoint := "https://api.telegram.org/bot" + token + "/sendMessage"
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ httpClient := client
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: 15 * time.Second}
+ }
+ if proxyURL := strings.TrimSpace(cfg.Telegram.ProxyURL); proxyURL != "" {
+ parsed, err := url.Parse(proxyURL)
+ if err != nil {
+ return fmt.Errorf("Telegram 代理地址无效")
+ }
+ httpClient = &http.Client{
+ Timeout: 15 * time.Second,
+ Transport: &http.Transport{
+ Proxy: http.ProxyURL(parsed),
+ },
+ }
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode >= 400 {
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ if len(raw) > 0 {
+ return fmt.Errorf("Telegram 请求失败 (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+ return fmt.Errorf("Telegram 请求失败 (%d)", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go
new file mode 100644
index 0000000..002f14e
--- /dev/null
+++ b/internal/proxy/proxy.go
@@ -0,0 +1,724 @@
+package proxy
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/fonu/fonu/internal/validate"
+)
+
+type Host struct {
+ ID int64 `json:"id"`
+ Hostname string `json:"hostname"`
+ ListenPort *int `json:"listen_port,omitempty"`
+}
+
+type Rule struct {
+ ID int64 `json:"id"`
+ Domain string `json:"domain"`
+ Upstream string `json:"upstream"`
+ ListenPort int `json:"listen_port"`
+ ListenIPv4 bool `json:"listen_ipv4"`
+ ListenIPv6 bool `json:"listen_ipv6"`
+ Hosts []Host `json:"hosts"`
+ HTTPSEnabled bool `json:"https_enabled"`
+ HTTPRedirect bool `json:"http_redirect"`
+ Enabled bool `json:"enabled"`
+ NginxMode string `json:"nginx_mode"`
+ Name string `json:"name"`
+ SortOrder int `json:"sort_order"`
+ Security SecurityConfig `json:"security"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type PortGroup struct {
+ Port int
+ Hostnames []string
+}
+
+func (r Rule) PrimaryHost() string {
+ if len(r.Hosts) > 0 {
+ return r.Hosts[0].Hostname
+ }
+ return ""
+}
+
+type Endpoint struct {
+ Hostname string
+ Port int
+}
+
+func (r Rule) Endpoints() []Endpoint {
+ seen := make(map[string]bool)
+ var out []Endpoint
+ for _, host := range r.Hosts {
+ name := strings.ToLower(strings.TrimSpace(host.Hostname))
+ if name == "" {
+ continue
+ }
+ port := hostEffectivePort(host, r.ListenPort)
+ key := fmt.Sprintf("%s:%d", name, port)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, Endpoint{Hostname: name, Port: port})
+ }
+ if len(out) == 0 {
+ domain := strings.ToLower(strings.TrimSpace(r.Domain))
+ if domain != "" {
+ out = append(out, Endpoint{Hostname: domain, Port: r.ListenPort})
+ }
+ }
+ return out
+}
+
+func (r Rule) Hostnames() []string {
+ seen := make(map[string]bool)
+ var names []string
+ for _, host := range r.Hosts {
+ name := strings.ToLower(strings.TrimSpace(host.Hostname))
+ if name == "" || seen[name] {
+ continue
+ }
+ seen[name] = true
+ names = append(names, name)
+ }
+ if len(names) == 0 {
+ domain := strings.ToLower(strings.TrimSpace(r.Domain))
+ if domain != "" {
+ names = append(names, domain)
+ }
+ }
+ return names
+}
+
+func (r Rule) PortGroups() []PortGroup {
+ groups := map[int][]string{}
+ ports := make([]int, 0)
+ for _, host := range r.Hosts {
+ port := r.ListenPort
+ if host.ListenPort != nil && *host.ListenPort > 0 {
+ port = *host.ListenPort
+ }
+ if _, ok := groups[port]; !ok {
+ ports = append(ports, port)
+ }
+ groups[port] = append(groups[port], host.Hostname)
+ }
+
+ out := make([]PortGroup, 0, len(ports))
+ for _, port := range ports {
+ out = append(out, PortGroup{Port: port, Hostnames: groups[port]})
+ }
+ return out
+}
+
+type CreateInput struct {
+ Upstream string
+ ListenPort int
+ ListenIPv4 bool
+ ListenIPv6 bool
+ Hosts []string
+ HTTPSEnabled bool
+ HTTPRedirect bool
+ Enabled bool
+ Name string
+ Security SecurityInput
+}
+
+type UpdateInput struct {
+ Upstream *string
+ ListenPort *int
+ ListenIPv4 *bool
+ ListenIPv6 *bool
+ Hosts *[]string
+ HTTPSEnabled *bool
+ HTTPRedirect *bool
+ Enabled *bool
+ Name *string
+ Security *SecurityInput
+}
+
+type Store struct {
+ db *sql.DB
+ tx *sql.Tx
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func NewStoreWithTx(tx *sql.Tx) *Store {
+ return &Store{tx: tx}
+}
+
+func (s *Store) querier() interface {
+ QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+ ExecContext(context.Context, string, ...any) (sql.Result, error)
+} {
+ if s.tx != nil {
+ return s.tx
+ }
+ return s.db
+}
+
+func (s *Store) List(ctx context.Context) ([]Rule, error) {
+ if err := s.cleanupOrphanRules(ctx); err != nil {
+ return nil, err
+ }
+ q := s.querier()
+ rows, err := q.QueryContext(ctx, `
+ SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+ FROM proxy_rules
+ ORDER BY sort_order ASC, id ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var rules []Rule
+ for rows.Next() {
+ rule, err := scanRule(rows)
+ if err != nil {
+ return nil, err
+ }
+ rules = append(rules, rule)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return s.attachHosts(ctx, rules)
+}
+
+func (s *Store) Get(ctx context.Context, id int64) (Rule, error) {
+ row := s.querier().QueryRowContext(ctx, `
+ SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+ FROM proxy_rules WHERE id = ?
+ `, id)
+ rule, err := scanRule(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return Rule{}, fmt.Errorf("规则不存在")
+ }
+ if err != nil {
+ return Rule{}, err
+ }
+ rules, err := s.attachHosts(ctx, []Rule{rule})
+ if err != nil {
+ return Rule{}, err
+ }
+ return rules[0], nil
+}
+
+func (s *Store) Create(ctx context.Context, in CreateInput) (Rule, error) {
+ upstream, hosts, err := validateCreateInput(in)
+ if err != nil {
+ return Rule{}, err
+ }
+ if err := s.ensureHostsAvailable(ctx, hosts, in.ListenPort, 0); err != nil {
+ return Rule{}, err
+ }
+
+ name, err := normalizeName(in.Name)
+ if err != nil {
+ return Rule{}, err
+ }
+ sortOrder, err := s.nextSortOrder(ctx)
+ if err != nil {
+ return Rule{}, err
+ }
+
+ security, err := MergeSecurity(DefaultSecurityConfig(), in.Security)
+ if err != nil {
+ return Rule{}, err
+ }
+ securityJSON, err := security.ToJSON()
+ if err != nil {
+ return Rule{}, err
+ }
+
+ res, err := s.querier().ExecContext(ctx, `
+ INSERT INTO proxy_rules(upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, name, sort_order, security_json, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ `, upstream, in.ListenPort, boolInt(in.ListenIPv4), boolInt(in.ListenIPv6), boolInt(in.HTTPSEnabled), boolInt(in.HTTPRedirect), boolInt(in.Enabled), name, sortOrder, securityJSON)
+ if err != nil {
+ return Rule{}, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return Rule{}, err
+ }
+ if err := s.replaceHosts(ctx, id, in.ListenPort, hosts); err != nil {
+ _, _ = s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+ return Rule{}, err
+ }
+ return s.Get(ctx, id)
+}
+
+func (s *Store) Update(ctx context.Context, id int64, in UpdateInput) (Rule, error) {
+ current, err := s.Get(ctx, id)
+ if err != nil {
+ return Rule{}, err
+ }
+
+ oldListenPort := current.ListenPort
+ upstream := current.Upstream
+ listenPort := current.ListenPort
+ listenIPv4 := current.ListenIPv4
+ listenIPv6 := current.ListenIPv6
+ httpsEnabled := current.HTTPSEnabled
+ httpRedirect := current.HTTPRedirect
+ enabled := current.Enabled
+ name := current.Name
+ hosts := current.Hosts
+
+ if in.Upstream != nil {
+ upstream, err = validate.Upstream(*in.Upstream)
+ if err != nil {
+ return Rule{}, err
+ }
+ }
+ if in.ListenPort != nil {
+ if err := validate.ListenPort(*in.ListenPort); err != nil {
+ return Rule{}, err
+ }
+ listenPort = *in.ListenPort
+ }
+ if in.ListenIPv4 != nil {
+ listenIPv4 = *in.ListenIPv4
+ }
+ if in.ListenIPv6 != nil {
+ listenIPv6 = *in.ListenIPv6
+ }
+ if !listenIPv4 && !listenIPv6 {
+ return Rule{}, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+ }
+ if in.HTTPSEnabled != nil {
+ httpsEnabled = *in.HTTPSEnabled
+ }
+ if in.HTTPRedirect != nil {
+ httpRedirect = *in.HTTPRedirect
+ }
+ if in.Enabled != nil {
+ enabled = *in.Enabled
+ }
+ if in.Name != nil {
+ name, err = normalizeName(*in.Name)
+ if err != nil {
+ return Rule{}, err
+ }
+ }
+ if in.Hosts != nil {
+ hosts, err = parseHosts(*in.Hosts, listenPort)
+ if err != nil {
+ return Rule{}, err
+ }
+ if len(hosts) == 0 {
+ return Rule{}, fmt.Errorf("至少需要一个前端域名")
+ }
+ }
+
+ security := current.Security
+ if in.Security != nil {
+ security, err = MergeSecurity(current.Security, *in.Security)
+ if err != nil {
+ return Rule{}, err
+ }
+ }
+ securityJSON, err := security.ToJSON()
+ if err != nil {
+ return Rule{}, err
+ }
+
+ if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+ for _, host := range current.Hosts {
+ if hostEffectivePort(host, oldListenPort) != oldListenPort {
+ continue
+ }
+ if err := s.ensureHostAvailable(ctx, host.Hostname, listenPort, id); err != nil {
+ return Rule{}, err
+ }
+ }
+ }
+
+ _, err = s.querier().ExecContext(ctx, `
+ UPDATE proxy_rules
+ SET upstream = ?, listen_port = ?, listen_ipv4 = ?, listen_ipv6 = ?, https_enabled = ?, http_redirect = ?, enabled = ?, name = ?, security_json = ?, updated_at = datetime('now')
+ WHERE id = ?
+ `, upstream, listenPort, boolInt(listenIPv4), boolInt(listenIPv6), boolInt(httpsEnabled), boolInt(httpRedirect), boolInt(enabled), name, securityJSON, id)
+ if err != nil {
+ return Rule{}, err
+ }
+ if in.ListenPort != nil && *in.ListenPort != oldListenPort && in.Hosts == nil {
+ if _, err := s.querier().ExecContext(ctx, `
+ UPDATE proxy_hosts SET listen_port = ? WHERE rule_id = ? AND listen_port = ?
+ `, listenPort, id, oldListenPort); err != nil {
+ return Rule{}, err
+ }
+ }
+ if in.Hosts != nil {
+ if err := s.ensureHostsAvailable(ctx, hosts, listenPort, id); err != nil {
+ return Rule{}, err
+ }
+ if err := s.replaceHosts(ctx, id, listenPort, hosts); err != nil {
+ return Rule{}, err
+ }
+ }
+ return s.Get(ctx, id)
+}
+
+func (s *Store) SetNginxMode(ctx context.Context, id int64, mode string) error {
+ mode = strings.TrimSpace(mode)
+ if mode != "auto" && mode != "custom" {
+ return fmt.Errorf("无效的 nginx 模式")
+ }
+ res, err := s.querier().ExecContext(ctx, `
+ UPDATE proxy_rules SET nginx_mode = ?, updated_at = datetime('now') WHERE id = ?
+ `, mode, id)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("规则不存在")
+ }
+ return nil
+}
+
+func (s *Store) Delete(ctx context.Context, id int64) error {
+ res, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_rules WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("规则不存在")
+ }
+ return nil
+}
+
+func (s *Store) ListEnabled(ctx context.Context) ([]Rule, error) {
+ rows, err := s.querier().QueryContext(ctx, `
+ SELECT id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, nginx_mode, name, sort_order, security_json, created_at, updated_at
+ FROM proxy_rules WHERE enabled = 1 ORDER BY sort_order ASC, id ASC
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var rules []Rule
+ for rows.Next() {
+ rule, err := scanRule(rows)
+ if err != nil {
+ return nil, err
+ }
+ rules = append(rules, rule)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return s.attachHosts(ctx, rules)
+}
+
+func validateCreateInput(in CreateInput) (string, []Host, error) {
+ upstream, err := validate.Upstream(in.Upstream)
+ if err != nil {
+ return "", nil, err
+ }
+ if err := validate.ListenPort(in.ListenPort); err != nil {
+ return "", nil, err
+ }
+ if !in.ListenIPv4 && !in.ListenIPv6 {
+ return "", nil, fmt.Errorf("至少需要启用 IPv4 或 IPv6 监听")
+ }
+ hosts, err := parseHosts(in.Hosts, in.ListenPort)
+ if err != nil {
+ return "", nil, err
+ }
+ if len(hosts) == 0 {
+ return "", nil, fmt.Errorf("至少需要一个前端域名")
+ }
+ return upstream, hosts, nil
+}
+
+func (s *Store) cleanupOrphanRules(ctx context.Context) error {
+ _, err := s.querier().ExecContext(ctx, `
+ DELETE FROM proxy_rules
+ WHERE id NOT IN (SELECT rule_id FROM proxy_hosts)
+ `)
+ return err
+}
+
+func hostEffectivePort(host Host, ruleListenPort int) int {
+ if host.ListenPort != nil && *host.ListenPort > 0 {
+ return *host.ListenPort
+ }
+ return ruleListenPort
+}
+
+func parseHosts(raw []string, ruleListenPort int) ([]Host, error) {
+ seen := make(map[string]struct{})
+ hosts := make([]Host, 0, len(raw))
+ for _, item := range raw {
+ hostname, port, err := validate.FrontendAddress(item)
+ if err != nil {
+ return nil, err
+ }
+ effectivePort := ruleListenPort
+ if port > 0 {
+ effectivePort = port
+ }
+ key := fmt.Sprintf("%s:%d", strings.ToLower(hostname), effectivePort)
+ if _, ok := seen[key]; ok {
+ return nil, fmt.Errorf("前端域名 %s:%d 重复", hostname, effectivePort)
+ }
+ seen[key] = struct{}{}
+ host := Host{Hostname: hostname}
+ if port > 0 {
+ host.ListenPort = &port
+ }
+ hosts = append(hosts, host)
+ }
+ return hosts, nil
+}
+
+func (s *Store) ensureHostAvailable(ctx context.Context, hostname string, port int, excludeRuleID int64) error {
+ var ownerID int64
+ err := s.querier().QueryRowContext(ctx, `
+ SELECT rule_id FROM proxy_hosts WHERE hostname = ? COLLATE NOCASE AND listen_port = ? LIMIT 1
+ `, hostname, port).Scan(&ownerID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if ownerID != excludeRuleID {
+ return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", hostname, port)
+ }
+ return nil
+}
+
+func (s *Store) ensureHostsAvailable(ctx context.Context, hosts []Host, ruleListenPort int, excludeRuleID int64) error {
+ for _, host := range hosts {
+ port := hostEffectivePort(host, ruleListenPort)
+ if err := s.ensureHostAvailable(ctx, host.Hostname, port, excludeRuleID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) replaceHosts(ctx context.Context, ruleID int64, ruleListenPort int, hosts []Host) error {
+ if _, err := s.querier().ExecContext(ctx, `DELETE FROM proxy_hosts WHERE rule_id = ?`, ruleID); err != nil {
+ return err
+ }
+ for _, host := range hosts {
+ port := hostEffectivePort(host, ruleListenPort)
+ _, err := s.querier().ExecContext(ctx, `
+ INSERT INTO proxy_hosts(rule_id, hostname, listen_port)
+ VALUES (?, ?, ?)
+ `, ruleID, host.Hostname, port)
+ if err != nil {
+ if strings.Contains(err.Error(), "UNIQUE") {
+ return fmt.Errorf("前端域名 %s:%d 已被其他规则使用", host.Hostname, port)
+ }
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) attachHosts(ctx context.Context, rules []Rule) ([]Rule, error) {
+ if len(rules) == 0 {
+ return rules, nil
+ }
+ ids := make([]string, len(rules))
+ args := make([]any, len(rules))
+ for i, rule := range rules {
+ ids[i] = "?"
+ args[i] = rule.ID
+ }
+
+ query := `
+ SELECT id, rule_id, hostname, listen_port
+ FROM proxy_hosts
+ WHERE rule_id IN (` + strings.Join(ids, ",") + `)
+ ORDER BY id ASC
+ `
+ rows, err := s.querier().QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ byRule := make(map[int64][]Host)
+ for rows.Next() {
+ var host Host
+ var ruleID int64
+ var listenPort sql.NullInt64
+ if err := rows.Scan(&host.ID, &ruleID, &host.Hostname, &listenPort); err != nil {
+ return nil, err
+ }
+ if listenPort.Valid {
+ port := int(listenPort.Int64)
+ host.ListenPort = &port
+ }
+ byRule[ruleID] = append(byRule[ruleID], host)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ for i := range rules {
+ rules[i].Hosts = byRule[rules[i].ID]
+ rules[i].Domain = rules[i].PrimaryHost()
+ }
+ return rules, nil
+}
+
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanRule(row rowScanner) (Rule, error) {
+ var rule Rule
+ var listenIPv4 int
+ var listenIPv6 int
+ var httpsEnabled int
+ var httpRedirect int
+ var enabled int
+ var nginxMode string
+ var securityJSON string
+ var createdAt string
+ var updatedAt string
+ if err := row.Scan(
+ &rule.ID,
+ &rule.Upstream,
+ &rule.ListenPort,
+ &listenIPv4,
+ &listenIPv6,
+ &httpsEnabled,
+ &httpRedirect,
+ &enabled,
+ &nginxMode,
+ &rule.Name,
+ &rule.SortOrder,
+ &securityJSON,
+ &createdAt,
+ &updatedAt,
+ ); err != nil {
+ return Rule{}, err
+ }
+ rule.ListenIPv4 = listenIPv4 == 1
+ rule.ListenIPv6 = listenIPv6 == 1
+ rule.HTTPSEnabled = httpsEnabled == 1
+ rule.HTTPRedirect = httpRedirect == 1
+ rule.Enabled = enabled == 1
+ if nginxMode == "" {
+ nginxMode = "auto"
+ }
+ rule.NginxMode = nginxMode
+ security, err := ParseSecurityJSON(securityJSON)
+ if err != nil {
+ return Rule{}, err
+ }
+ rule.Security = security
+ rule.CreatedAt = parseTime(createdAt)
+ rule.UpdatedAt = parseTime(updatedAt)
+ return rule, nil
+}
+
+func parseTime(v string) time.Time {
+ t, err := time.Parse("2006-01-02 15:04:05", v)
+ if err != nil {
+ t, _ = time.Parse(time.RFC3339, v)
+ }
+ return t.UTC()
+}
+
+func boolInt(v bool) int {
+ if v {
+ return 1
+ }
+ return 0
+}
+
+func normalizeName(raw string) (string, error) {
+ name := strings.TrimSpace(raw)
+ if len(name) > 100 {
+ return "", fmt.Errorf("名称不能超过 100 个字符")
+ }
+ return name, nil
+}
+
+func (s *Store) nextSortOrder(ctx context.Context) (int, error) {
+ var maxOrder sql.NullInt64
+ err := s.querier().QueryRowContext(ctx, `SELECT MAX(sort_order) FROM proxy_rules`).Scan(&maxOrder)
+ if err != nil {
+ return 0, err
+ }
+ if !maxOrder.Valid {
+ return 0, nil
+ }
+ return int(maxOrder.Int64) + 1, nil
+}
+
+func (s *Store) Reorder(ctx context.Context, ids []int64) error {
+ if len(ids) == 0 {
+ return nil
+ }
+ seen := make(map[int64]struct{}, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ return fmt.Errorf("无效的规则 ID")
+ }
+ if _, ok := seen[id]; ok {
+ return fmt.Errorf("排序列表包含重复的规则 ID")
+ }
+ seen[id] = struct{}{}
+ }
+
+ var total int
+ if err := s.querier().QueryRowContext(ctx, `SELECT COUNT(*) FROM proxy_rules`).Scan(&total); err != nil {
+ return err
+ }
+ if len(ids) != total {
+ return fmt.Errorf("排序列表必须包含全部规则")
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ for index, id := range ids {
+ res, err := tx.ExecContext(ctx, `UPDATE proxy_rules SET sort_order = ?, updated_at = datetime('now') WHERE id = ?`, index, id)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("规则不存在")
+ }
+ }
+ return tx.Commit()
+}
diff --git a/internal/proxy/proxy_endpoints_test.go b/internal/proxy/proxy_endpoints_test.go
new file mode 100644
index 0000000..c14ec3f
--- /dev/null
+++ b/internal/proxy/proxy_endpoints_test.go
@@ -0,0 +1,25 @@
+package proxy
+
+import "testing"
+
+func TestRuleEndpointsUsesListenPort(t *testing.T) {
+ rule := Rule{
+ ListenPort: 6893,
+ Hosts: []Host{
+ {Hostname: "xxx.com"},
+ {Hostname: "xxx.com", ListenPort: intPtr(8081)},
+ },
+ }
+ endpoints := rule.Endpoints()
+ if len(endpoints) != 2 {
+ t.Fatalf("expected 2 endpoints, got %d", len(endpoints))
+ }
+ if endpoints[0].Hostname != "xxx.com" || endpoints[0].Port != 6893 {
+ t.Fatalf("unexpected first endpoint: %+v", endpoints[0])
+ }
+ if endpoints[1].Port != 8081 {
+ t.Fatalf("expected custom port 8081, got %d", endpoints[1].Port)
+ }
+}
+
+func intPtr(v int) *int { return &v }
diff --git a/internal/proxy/proxy_hosts_test.go b/internal/proxy/proxy_hosts_test.go
new file mode 100644
index 0000000..2f17b33
--- /dev/null
+++ b/internal/proxy/proxy_hosts_test.go
@@ -0,0 +1,28 @@
+package proxy
+
+import (
+ "testing"
+)
+
+func TestParseHostsAllowsSameHostnameDifferentPorts(t *testing.T) {
+ hosts, err := parseHosts([]string{"example.com", "example.com:6893"}, 8011)
+ if err != nil {
+ t.Fatalf("parseHosts failed: %v", err)
+ }
+ if len(hosts) != 2 {
+ t.Fatalf("expected 2 hosts, got %d", len(hosts))
+ }
+ if hostEffectivePort(hosts[0], 8011) != 8011 {
+ t.Fatalf("expected default port 8011, got %d", hostEffectivePort(hosts[0], 8011))
+ }
+ if hostEffectivePort(hosts[1], 8011) != 6893 {
+ t.Fatalf("expected custom port 6893, got %d", hostEffectivePort(hosts[1], 8011))
+ }
+}
+
+func TestParseHostsRejectsDuplicateBinding(t *testing.T) {
+ _, err := parseHosts([]string{"example.com", "example.com"}, 8011)
+ if err == nil {
+ t.Fatal("expected duplicate binding error")
+ }
+}
diff --git a/internal/proxy/security.go b/internal/proxy/security.go
new file mode 100644
index 0000000..ff3d077
--- /dev/null
+++ b/internal/proxy/security.go
@@ -0,0 +1,292 @@
+package proxy
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/validate"
+)
+
+type BasicAuthConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ Username string `json:"username,omitempty"`
+ PasswordHash string `json:"password_hash,omitempty"` // bcrypt htpasswd line body
+ HasPassword bool `json:"has_password,omitempty"` // API response only
+}
+
+type RateLimitConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ Rate int `json:"rate,omitempty"` // requests per second
+ Burst int `json:"burst,omitempty"` // burst count
+}
+
+type ConnLimitConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+ Max int `json:"max,omitempty"` // max concurrent connections per IP
+}
+
+type SecurityConfig struct {
+ IPBlacklist []string `json:"ip_blacklist,omitempty"`
+ IPWhitelist []string `json:"ip_whitelist,omitempty"`
+ IPWhitelistMode bool `json:"ip_whitelist_mode,omitempty"`
+ ChinaOnly bool `json:"china_only,omitempty"`
+ BasicAuth *BasicAuthConfig `json:"basic_auth,omitempty"`
+ RateLimit *RateLimitConfig `json:"rate_limit,omitempty"`
+ ConnLimit *ConnLimitConfig `json:"conn_limit,omitempty"`
+ ProxySSLVerifyOff bool `json:"proxy_ssl_verify_off,omitempty"`
+ ProxyHostUpstream bool `json:"proxy_host_upstream,omitempty"` // use $proxy_host instead of $host
+ TLSMin13Only bool `json:"tls_min_13_only,omitempty"`
+ SecurityHeaders bool `json:"security_headers,omitempty"`
+}
+
+type SecurityInput struct {
+ IPBlacklist []string
+ IPWhitelist []string
+ IPWhitelistMode *bool
+ ChinaOnly *bool
+ BasicAuth *BasicAuthInput
+ RateLimit *RateLimitConfig
+ ConnLimit *ConnLimitConfig
+ ProxySSLVerifyOff *bool
+ ProxyHostUpstream *bool
+ TLSMin13Only *bool
+ SecurityHeaders *bool
+}
+
+type BasicAuthInput struct {
+ Enabled *bool
+ Username *string
+ Password *string // plaintext on write only
+}
+
+// DefaultPrivateCIDRs are always exempt from china_only restrictions.
+var DefaultPrivateCIDRs = []string{
+ "10.0.0.0/8",
+ "127.0.0.0/8",
+ "172.16.0.0/12",
+ "192.168.0.0/16",
+ "::1/128",
+ "fc00::/7",
+}
+
+func ChinaBypassCIDRs(extra []string) []string {
+ seen := make(map[string]bool, len(DefaultPrivateCIDRs)+len(extra))
+ out := make([]string, 0, len(DefaultPrivateCIDRs)+len(extra))
+ for _, cidr := range DefaultPrivateCIDRs {
+ if !seen[cidr] {
+ seen[cidr] = true
+ out = append(out, cidr)
+ }
+ }
+ for _, cidr := range extra {
+ cidr = strings.TrimSpace(cidr)
+ if cidr != "" && !seen[cidr] {
+ seen[cidr] = true
+ out = append(out, cidr)
+ }
+ }
+ return out
+}
+
+func DefaultSecurityConfig() SecurityConfig {
+ return SecurityConfig{}
+}
+
+func ParseSecurityJSON(raw string) (SecurityConfig, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" || raw == "{}" {
+ return DefaultSecurityConfig(), nil
+ }
+ var cfg SecurityConfig
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ return SecurityConfig{}, fmt.Errorf("安全配置格式无效")
+ }
+ return cfg.Normalize(), nil
+}
+
+func (c SecurityConfig) Normalize() SecurityConfig {
+ if c.RateLimit != nil && c.RateLimit.Enabled {
+ if c.RateLimit.Rate <= 0 {
+ c.RateLimit.Rate = 10
+ }
+ if c.RateLimit.Burst <= 0 {
+ c.RateLimit.Burst = 20
+ }
+ }
+ if c.ConnLimit != nil && c.ConnLimit.Enabled {
+ if c.ConnLimit.Max <= 0 {
+ c.ConnLimit.Max = 20
+ }
+ }
+ return c
+}
+
+func RuleForAPI(rule Rule) Rule {
+ rule.Security = rule.Security.ForAPI()
+ return rule
+}
+
+func RulesForAPI(rules []Rule) []Rule {
+ out := make([]Rule, len(rules))
+ for i, rule := range rules {
+ out[i] = RuleForAPI(rule)
+ }
+ return out
+}
+
+func (c SecurityConfig) ForAPI() SecurityConfig {
+ out := c.Normalize()
+ if out.BasicAuth != nil {
+ hasPassword := out.BasicAuth.PasswordHash != ""
+ out.BasicAuth = &BasicAuthConfig{
+ Enabled: out.BasicAuth.Enabled,
+ Username: out.BasicAuth.Username,
+ HasPassword: hasPassword,
+ }
+ }
+ return out
+}
+
+func (c SecurityConfig) MarshalJSON() ([]byte, error) {
+ type alias SecurityConfig
+ return json.Marshal(alias(c.Normalize()))
+}
+
+func (c SecurityConfig) ToJSON() (string, error) {
+ b, err := c.MarshalJSON()
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func (c SecurityConfig) BasicAuthEnabled() bool {
+ return c.BasicAuth != nil && c.BasicAuth.Enabled && c.BasicAuth.Username != "" && c.BasicAuth.PasswordHash != ""
+}
+
+func (c SecurityConfig) RateLimitEnabled() bool {
+ return c.RateLimit != nil && c.RateLimit.Enabled
+}
+
+func (c SecurityConfig) ConnLimitEnabled() bool {
+ return c.ConnLimit != nil && c.ConnLimit.Enabled
+}
+
+func (c SecurityConfig) WhitelistEnabled() bool {
+ return c.IPWhitelistMode && len(c.IPWhitelist) > 0
+}
+
+func MergeSecurity(current SecurityConfig, in SecurityInput) (SecurityConfig, error) {
+ out := current.Normalize()
+
+ if in.IPBlacklist != nil {
+ out.IPBlacklist = in.IPBlacklist
+ }
+ if in.IPWhitelist != nil {
+ out.IPWhitelist = in.IPWhitelist
+ }
+ if in.IPWhitelistMode != nil {
+ out.IPWhitelistMode = *in.IPWhitelistMode
+ }
+ if in.ChinaOnly != nil {
+ out.ChinaOnly = *in.ChinaOnly
+ }
+ if in.RateLimit != nil {
+ out.RateLimit = in.RateLimit
+ }
+ if in.ConnLimit != nil {
+ out.ConnLimit = in.ConnLimit
+ }
+ if in.ProxySSLVerifyOff != nil {
+ out.ProxySSLVerifyOff = *in.ProxySSLVerifyOff
+ }
+ if in.ProxyHostUpstream != nil {
+ out.ProxyHostUpstream = *in.ProxyHostUpstream
+ }
+ if in.TLSMin13Only != nil {
+ out.TLSMin13Only = *in.TLSMin13Only
+ }
+ if in.SecurityHeaders != nil {
+ out.SecurityHeaders = *in.SecurityHeaders
+ }
+
+ if in.BasicAuth != nil {
+ if in.BasicAuth.Enabled != nil {
+ if out.BasicAuth == nil {
+ out.BasicAuth = &BasicAuthConfig{}
+ }
+ out.BasicAuth.Enabled = *in.BasicAuth.Enabled
+ }
+ if in.BasicAuth.Username != nil {
+ if out.BasicAuth == nil {
+ out.BasicAuth = &BasicAuthConfig{}
+ }
+ out.BasicAuth.Username = strings.TrimSpace(*in.BasicAuth.Username)
+ }
+ if in.BasicAuth.Password != nil && strings.TrimSpace(*in.BasicAuth.Password) != "" {
+ if out.BasicAuth == nil {
+ out.BasicAuth = &BasicAuthConfig{}
+ }
+ hash, err := validate.BasicAuthPasswordHash(out.BasicAuth.Username, *in.BasicAuth.Password)
+ if err != nil {
+ return SecurityConfig{}, err
+ }
+ out.BasicAuth.PasswordHash = hash
+ }
+ }
+
+ return out, ValidateSecurity(out)
+}
+
+func ValidateSecurity(cfg SecurityConfig) error {
+ cfg = cfg.Normalize()
+ if err := validate.IPOrCIDRList(cfg.IPBlacklist); err != nil {
+ return fmt.Errorf("IP 黑名单:%w", err)
+ }
+ if err := validate.IPOrCIDRList(cfg.IPWhitelist); err != nil {
+ return fmt.Errorf("IP 白名单:%w", err)
+ }
+ if cfg.IPWhitelistMode && len(cfg.IPWhitelist) == 0 {
+ return fmt.Errorf("启用白名单模式时至少添加一条 IP 或 CIDR")
+ }
+ if cfg.BasicAuth != nil && cfg.BasicAuth.Enabled {
+ if err := validate.Username(cfg.BasicAuth.Username); err != nil {
+ return fmt.Errorf("Basic Auth:%w", err)
+ }
+ if cfg.BasicAuth.PasswordHash == "" {
+ return fmt.Errorf("Basic Auth 需要设置密码")
+ }
+ }
+ if cfg.RateLimit != nil && cfg.RateLimit.Enabled {
+ if cfg.RateLimit.Rate < 1 || cfg.RateLimit.Rate > 10000 {
+ return fmt.Errorf("限流速率无效(1-10000 次/秒)")
+ }
+ if cfg.RateLimit.Burst < 1 || cfg.RateLimit.Burst > 100000 {
+ return fmt.Errorf("限流突发值无效(1-100000)")
+ }
+ }
+ if cfg.ConnLimit != nil && cfg.ConnLimit.Enabled {
+ if cfg.ConnLimit.Max < 1 || cfg.ConnLimit.Max > 10000 {
+ return fmt.Errorf("连接数限制无效(1-10000)")
+ }
+ }
+ return nil
+}
+
+func ParseIPListText(text string) []string {
+ lines := strings.Split(text, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ out = append(out, line)
+ }
+ return out
+}
+
+func FormatIPListText(list []string) string {
+ return strings.Join(list, "\n")
+}
diff --git a/internal/proxy/security_test.go b/internal/proxy/security_test.go
new file mode 100644
index 0000000..87613b6
--- /dev/null
+++ b/internal/proxy/security_test.go
@@ -0,0 +1,102 @@
+package proxy
+
+import (
+ "testing"
+
+ "github.com/fonu/fonu/internal/validate"
+)
+
+func TestForAPIStripsPasswordHashButRuntimeKeepsAuth(t *testing.T) {
+ hash := "$2a$10$abcdefghijklmnopqrstuv"
+ sec := SecurityConfig{
+ BasicAuth: &BasicAuthConfig{
+ Enabled: true,
+ Username: "admin",
+ PasswordHash: hash,
+ },
+ }
+ if !sec.BasicAuthEnabled() {
+ t.Fatal("expected runtime basic auth enabled")
+ }
+ api := sec.ForAPI()
+ if api.BasicAuth.PasswordHash != "" {
+ t.Fatal("expected API view to strip password hash")
+ }
+ if !api.BasicAuth.HasPassword {
+ t.Fatal("expected has_password flag in API view")
+ }
+ if api.BasicAuthEnabled() {
+ t.Fatal("API view must not be treated as nginx-ready basic auth config")
+ }
+}
+
+func TestMergeSecurityBasicAuthKeepsHash(t *testing.T) {
+ hash, err := validate.BasicAuthPasswordHash("admin", "password123")
+ if err != nil {
+ t.Fatal(err)
+ }
+ current := SecurityConfig{
+ BasicAuth: &BasicAuthConfig{
+ Enabled: true,
+ Username: "admin",
+ PasswordHash: hash,
+ },
+ }
+ enabled := true
+ out, err := MergeSecurity(current, SecurityInput{
+ BasicAuth: &BasicAuthInput{Enabled: &enabled, Username: strPtr("admin")},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.BasicAuth.PasswordHash != hash {
+ t.Fatalf("expected hash preserved")
+ }
+}
+
+func TestValidateSecurityWhitelistRequiresEntries(t *testing.T) {
+ err := ValidateSecurity(SecurityConfig{IPWhitelistMode: true})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestChinaBypassCIDRsIncludesDefaults(t *testing.T) {
+ got := ChinaBypassCIDRs(nil)
+ for _, want := range DefaultPrivateCIDRs {
+ if !contains(got, want) {
+ t.Fatalf("missing default %s in %v", want, got)
+ }
+ }
+}
+
+func TestChinaBypassCIDRsMergesExtra(t *testing.T) {
+ got := ChinaBypassCIDRs([]string{"203.0.113.0/24", "192.168.0.0/16"})
+ if !contains(got, "203.0.113.0/24") {
+ t.Fatalf("missing extra CIDR: %v", got)
+ }
+ if count(got, "192.168.0.0/16") != 1 {
+ t.Fatalf("expected single 192.168.0.0/16 entry: %v", got)
+ }
+}
+
+func contains(ss []string, s string) bool {
+ for _, v := range ss {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+func count(ss []string, s string) int {
+ n := 0
+ for _, v := range ss {
+ if v == s {
+ n++
+ }
+ }
+ return n
+}
+
+func strPtr(s string) *string { return &s }
diff --git a/internal/publicip/publicip.go b/internal/publicip/publicip.go
new file mode 100644
index 0000000..716a53a
--- /dev/null
+++ b/internal/publicip/publicip.go
@@ -0,0 +1,122 @@
+package publicip
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "strings"
+ "time"
+)
+
+var ipv4Providers = []string{
+ "https://api.ipify.org",
+ "https://ipv4.icanhazip.com",
+ "https://ifconfig.me/ip",
+ "http://ipv4.icanhazip.com",
+}
+
+var ipv6Providers = []string{
+ "https://api64.ipify.org",
+ "https://ipv6.icanhazip.com",
+}
+
+func Detect(ctx context.Context) (ipv4, ipv6 string, err error) {
+ ipv4, v4err := detectIPv4(ctx)
+ ipv6, _ = detectIPv6(ctx)
+ if v4err != nil {
+ return "", ipv6, v4err
+ }
+ return ipv4, ipv6, nil
+}
+
+func detectClient() *http.Client {
+ return &http.Client{
+ Timeout: 8 * time.Second,
+ Transport: &http.Transport{
+ Proxy: nil,
+ DialContext: (&net.Dialer{
+ Timeout: 8 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ },
+ }
+}
+
+func detectIPv4(ctx context.Context) (string, error) {
+ client := detectClient()
+ var lastErr error
+ for _, url := range ipv4Providers {
+ ip, err := fetchIP(ctx, client, url, false)
+ if err == nil {
+ return ip, nil
+ }
+ lastErr = err
+ }
+ return "", fmt.Errorf("获取公网 IPv4 失败:%w", lastErr)
+}
+
+func detectIPv6(ctx context.Context) (string, error) {
+ client := detectClient()
+ for _, url := range ipv6Providers {
+ ip, err := fetchIP(ctx, client, url, true)
+ if err == nil {
+ return ip, nil
+ }
+ }
+ return "", fmt.Errorf("no ipv6")
+}
+
+func fetchIP(ctx context.Context, client *http.Client, url string, wantV6 bool) (string, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return "", err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("http %d", resp.StatusCode)
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 128))
+ if err != nil {
+ return "", err
+ }
+ ip := strings.TrimSpace(string(body))
+ parsed := net.ParseIP(ip)
+ if parsed == nil {
+ return "", fmt.Errorf("invalid ip response")
+ }
+ if wantV6 && parsed.To4() != nil {
+ return "", fmt.Errorf("no ipv6")
+ }
+ if !wantV6 && parsed.To4() == nil {
+ return "", fmt.Errorf("no ipv4")
+ }
+ if !wantV6 && !IsPublicIPv4(ip) {
+ return "", fmt.Errorf("private ipv4")
+ }
+ if wantV6 && !IsPublicIPv6(ip) {
+ return "", fmt.Errorf("private ipv6")
+ }
+ return ip, nil
+}
+
+func IsPublicIPv4(ip string) bool {
+ parsed := net.ParseIP(strings.TrimSpace(ip))
+ if parsed == nil || parsed.To4() == nil {
+ return false
+ }
+ return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
+
+func IsPublicIPv6(ip string) bool {
+ parsed := net.ParseIP(strings.TrimSpace(ip))
+ if parsed == nil || parsed.To4() != nil {
+ return false
+ }
+ return !parsed.IsPrivate() && !parsed.IsLoopback() && !parsed.IsLinkLocalUnicast() && !parsed.IsUnspecified()
+}
diff --git a/internal/publicip/publicip_test.go b/internal/publicip/publicip_test.go
new file mode 100644
index 0000000..ed457cb
--- /dev/null
+++ b/internal/publicip/publicip_test.go
@@ -0,0 +1,12 @@
+package publicip
+
+import "testing"
+
+func TestIsPublicIPv4(t *testing.T) {
+ if IsPublicIPv4("192.168.1.1") {
+ t.Fatal("private ip should be rejected")
+ }
+ if !IsPublicIPv4("8.8.8.8") {
+ t.Fatal("public ip should be accepted")
+ }
+}
diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go
new file mode 100644
index 0000000..9b8aa41
--- /dev/null
+++ b/internal/scheduler/scheduler.go
@@ -0,0 +1,40 @@
+package scheduler
+
+import (
+ "context"
+ "time"
+)
+
+type Job struct {
+ Name string
+ Interval time.Duration
+ Run func(ctx context.Context)
+}
+
+type Scheduler struct {
+ jobs []Job
+}
+
+func New(jobs ...Job) *Scheduler {
+ return &Scheduler{jobs: jobs}
+}
+
+func (s *Scheduler) Start(ctx context.Context) {
+ for _, job := range s.jobs {
+ go s.runJob(ctx, job)
+ }
+}
+
+func (s *Scheduler) runJob(ctx context.Context, job Job) {
+ ticker := time.NewTicker(job.Interval)
+ defer ticker.Stop()
+ job.Run(ctx)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ job.Run(ctx)
+ }
+ }
+}
diff --git a/internal/secret/secret.go b/internal/secret/secret.go
new file mode 100644
index 0000000..511420d
--- /dev/null
+++ b/internal/secret/secret.go
@@ -0,0 +1,84 @@
+package secret
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+)
+
+var ErrEmpty = errors.New("secret is empty")
+
+type Box struct {
+ gcm cipher.AEAD
+}
+
+func NewBox(keyMaterial string) (*Box, error) {
+ if keyMaterial == "" {
+ return nil, fmt.Errorf("encryption key material is required")
+ }
+ sum := sha256.Sum256([]byte(keyMaterial))
+ block, err := aes.NewCipher(sum[:])
+ if err != nil {
+ return nil, err
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+ return &Box{gcm: gcm}, nil
+}
+
+func (b *Box) Encrypt(plain string) (string, error) {
+ if plain == "" {
+ return "", ErrEmpty
+ }
+ nonce := make([]byte, b.gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+ ciphertext := b.gcm.Seal(nonce, nonce, []byte(plain), nil)
+ return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+func (b *Box) Decrypt(encoded string) (string, error) {
+ if encoded == "" {
+ return "", ErrEmpty
+ }
+ raw, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", err
+ }
+ nonceSize := b.gcm.NonceSize()
+ if len(raw) < nonceSize {
+ return "", fmt.Errorf("invalid ciphertext")
+ }
+ nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
+ plain, err := b.gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return "", err
+ }
+ return string(plain), nil
+}
+
+func IsDecryptFailure(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), "message authentication failed")
+}
+
+func DecryptHint(err error) error {
+ if err == nil {
+ return nil
+ }
+ if IsDecryptFailure(err) {
+ return fmt.Errorf("DNS API 凭据无法解密,请在 DDNS 页面重新保存 API Token(服务加密密钥可能已变更)")
+ }
+ return err
+}
diff --git a/internal/service/nginx_config.go b/internal/service/nginx_config.go
new file mode 100644
index 0000000..e67eeaa
--- /dev/null
+++ b/internal/service/nginx_config.go
@@ -0,0 +1,331 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/fonu/fonu/internal/nginx"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+type NginxBackupInfo struct {
+ Name string `json:"name"`
+ CreatedAt string `json:"created_at"`
+}
+
+type RuleNginxView struct {
+ Mode string `json:"mode"`
+ Enabled bool `json:"enabled"`
+ Active bool `json:"active"`
+ Generated string `json:"generated"`
+ Content string `json:"content"`
+ Backups []NginxBackupInfo `json:"backups"`
+}
+
+type GlobalNginxView struct {
+ Mode string `json:"mode"`
+ GeneratedFramework string `json:"generated_framework"`
+ GeneratedSnippet string `json:"generated_snippet"`
+ Content string `json:"content"`
+ Backups []NginxBackupInfo `json:"backups"`
+}
+
+type SaveRuleNginxInput struct {
+ Mode string
+ Content *string
+}
+
+type SaveGlobalNginxInput struct {
+ Mode string
+ Content *string
+}
+
+func (s *ProxyService) GetRuleNginx(ctx context.Context, id int64) (RuleNginxView, error) {
+ rule, err := s.Get(ctx, id)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ certs, err := s.loadCertSources(ctx)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ opts := s.loadGenerateOptions(ctx)
+ generated, err := nginx.GenerateRuleBlocks(s.cfg, rule, certs, opts)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ mode := rule.NginxMode
+ if mode == "" {
+ mode = "auto"
+ }
+ content := generated
+ if mode == "custom" {
+ custom, err := nginx.ReadRuleCustom(s.cfg, id)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ if strings.TrimSpace(custom) != "" {
+ content = custom
+ }
+ }
+ backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ return RuleNginxView{
+ Mode: mode,
+ Enabled: rule.Enabled,
+ Active: rule.Enabled,
+ Generated: generated,
+ Content: content,
+ Backups: mapBackups(backups),
+ }, nil
+}
+
+func (s *ProxyService) SaveRuleNginx(ctx context.Context, id int64, in SaveRuleNginxInput) (RuleNginxView, error) {
+ rule, err := s.Get(ctx, id)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ mode := strings.TrimSpace(in.Mode)
+ if mode == "" {
+ mode = rule.NginxMode
+ }
+ if mode != "auto" && mode != "custom" {
+ return RuleNginxView{}, fmt.Errorf("无效的 nginx 模式")
+ }
+
+ if mode == "auto" {
+ if err := s.store.SetNginxMode(ctx, id, "auto"); err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return RuleNginxView{}, err
+ }
+ return s.GetRuleNginx(ctx, id)
+ }
+
+ if in.Content == nil {
+ return RuleNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+ }
+ content := *in.Content
+ if strings.TrimSpace(content) == "" {
+ return RuleNginxView{}, fmt.Errorf("Nginx 配置不能为空")
+ }
+
+ if err := s.validateRuleNginxPending(ctx, rule, content); err != nil {
+ return RuleNginxView{}, err
+ }
+ if _, err := nginx.SaveRuleCustomWithBackup(s.cfg, id, content); err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return RuleNginxView{}, err
+ }
+ return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) RollbackRuleNginx(ctx context.Context, id int64, backupName string) (RuleNginxView, error) {
+ if _, err := s.Get(ctx, id); err != nil {
+ return RuleNginxView{}, err
+ }
+ backups, err := nginx.ListBackups(s.cfg.NginxRuleBackupsDir(id))
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ if len(backups) == 0 {
+ return RuleNginxView{}, fmt.Errorf("没有可回滚的备份")
+ }
+ if backupName == "" {
+ backupName = backups[0].Name
+ }
+ if err := nginx.RestoreBackup(s.cfg.NginxRuleBackupsDir(id), backupName, s.cfg.NginxRuleCustomPath(id)); err != nil {
+ return RuleNginxView{}, err
+ }
+ custom, err := nginx.ReadRuleCustom(s.cfg, id)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ rule, err := s.Get(ctx, id)
+ if err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.validateRuleNginxPending(ctx, rule, custom); err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.store.SetNginxMode(ctx, id, "custom"); err != nil {
+ return RuleNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return RuleNginxView{}, err
+ }
+ return s.GetRuleNginx(ctx, id)
+}
+
+func (s *ProxyService) GetGlobalNginx(ctx context.Context) (GlobalNginxView, error) {
+ mode, err := s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ if mode == "" {
+ mode = "auto"
+ }
+ content, err := nginx.ReadGlobalCustom(s.cfg)
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ return GlobalNginxView{
+ Mode: mode,
+ GeneratedFramework: nginx.GenerateGlobalFramework(s.cfg),
+ GeneratedSnippet: nginx.DefaultGlobalHTTPSnippet(),
+ Content: content,
+ Backups: mapBackups(backups),
+ }, nil
+}
+
+func (s *ProxyService) SaveGlobalNginx(ctx context.Context, in SaveGlobalNginxInput) (GlobalNginxView, error) {
+ mode := strings.TrimSpace(in.Mode)
+ if mode == "" {
+ var err error
+ mode, err = s.settings.Get(ctx, settings.KeyNginxGlobalMode)
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ }
+ if mode != "auto" && mode != "custom" {
+ return GlobalNginxView{}, fmt.Errorf("无效的 nginx 模式")
+ }
+
+ if mode == "auto" {
+ if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "auto"); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return GlobalNginxView{}, err
+ }
+ return s.GetGlobalNginx(ctx)
+ }
+
+ if in.Content == nil {
+ return GlobalNginxView{}, fmt.Errorf("手动模式需要提供配置内容")
+ }
+ content := *in.Content
+ if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if _, err := nginx.SaveGlobalCustomWithBackup(s.cfg, content); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return GlobalNginxView{}, err
+ }
+ return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) RollbackGlobalNginx(ctx context.Context, backupName string) (GlobalNginxView, error) {
+ backups, err := nginx.ListBackups(s.cfg.NginxGlobalBackupsDir())
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ if len(backups) == 0 {
+ return GlobalNginxView{}, fmt.Errorf("没有可回滚的备份")
+ }
+ if backupName == "" {
+ backupName = backups[0].Name
+ }
+ if err := nginx.RestoreBackup(s.cfg.NginxGlobalBackupsDir(), backupName, s.cfg.NginxGlobalCustomPath()); err != nil {
+ return GlobalNginxView{}, err
+ }
+ content, err := nginx.ReadGlobalCustom(s.cfg)
+ if err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.validateGlobalNginxPending(ctx, content); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.settings.Set(ctx, settings.KeyNginxGlobalMode, "custom"); err != nil {
+ return GlobalNginxView{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return GlobalNginxView{}, err
+ }
+ return s.GetGlobalNginx(ctx)
+}
+
+func (s *ProxyService) validateRuleNginxPending(ctx context.Context, rule proxy.Rule, content string) error {
+ rules, err := s.store.ListEnabled(ctx)
+ if err != nil {
+ return err
+ }
+ merged := make([]proxy.Rule, 0, len(rules))
+ found := false
+ for _, r := range rules {
+ if r.ID == rule.ID {
+ ruleCopy := rule
+ ruleCopy.NginxMode = "custom"
+ ruleCopy.Enabled = true
+ merged = append(merged, ruleCopy)
+ found = true
+ continue
+ }
+ merged = append(merged, r)
+ }
+ if !found && rule.Enabled {
+ ruleCopy := rule
+ ruleCopy.NginxMode = "custom"
+ merged = append(merged, ruleCopy)
+ }
+ return s.validateWithOverrides(ctx, merged, nginx.GenerateOptions{
+ RuleCustomOverrides: map[int64]string{rule.ID: content},
+ })
+}
+
+func (s *ProxyService) validateGlobalNginxPending(ctx context.Context, content string) error {
+ rules, err := s.store.ListEnabled(ctx)
+ if err != nil {
+ return err
+ }
+ return s.validateWithOverrides(ctx, rules, nginx.GenerateOptions{
+ GlobalCustomOverride: content,
+ })
+}
+
+func (s *ProxyService) validateWithOverrides(ctx context.Context, rules []proxy.Rule, extra nginx.GenerateOptions) error {
+ certs, err := s.loadCertSources(ctx)
+ if err != nil {
+ return err
+ }
+ opts := s.loadGenerateOptions(ctx)
+ opts.GlobalCustomOverride = extra.GlobalCustomOverride
+ opts.GlobalCustomPathOverride = extra.GlobalCustomPathOverride
+ opts.RuleCustomOverrides = extra.RuleCustomOverrides
+ opts.ChinaCIDRAvailable = nginx.ChinaCIDRExists(s.cfg)
+ content, err := nginx.Generate(s.cfg, rules, certs, opts)
+ if err != nil {
+ return err
+ }
+ return s.nginx.ValidateContent(ctx, content)
+}
+
+func mapBackups(entries []nginx.BackupEntry) []NginxBackupInfo {
+ out := make([]NginxBackupInfo, 0, len(entries))
+ for _, entry := range entries {
+ out = append(out, NginxBackupInfo{
+ Name: entry.Name,
+ CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
+ })
+ }
+ return out
+}
diff --git a/internal/service/proxy.go b/internal/service/proxy.go
new file mode 100644
index 0000000..e017e4c
--- /dev/null
+++ b/internal/service/proxy.go
@@ -0,0 +1,199 @@
+package service
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strconv"
+
+ "github.com/fonu/fonu/internal/certificate"
+ "github.com/fonu/fonu/internal/config"
+ "github.com/fonu/fonu/internal/nginx"
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/proxy"
+ "github.com/fonu/fonu/internal/settings"
+ "github.com/fonu/fonu/internal/validate"
+)
+
+type ProxyService struct {
+ db *sql.DB
+ cfg config.Config
+ store *proxy.Store
+ certStore *certificate.Store
+ settings *settings.Store
+ nginx *nginx.Manager
+ notify *notify.Service
+}
+
+func NewProxyService(cfg config.Config, db *sql.DB, store *proxy.Store, certStore *certificate.Store, settingsStore *settings.Store, nginxMgr *nginx.Manager) *ProxyService {
+ return &ProxyService{db: db, cfg: cfg, store: store, certStore: certStore, settings: settingsStore, nginx: nginxMgr}
+}
+
+func (s *ProxyService) SetNotify(notifySvc *notify.Service) {
+ s.notify = notifySvc
+}
+
+func (s *ProxyService) List(ctx context.Context) ([]proxy.Rule, error) {
+ return s.store.List(ctx)
+}
+
+func (s *ProxyService) Get(ctx context.Context, id int64) (proxy.Rule, error) {
+ return s.store.Get(ctx, id)
+}
+
+func (s *ProxyService) Create(ctx context.Context, in proxy.CreateInput) (proxy.Rule, error) {
+ if err := s.validateHTTPSInput(ctx, in.Hosts, in.HTTPSEnabled); err != nil {
+ return proxy.Rule{}, err
+ }
+
+ rule, err := s.store.Create(ctx, in)
+ if err != nil {
+ return proxy.Rule{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+ }
+ return rule, nil
+}
+
+func (s *ProxyService) Update(ctx context.Context, id int64, in proxy.UpdateInput) (proxy.Rule, error) {
+ current, err := s.Get(ctx, id)
+ if err != nil {
+ return proxy.Rule{}, err
+ }
+ httpsEnabled := current.HTTPSEnabled
+ if in.HTTPSEnabled != nil {
+ httpsEnabled = *in.HTTPSEnabled
+ }
+ hosts := hostsFromRule(current)
+ if in.Hosts != nil {
+ hosts = *in.Hosts
+ }
+ if err := s.validateHTTPSInput(ctx, hosts, httpsEnabled); err != nil {
+ return proxy.Rule{}, err
+ }
+
+ rule, err := s.store.Update(ctx, id, in)
+ if err != nil {
+ return proxy.Rule{}, err
+ }
+ if err := s.applyNginx(ctx); err != nil {
+ return rule, fmt.Errorf("规则已保存,但 Nginx 重载失败:%w", err)
+ }
+ return rule, nil
+}
+
+func (s *ProxyService) Reorder(ctx context.Context, ids []int64) error {
+ return s.store.Reorder(ctx, ids)
+}
+
+func (s *ProxyService) Delete(ctx context.Context, id int64) error {
+ if err := s.store.Delete(ctx, id); err != nil {
+ return err
+ }
+ _ = nginx.RemoveRuleCustom(s.cfg, id)
+ if err := s.applyNginx(ctx); err != nil {
+ return fmt.Errorf("规则已删除,但 Nginx 重载失败:%w", err)
+ }
+ return nil
+}
+
+func (s *ProxyService) ReloadAll(ctx context.Context) error {
+ return s.applyNginx(ctx)
+}
+
+func (s *ProxyService) applyNginx(ctx context.Context) error {
+ rules, err := s.store.ListEnabled(ctx)
+ if err != nil {
+ return err
+ }
+ certs, err := s.loadCertSources(ctx)
+ if err != nil {
+ return err
+ }
+ s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+ _, err = s.nginx.Apply(ctx, rules, certs)
+ if err != nil && s.notify != nil {
+ s.notify.Alert(ctx, notify.EventNginxReloadFailure, "Nginx 重载失败", err.Error())
+ }
+ return err
+}
+
+func (s *ProxyService) ValidateRule(ctx context.Context, candidate proxy.Rule, existing []proxy.Rule) error {
+ rules := make([]proxy.Rule, 0, len(existing)+1)
+ replaced := false
+ for _, r := range existing {
+ if r.ID == candidate.ID {
+ if candidate.Enabled {
+ rules = append(rules, candidate)
+ }
+ replaced = true
+ continue
+ }
+ if r.Enabled {
+ rules = append(rules, r)
+ }
+ }
+ if !replaced && candidate.Enabled {
+ rules = append(rules, candidate)
+ }
+ certs, err := s.loadCertSources(ctx)
+ if err != nil {
+ return err
+ }
+ s.nginx.SetGenerateOptions(s.loadGenerateOptions(ctx))
+ if err := s.nginx.ValidateOnly(ctx, rules, certs); err != nil {
+ return err
+ }
+ return nil
+}
+
+func hostsFromRule(rule proxy.Rule) []string {
+ hosts := make([]string, 0, len(rule.Hosts))
+ for _, host := range rule.Hosts {
+ if host.ListenPort != nil && *host.ListenPort > 0 && *host.ListenPort != rule.ListenPort {
+ hosts = append(hosts, host.Hostname+":"+strconv.Itoa(*host.ListenPort))
+ continue
+ }
+ hosts = append(hosts, host.Hostname)
+ }
+ return hosts
+}
+
+func (s *ProxyService) validateHTTPSInput(ctx context.Context, hosts []string, httpsEnabled bool) error {
+ if !httpsEnabled || len(hosts) == 0 {
+ return nil
+ }
+ certs, err := s.loadCertSources(ctx)
+ if err != nil {
+ return err
+ }
+ parsedHosts := make([]string, 0, len(hosts))
+ for _, host := range hosts {
+ hostname, _, err := validate.FrontendAddress(host)
+ if err != nil {
+ return err
+ }
+ parsedHosts = append(parsedHosts, hostname)
+ }
+ if nginx.HasCertificateForHosts(s.cfg.CertsDir(), parsedHosts, certs) {
+ return nil
+ }
+ return nginx.HTTPSCoverageError(parsedHosts[0], certs)
+}
+
+func (s *ProxyService) loadCertSources(ctx context.Context) ([]nginx.CertSource, error) {
+ records, err := s.certStore.List(ctx)
+ if err != nil {
+ return nil, err
+ }
+ sources := make([]nginx.CertSource, 0, len(records))
+ for _, record := range records {
+ sources = append(sources, nginx.CertSource{
+ Domains: record.Domains,
+ CertPath: record.CertPath,
+ KeyPath: record.KeyPath,
+ })
+ }
+ return sources, nil
+}
diff --git a/internal/service/proxy_opts.go b/internal/service/proxy_opts.go
new file mode 100644
index 0000000..45f7f22
--- /dev/null
+++ b/internal/service/proxy_opts.go
@@ -0,0 +1,29 @@
+package service
+
+import (
+ "context"
+
+ "github.com/fonu/fonu/internal/chinacidr"
+ "github.com/fonu/fonu/internal/nginx"
+ "github.com/fonu/fonu/internal/settings"
+)
+
+func (s *ProxyService) loadGenerateOptions(ctx context.Context) nginx.GenerateOptions {
+ opts := nginx.GenerateOptions{}
+ if s.settings == nil {
+ return opts
+ }
+ raw, err := s.settings.Get(ctx, settings.KeyTrustedProxy)
+ if err == nil {
+ opts.TrustedProxy = nginx.ParseTrustedProxyJSON(raw)
+ }
+ blRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPBlacklist)
+ if err == nil {
+ opts.GlobalIPBlacklist = chinacidr.ParseGlobalIPList(blRaw)
+ }
+ wlRaw, err := s.settings.Get(ctx, settings.KeyGlobalIPWhitelist)
+ if err == nil {
+ opts.GlobalIPWhitelist = chinacidr.ParseGlobalIPList(wlRaw)
+ }
+ return opts
+}
diff --git a/internal/settings/settings.go b/internal/settings/settings.go
new file mode 100644
index 0000000..85f873a
--- /dev/null
+++ b/internal/settings/settings.go
@@ -0,0 +1,181 @@
+package settings
+
+import (
+ "context"
+ "database/sql"
+ "strconv"
+ "strings"
+)
+
+const (
+ KeyTheme = "theme"
+ KeyTimezone = "timezone"
+ KeyLogRetentionDays = "log_retention_days"
+ KeyDDNSCheckInterval = "ddns_check_interval_minutes"
+ KeyCertRenewThreshold = "cert_renew_threshold_days"
+ KeyRootDomain = "root_domain"
+ KeyACMEEmail = "acme_email"
+ KeyACMECA = "acme_ca"
+ KeyZeroSSLAPIKey = "zerossl_api_key"
+ KeyNotifyWebhookURL = "notify_webhook_url"
+ KeyNotifyType = "notify_type"
+ KeyNotifyEmailJSON = "notify_email_json"
+ KeyNotifySMTPPassword = "notify_smtp_password"
+ KeyNotifyWebhookJSON = "notify_webhook_json"
+ KeyNotifyWebhookSecret = "notify_webhook_secret"
+ KeyNotifyTelegramJSON = "notify_telegram_json"
+ KeyNotifyTelegramToken = "notify_telegram_token"
+ KeyNotifyOnDDNSError = "notify_on_ddns_error" // legacy, migrated to KeyNotifyOnDDNSFailure
+ KeyNotifyOnCertError = "notify_on_cert_error" // legacy, migrated to KeyNotifyOnCertExpiry
+ KeyNotifyOnNginxError = "notify_on_nginx_error" // legacy, ignored
+ KeyNotifyOnDDNSIPChange = "notify_on_ddns_ip_change"
+ KeyNotifyOnDDNSFailure = "notify_on_ddns_failure"
+ KeyNotifyOnCertExpiry = "notify_on_cert_expiry"
+ KeyNotifyOnCertRenewSuccess = "notify_on_cert_renew_success"
+ KeyNotifyOnIPFrequentAccess = "notify_on_ip_frequent_access"
+ KeyNotifyOnCertRenewFailure = "notify_on_cert_renew_failure"
+ KeyNotifyOnLoginFailure = "notify_on_login_failure"
+ KeyNotifyOnNginxReloadFailure = "notify_on_nginx_reload_failure"
+ KeyNotifyIPFrequentThreshold = "notify_ip_frequent_threshold"
+ KeyNotifyIPFrequentWindowSec = "notify_ip_frequent_window_sec"
+ KeyNotifyLoginFailureThreshold = "notify_login_failure_threshold"
+ KeyNotifyLoginFailureWindowSec = "notify_login_failure_window_sec"
+ KeyTrustedProxy = "trusted_proxy_json"
+ KeyGlobalIPBlacklist = "global_ip_blacklist"
+ KeyGlobalIPWhitelist = "global_ip_whitelist"
+ KeyChinaCIDRUpdateHours = "china_cidr_update_interval_hours"
+ KeyChinaCIDRSourceV4 = "china_cidr_source_url_v4"
+ KeyChinaCIDRSourceV6 = "china_cidr_source_url_v6"
+ KeyChinaCIDRUpdatedAt = "china_cidr_updated_at"
+ KeyChinaCIDRCountV4 = "china_cidr_count_v4"
+ KeyChinaCIDRCountV6 = "china_cidr_count_v6"
+ KeyChinaCIDRLastError = "china_cidr_last_error"
+ KeyFRPEnabled = "frp_enabled"
+ KeyFRPServerAddr = "frp_server_addr"
+ KeyFRPServerPort = "frp_server_port"
+ KeyFRPAuthToken = "frp_auth_token"
+ KeyFRPTLSEnabled = "frp_tls_enabled"
+ KeyFRPCustomDomains = "frp_custom_domains"
+ KeyFRPLastError = "frp_last_error"
+ KeyFRPStartedAt = "frp_started_at"
+ KeyNginxGlobalMode = "nginx_global_mode"
+)
+
+var Defaults = map[string]string{
+ KeyTheme: "system",
+ KeyTimezone: "Asia/Shanghai",
+ KeyLogRetentionDays: "30",
+ KeyDDNSCheckInterval: "5",
+ KeyCertRenewThreshold: "30",
+ KeyACMECA: "letsencrypt",
+ KeyChinaCIDRUpdateHours: "24",
+ KeyNginxGlobalMode: "auto",
+}
+
+type Store struct {
+ db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) Get(ctx context.Context, key string) (string, error) {
+ var value string
+ err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
+ if err == sql.ErrNoRows {
+ if def, ok := Defaults[key]; ok {
+ return def, nil
+ }
+ return "", nil
+ }
+ return value, err
+}
+
+func (s *Store) Set(ctx context.Context, key, value string) error {
+ _, err := s.db.ExecContext(ctx, `
+ INSERT INTO settings(key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ `, key, value)
+ return err
+}
+
+func (s *Store) GetBool(ctx context.Context, key string) (bool, error) {
+ raw, err := s.Get(ctx, key)
+ if err != nil {
+ return false, err
+ }
+ switch strings.ToLower(strings.TrimSpace(raw)) {
+ case "1", "true", "yes", "on":
+ return true, nil
+ default:
+ return false, nil
+ }
+}
+
+func (s *Store) SetBool(ctx context.Context, key string, value bool) error {
+ if value {
+ return s.Set(ctx, key, "true")
+ }
+ return s.Set(ctx, key, "false")
+}
+
+func (s *Store) SetInt(ctx context.Context, key string, value int) error {
+ return s.Set(ctx, key, strconv.Itoa(value))
+}
+
+func (s *Store) GetInt(ctx context.Context, key string) (int, error) {
+ raw, err := s.Get(ctx, key)
+ if err != nil {
+ return 0, err
+ }
+ if raw == "" {
+ if def, ok := Defaults[key]; ok {
+ raw = def
+ }
+ }
+ n, err := strconv.Atoi(raw)
+ if err != nil {
+ return 0, err
+ }
+ return n, nil
+}
+
+func (s *Store) List(ctx context.Context) (map[string]string, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := map[string]string{}
+ for k, v := range Defaults {
+ out[k] = v
+ }
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return nil, err
+ }
+ out[key] = value
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) SetMany(ctx context.Context, values map[string]string) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ for key, value := range values {
+ if _, err := tx.ExecContext(ctx, `
+ INSERT INTO settings(key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
+ `, key, value); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
diff --git a/internal/traffic/collector.go b/internal/traffic/collector.go
new file mode 100644
index 0000000..0d1ff26
--- /dev/null
+++ b/internal/traffic/collector.go
@@ -0,0 +1,328 @@
+package traffic
+
+import (
+ "bufio"
+ "context"
+ "database/sql"
+ "io"
+ "os"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fonu/fonu/internal/logstore"
+ "github.com/fonu/fonu/internal/notify"
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+const (
+ rateWindow = 5 * time.Second
+ activeWindow = 65 * time.Second
+ persistInterval = 30 * time.Second
+ maxSamplesPerHost = 256
+)
+
+type RuleTraffic struct {
+ RuleID int64 `json:"rule_id"`
+ UploadTotal int64 `json:"upload_total"`
+ DownloadTotal int64 `json:"download_total"`
+ UploadRate float64 `json:"upload_rate"`
+ DownloadRate float64 `json:"download_rate"`
+ Connections int `json:"connections"`
+}
+
+type ClientConn struct {
+ IP string `json:"ip"`
+ LastSeen string `json:"last_seen"`
+}
+
+type Collector struct {
+ path string
+ store *Store
+ notify *notify.Service
+
+ mu sync.RWMutex
+ hosts map[string]*hostState
+}
+
+type hostState struct {
+ uploadTotal int64
+ downloadTotal int64
+ clients map[string]time.Time
+ samples []sample
+}
+
+type sample struct {
+ at time.Time
+ up int64
+ down int64
+}
+
+func NewCollector(db *sql.DB, accessLogPath string, notifySvc *notify.Service) *Collector {
+ return &Collector{
+ path: accessLogPath,
+ store: NewStore(db),
+ notify: notifySvc,
+ hosts: map[string]*hostState{},
+ }
+}
+
+func (c *Collector) Start(ctx context.Context) {
+ if totals, err := c.store.LoadTotals(ctx); err == nil {
+ c.mu.Lock()
+ for host, t := range totals {
+ st := c.ensureHost(host)
+ st.uploadTotal = t.Upload
+ st.downloadTotal = t.Download
+ }
+ c.mu.Unlock()
+ }
+
+ go c.tailLoop(ctx)
+ go c.persistLoop(ctx)
+}
+
+func (c *Collector) tailLoop(ctx context.Context) {
+ offset := c.logOffset()
+ for {
+ if ctx.Err() != nil {
+ return
+ }
+ offset = c.readFrom(offset)
+ time.Sleep(500 * time.Millisecond)
+ }
+}
+
+func (c *Collector) logOffset() int64 {
+ info, err := os.Stat(c.path)
+ if err != nil {
+ return 0
+ }
+ return info.Size()
+}
+
+func (c *Collector) readFrom(offset int64) int64 {
+ file, err := os.Open(c.path)
+ if err != nil {
+ return offset
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return offset
+ }
+ if info.Size() < offset {
+ offset = 0
+ }
+ if _, err := file.Seek(offset, 0); err != nil {
+ return offset
+ }
+
+ reader := bufio.NewReader(file)
+ for {
+ line, err := reader.ReadString('\n')
+ if len(line) > 0 {
+ c.ingestLine(strings.TrimRight(line, "\r\n"), true)
+ }
+ if err != nil {
+ if err == io.EOF {
+ pos, seekErr := file.Seek(0, io.SeekCurrent)
+ if seekErr == nil {
+ return pos
+ }
+ return info.Size()
+ }
+ return offset
+ }
+ }
+}
+
+func (c *Collector) persistLoop(ctx context.Context) {
+ ticker := time.NewTicker(persistInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ _ = c.persist(context.Background())
+ return
+ case <-ticker.C:
+ _ = c.persist(ctx)
+ }
+ }
+}
+
+func (c *Collector) persist(ctx context.Context) error {
+ c.mu.RLock()
+ snapshot := make(map[string]hostTotals, len(c.hosts))
+ for host, st := range c.hosts {
+ snapshot[host] = hostTotals{Upload: st.uploadTotal, Download: st.downloadTotal}
+ }
+ c.mu.RUnlock()
+ return c.store.SaveTotals(ctx, snapshot)
+}
+
+func (c *Collector) ingestLine(line string, trackLive bool) {
+ entry, ok := logstore.ParseAccess(line)
+ if !ok || entry.Domain == "" {
+ return
+ }
+ host := EndpointKey(entry.Domain, entry.ServerPort)
+ up := entry.RequestLength
+ down := entry.BytesSent
+ if up == 0 && down == 0 && !trackLive {
+ return
+ }
+
+ now := time.Now()
+ c.mu.Lock()
+ st := c.ensureHost(host)
+ st.uploadTotal += up
+ st.downloadTotal += down
+ if trackLive {
+ if up > 0 || down > 0 {
+ st.samples = append(st.samples, sample{at: now, up: up, down: down})
+ if len(st.samples) > maxSamplesPerHost {
+ st.samples = st.samples[len(st.samples)-maxSamplesPerHost:]
+ }
+ }
+ if entry.ClientIP != "" && entry.ClientIP != "-" {
+ st.clients[entry.ClientIP] = now
+ if c.notify != nil {
+ c.notify.RecordAccessHit(context.Background(), entry.ClientIP)
+ }
+ }
+ c.pruneHost(st, now)
+ }
+ c.mu.Unlock()
+}
+
+func (c *Collector) ensureHost(host string) *hostState {
+ st, ok := c.hosts[host]
+ if !ok {
+ st = &hostState{clients: map[string]time.Time{}}
+ c.hosts[host] = st
+ }
+ return st
+}
+
+func (c *Collector) pruneHost(st *hostState, now time.Time) {
+ cutoff := now.Add(-rateWindow)
+ for i := 0; i < len(st.samples); {
+ if st.samples[i].at.Before(cutoff) {
+ st.samples = append(st.samples[:i], st.samples[i+1:]...)
+ continue
+ }
+ i++
+ }
+ activeCutoff := now.Add(-activeWindow)
+ for ip, seen := range st.clients {
+ if seen.Before(activeCutoff) {
+ delete(st.clients, ip)
+ }
+ }
+}
+
+func (c *Collector) aggregateEndpoints(endpoints []proxy.Endpoint, now time.Time) (uploadTotal, downloadTotal int64, uploadRate, downloadRate float64, connections int) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ var upSamples, downSamples int64
+ var oldest time.Time
+ clientSet := map[string]time.Time{}
+
+ for _, ep := range endpoints {
+ key := EndpointKey(ep.Hostname, ep.Port)
+ st, ok := c.hosts[key]
+ if !ok {
+ continue
+ }
+ uploadTotal += st.uploadTotal
+ downloadTotal += st.downloadTotal
+ cutoff := now.Add(-rateWindow)
+ for _, s := range st.samples {
+ if s.at.Before(cutoff) {
+ continue
+ }
+ upSamples += s.up
+ downSamples += s.down
+ if oldest.IsZero() || s.at.Before(oldest) {
+ oldest = s.at
+ }
+ }
+ activeCutoff := now.Add(-activeWindow)
+ for ip, seen := range st.clients {
+ if seen.Before(activeCutoff) {
+ continue
+ }
+ if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+ clientSet[ip] = seen
+ }
+ }
+ }
+
+ span := now.Sub(oldest).Seconds()
+ if span < 0.5 {
+ span = 0.5
+ }
+ if upSamples > 0 {
+ uploadRate = float64(upSamples) / span
+ }
+ if downSamples > 0 {
+ downloadRate = float64(downSamples) / span
+ }
+ connections = len(clientSet)
+ return uploadTotal, downloadTotal, uploadRate, downloadRate, connections
+}
+
+func (c *Collector) SnapshotForRules(rules []proxy.Rule) []RuleTraffic {
+ now := time.Now()
+ out := make([]RuleTraffic, 0, len(rules))
+ for _, rule := range rules {
+ endpoints := rule.Endpoints()
+ up, down, upRate, downRate, conns := c.aggregateEndpoints(endpoints, now)
+ out = append(out, RuleTraffic{
+ RuleID: rule.ID,
+ UploadTotal: up,
+ DownloadTotal: down,
+ UploadRate: upRate,
+ DownloadRate: downRate,
+ Connections: conns,
+ })
+ }
+ return out
+}
+
+func (c *Collector) ClientsForRule(rule proxy.Rule) []ClientConn {
+ now := time.Now()
+ activeCutoff := now.Add(-activeWindow)
+ clientSet := map[string]time.Time{}
+
+ c.mu.RLock()
+ for _, ep := range rule.Endpoints() {
+ key := EndpointKey(ep.Hostname, ep.Port)
+ st, ok := c.hosts[key]
+ if !ok {
+ continue
+ }
+ for ip, seen := range st.clients {
+ if seen.Before(activeCutoff) {
+ continue
+ }
+ if prev, ok := clientSet[ip]; !ok || seen.After(prev) {
+ clientSet[ip] = seen
+ }
+ }
+ }
+ c.mu.RUnlock()
+
+ out := make([]ClientConn, 0, len(clientSet))
+ for ip, seen := range clientSet {
+ out = append(out, ClientConn{IP: ip, LastSeen: seen.UTC().Format(time.RFC3339)})
+ }
+ sort.Slice(out, func(i, j int) bool {
+ return out[i].LastSeen > out[j].LastSeen
+ })
+ return out
+}
diff --git a/internal/traffic/collector_test.go b/internal/traffic/collector_test.go
new file mode 100644
index 0000000..27c0317
--- /dev/null
+++ b/internal/traffic/collector_test.go
@@ -0,0 +1,53 @@
+package traffic
+
+import (
+ "testing"
+ "time"
+
+ "github.com/fonu/fonu/internal/proxy"
+)
+
+func TestCollectorAttributesConnectionsByHostAndPort(t *testing.T) {
+ c := NewCollector(nil, "", nil)
+ now := time.Now()
+ line6893 := "2026-09-17T08:30:00+08:00 xxx.com 6893 GET /login 200 0.010 218.88.23.20 192.168.8.3:6893 100 200"
+ line8443 := "2026-09-17T08:30:01+08:00 xxx.com 8443 GET / 200 0.010 218.88.23.20 192.168.8.3:8443 100 200"
+
+ c.ingestLine(line6893, true)
+ c.ingestLine(line8443, true)
+
+ rules := []proxy.Rule{
+ {
+ ID: 1,
+ ListenPort: 6893,
+ Hosts: []proxy.Host{{Hostname: "xxx.com"}},
+ },
+ {
+ ID: 2,
+ ListenPort: 8443,
+ Hosts: []proxy.Host{{Hostname: "xxx.com"}},
+ },
+ }
+
+ stats := c.SnapshotForRules(rules)
+ if len(stats) != 2 {
+ t.Fatalf("expected 2 stats, got %d", len(stats))
+ }
+ byID := map[int64]RuleTraffic{}
+ for _, s := range stats {
+ byID[s.RuleID] = s
+ }
+ if byID[1].Connections != 1 {
+ t.Fatalf("rule 6893 connections=%d want 1", byID[1].Connections)
+ }
+ if byID[2].Connections != 1 {
+ t.Fatalf("rule 8443 connections=%d want 1", byID[2].Connections)
+ }
+ _ = now
+}
+
+func TestEndpointKeyIncludesPort(t *testing.T) {
+ if got := EndpointKey("XXX.com", 6893); got != "xxx.com:6893" {
+ t.Fatalf("got %q", got)
+ }
+}
diff --git a/internal/traffic/endpoint.go b/internal/traffic/endpoint.go
new file mode 100644
index 0000000..7d2cc57
--- /dev/null
+++ b/internal/traffic/endpoint.go
@@ -0,0 +1,17 @@
+package traffic
+
+import (
+ "fmt"
+ "strings"
+)
+
+func EndpointKey(host string, port int) string {
+ host = strings.ToLower(strings.TrimSpace(host))
+ if i := strings.Index(host, ":"); i > 0 {
+ host = host[:i]
+ }
+ if port <= 0 {
+ return host
+ }
+ return fmt.Sprintf("%s:%d", host, port)
+}
diff --git a/internal/traffic/store.go b/internal/traffic/store.go
new file mode 100644
index 0000000..00a54ad
--- /dev/null
+++ b/internal/traffic/store.go
@@ -0,0 +1,63 @@
+package traffic
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "time"
+)
+
+type Store struct {
+ db *sql.DB
+}
+
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db}
+}
+
+func (s *Store) LoadTotals(ctx context.Context) (map[string]hostTotals, error) {
+ rows, err := s.db.QueryContext(ctx, `SELECT host, upload_bytes, download_bytes FROM proxy_traffic`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := map[string]hostTotals{}
+ for rows.Next() {
+ var host string
+ var up, down int64
+ if err := rows.Scan(&host, &up, &down); err != nil {
+ return nil, err
+ }
+ key := strings.ToLower(strings.TrimSpace(host))
+ out[key] = hostTotals{Upload: up, Download: down}
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) SaveTotals(ctx context.Context, hosts map[string]hostTotals) error {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ for host, totals := range hosts {
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO proxy_traffic(host, upload_bytes, download_bytes, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(host) DO UPDATE SET
+ upload_bytes = excluded.upload_bytes,
+ download_bytes = excluded.download_bytes,
+ updated_at = excluded.updated_at
+ `, host, totals.Upload, totals.Download, time.Now().UTC().Format(time.RFC3339))
+ if err != nil {
+ _ = tx.Rollback()
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+type hostTotals struct {
+ Upload int64
+ Download int64
+}
diff --git a/internal/validate/basicauth.go b/internal/validate/basicauth.go
new file mode 100644
index 0000000..3034b1c
--- /dev/null
+++ b/internal/validate/basicauth.go
@@ -0,0 +1,21 @@
+package validate
+
+import (
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+func BasicAuthPasswordHash(username, password string) (string, error) {
+ if err := Username(username); err != nil {
+ return "", err
+ }
+ if err := Password(password); err != nil {
+ return "", err
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", fmt.Errorf("密码加密失败")
+ }
+ return string(hash), nil
+}
diff --git a/internal/validate/ip.go b/internal/validate/ip.go
new file mode 100644
index 0000000..715e9f9
--- /dev/null
+++ b/internal/validate/ip.go
@@ -0,0 +1,38 @@
+package validate
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+func IPOrCIDR(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", fmt.Errorf("IP 或 CIDR 不能为空")
+ }
+ if strings.Contains(raw, "/") {
+ _, ipNet, err := net.ParseCIDR(raw)
+ if err != nil {
+ return "", fmt.Errorf("CIDR 格式无效")
+ }
+ return ipNet.String(), nil
+ }
+ ip := net.ParseIP(raw)
+ if ip == nil {
+ return "", fmt.Errorf("IP 格式无效")
+ }
+ if v4 := ip.To4(); v4 != nil {
+ return v4.String(), nil
+ }
+ return ip.String(), nil
+}
+
+func IPOrCIDRList(list []string) error {
+ for _, item := range list {
+ if _, err := IPOrCIDR(item); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/validate/validate.go b/internal/validate/validate.go
new file mode 100644
index 0000000..6b690a3
--- /dev/null
+++ b/internal/validate/validate.go
@@ -0,0 +1,138 @@
+package validate
+
+import (
+ "fmt"
+ "net"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var domainRe = regexp.MustCompile(`^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$`)
+
+func CertDomain(domain string) error {
+ domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".")
+ if domain == "" {
+ return fmt.Errorf("域名不能为空")
+ }
+ if strings.HasPrefix(domain, "*.") {
+ return Domain(strings.TrimPrefix(domain, "*."))
+ }
+ return Domain(domain)
+}
+
+func Domain(domain string) error {
+ domain = strings.TrimSpace(domain)
+ if domain == "" {
+ return fmt.Errorf("域名不能为空")
+ }
+ if len(domain) > 253 {
+ return fmt.Errorf("域名过长")
+ }
+ if strings.Contains(domain, "..") || strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
+ return fmt.Errorf("域名格式无效")
+ }
+ if !domainRe.MatchString(domain) {
+ return fmt.Errorf("域名格式无效")
+ }
+ return nil
+}
+
+func ListenPort(port int) error {
+ if port < 1 || port > 65535 {
+ return fmt.Errorf("监听端口无效")
+ }
+ return nil
+}
+
+// FrontendAddress parses a frontend binding like "example.com" or "example.com:8011".
+// A zero port means the rule-level default should be used.
+func FrontendAddress(raw string) (hostname string, port int, err error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", 0, fmt.Errorf("前端地址不能为空")
+ }
+
+ host := raw
+ port = 0
+ if strings.Count(raw, ":") == 1 && !strings.Contains(raw, "]") {
+ parts := strings.SplitN(raw, ":", 2)
+ host = parts[0]
+ p, parseErr := strconv.Atoi(parts[1])
+ if parseErr != nil || p < 1 || p > 65535 {
+ return "", 0, fmt.Errorf("前端地址端口无效")
+ }
+ port = p
+ }
+
+ host = strings.ToLower(strings.TrimSpace(host))
+ if err := Domain(host); err != nil {
+ return "", 0, err
+ }
+ return host, port, nil
+}
+
+func Upstream(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", fmt.Errorf("目标地址不能为空")
+ }
+
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", fmt.Errorf("目标地址格式无效")
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return "", fmt.Errorf("目标地址仅支持 http:// 或 https://")
+ }
+ if u.User != nil {
+ return "", fmt.Errorf("目标地址不能包含用户名或密码")
+ }
+ if u.Host == "" {
+ return "", fmt.Errorf("目标地址缺少主机名")
+ }
+ if u.Path != "" && u.Path != "/" {
+ return "", fmt.Errorf("目标地址不能包含路径")
+ }
+ if u.RawQuery != "" || u.Fragment != "" {
+ return "", fmt.Errorf("目标地址不能包含查询参数或片段")
+ }
+
+ host := u.Hostname()
+ port := u.Port()
+ if host == "" {
+ return "", fmt.Errorf("目标地址主机名无效")
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if port == "" {
+ return "", fmt.Errorf("IP 地址目标必须指定端口")
+ }
+ } else if err := Domain(host); err != nil {
+ return "", fmt.Errorf("目标地址主机名无效")
+ }
+
+ normalized := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
+ if !strings.HasPrefix(normalized, "http://") && !strings.HasPrefix(normalized, "https://") {
+ return "", fmt.Errorf("目标地址格式无效")
+ }
+ return normalized, nil
+}
+
+func Password(password string) error {
+ if len(password) < 8 {
+ return fmt.Errorf("密码长度至少 8 位")
+ }
+ return nil
+}
+
+func Username(username string) error {
+ username = strings.TrimSpace(username)
+ if username == "" {
+ return fmt.Errorf("用户名不能为空")
+ }
+ if len(username) > 64 {
+ return fmt.Errorf("用户名过长")
+ }
+ return nil
+}
diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go
new file mode 100644
index 0000000..478ddae
--- /dev/null
+++ b/internal/validate/validate_test.go
@@ -0,0 +1,21 @@
+package validate
+
+import "testing"
+
+func TestFrontendAddress(t *testing.T) {
+ host, port, err := FrontendAddress("example.com:8011")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if host != "example.com" || port != 8011 {
+ t.Fatalf("got %q %d", host, port)
+ }
+
+ host, port, err = FrontendAddress("nas.example.com")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if host != "nas.example.com" || port != 0 {
+ t.Fatalf("got %q %d", host, port)
+ }
+}
diff --git a/internal/version/version.go b/internal/version/version.go
new file mode 100644
index 0000000..2076ecc
--- /dev/null
+++ b/internal/version/version.go
@@ -0,0 +1,4 @@
+package version
+
+// Version is set at build time via -ldflags.
+var Version = "dev"
diff --git a/migrations/001_init.sql b/migrations/001_init.sql
new file mode 100644
index 0000000..b366980
--- /dev/null
+++ b/migrations/001_init.sql
@@ -0,0 +1,36 @@
+CREATE TABLE IF NOT EXISTS schema_migrations (
+ version INTEGER PRIMARY KEY,
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS admins (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
+
+CREATE TABLE IF NOT EXISTS proxy_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ upstream TEXT NOT NULL,
+ https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+ http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS settings (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+);
diff --git a/migrations/002_phase2.sql b/migrations/002_phase2.sql
new file mode 100644
index 0000000..28c14bb
--- /dev/null
+++ b/migrations/002_phase2.sql
@@ -0,0 +1,31 @@
+CREATE TABLE IF NOT EXISTS ddns_configs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ provider TEXT NOT NULL DEFAULT 'cloudflare',
+ root_domain TEXT NOT NULL,
+ record_name TEXT NOT NULL,
+ ipv4_enabled INTEGER NOT NULL DEFAULT 1 CHECK (ipv4_enabled IN (0, 1)),
+ ipv6_enabled INTEGER NOT NULL DEFAULT 0 CHECK (ipv6_enabled IN (0, 1)),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ api_token_enc TEXT,
+ last_ipv4 TEXT,
+ last_ipv6 TEXT,
+ last_status TEXT NOT NULL DEFAULT 'unknown',
+ last_error TEXT,
+ last_updated_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS certificates (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ domain TEXT NOT NULL UNIQUE,
+ wildcard INTEGER NOT NULL DEFAULT 0 CHECK (wildcard IN (0, 1)),
+ cert_path TEXT,
+ key_path TEXT,
+ expires_at TEXT,
+ last_renew_at TEXT,
+ status TEXT NOT NULL DEFAULT 'pending',
+ last_error TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/migrations/003_ddns_multi.sql b/migrations/003_ddns_multi.sql
new file mode 100644
index 0000000..678556b
--- /dev/null
+++ b/migrations/003_ddns_multi.sql
@@ -0,0 +1,2 @@
+CREATE UNIQUE INDEX IF NOT EXISTS idx_ddns_configs_domain_record
+ON ddns_configs(root_domain, record_name);
diff --git a/migrations/004_cert_acme_ca.sql b/migrations/004_cert_acme_ca.sql
new file mode 100644
index 0000000..9481a61
--- /dev/null
+++ b/migrations/004_cert_acme_ca.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN acme_ca TEXT NOT NULL DEFAULT 'letsencrypt';
diff --git a/migrations/005_cert_domains.sql b/migrations/005_cert_domains.sql
new file mode 100644
index 0000000..aa121dd
--- /dev/null
+++ b/migrations/005_cert_domains.sql
@@ -0,0 +1 @@
+ALTER TABLE certificates ADD COLUMN domains TEXT NOT NULL DEFAULT '';
diff --git a/migrations/006_proxy_multi_host.sql b/migrations/006_proxy_multi_host.sql
new file mode 100644
index 0000000..ed9bcdc
--- /dev/null
+++ b/migrations/006_proxy_multi_host.sql
@@ -0,0 +1,50 @@
+CREATE TEMP TABLE proxy_domain_backup AS
+SELECT id, domain FROM proxy_rules;
+
+CREATE TABLE proxy_rules_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ upstream TEXT NOT NULL,
+ listen_port INTEGER NOT NULL DEFAULT 80 CHECK (listen_port > 0 AND listen_port <= 65535),
+ listen_ipv4 INTEGER NOT NULL DEFAULT 1 CHECK (listen_ipv4 IN (0, 1)),
+ listen_ipv6 INTEGER NOT NULL DEFAULT 0 CHECK (listen_ipv6 IN (0, 1)),
+ https_enabled INTEGER NOT NULL DEFAULT 1 CHECK (https_enabled IN (0, 1)),
+ http_redirect INTEGER NOT NULL DEFAULT 1 CHECK (http_redirect IN (0, 1)),
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO proxy_rules_new (id, upstream, listen_port, listen_ipv4, listen_ipv6, https_enabled, http_redirect, enabled, created_at, updated_at)
+SELECT
+ id,
+ upstream,
+ CASE WHEN https_enabled = 1 THEN 443 ELSE 80 END,
+ 1,
+ 0,
+ https_enabled,
+ http_redirect,
+ enabled,
+ created_at,
+ updated_at
+FROM proxy_rules;
+
+DROP TABLE proxy_rules;
+ALTER TABLE proxy_rules_new RENAME TO proxy_rules;
+
+CREATE TABLE IF NOT EXISTS proxy_hosts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
+ hostname TEXT NOT NULL COLLATE NOCASE,
+ listen_port INTEGER CHECK (listen_port IS NULL OR (listen_port > 0 AND listen_port <= 65535)),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(hostname)
+);
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
+
+INSERT INTO proxy_hosts (rule_id, hostname, listen_port)
+SELECT id, domain, NULL
+FROM proxy_domain_backup
+WHERE domain IS NOT NULL AND TRIM(domain) != '';
+
+DROP TABLE proxy_domain_backup;
diff --git a/migrations/007_ddns_record_names.sql b/migrations/007_ddns_record_names.sql
new file mode 100644
index 0000000..1a43f7f
--- /dev/null
+++ b/migrations/007_ddns_record_names.sql
@@ -0,0 +1,5 @@
+ALTER TABLE ddns_configs ADD COLUMN record_names TEXT NOT NULL DEFAULT '[]';
+
+UPDATE ddns_configs
+SET record_names = json_array(record_name)
+WHERE record_names = '[]' OR record_names IS NULL OR record_names = '';
diff --git a/migrations/008_ddns_domain_records.sql b/migrations/008_ddns_domain_records.sql
new file mode 100644
index 0000000..1bcd97e
--- /dev/null
+++ b/migrations/008_ddns_domain_records.sql
@@ -0,0 +1 @@
+ALTER TABLE ddns_configs ADD COLUMN domain_records TEXT NOT NULL DEFAULT '[]';
diff --git a/migrations/009_proxy_traffic.sql b/migrations/009_proxy_traffic.sql
new file mode 100644
index 0000000..788cefe
--- /dev/null
+++ b/migrations/009_proxy_traffic.sql
@@ -0,0 +1,6 @@
+CREATE TABLE IF NOT EXISTS proxy_traffic (
+ host TEXT PRIMARY KEY,
+ upload_bytes INTEGER NOT NULL DEFAULT 0,
+ download_bytes INTEGER NOT NULL DEFAULT 0,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
diff --git a/migrations/010_proxy_host_port_unique.sql b/migrations/010_proxy_host_port_unique.sql
new file mode 100644
index 0000000..ff880b7
--- /dev/null
+++ b/migrations/010_proxy_host_port_unique.sql
@@ -0,0 +1,22 @@
+UPDATE proxy_hosts
+SET listen_port = (
+ SELECT listen_port FROM proxy_rules WHERE proxy_rules.id = proxy_hosts.rule_id
+)
+WHERE listen_port IS NULL;
+
+CREATE TABLE proxy_hosts_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ rule_id INTEGER NOT NULL REFERENCES proxy_rules(id) ON DELETE CASCADE,
+ hostname TEXT NOT NULL COLLATE NOCASE,
+ listen_port INTEGER NOT NULL CHECK (listen_port > 0 AND listen_port <= 65535),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(hostname, listen_port)
+);
+
+INSERT INTO proxy_hosts_new (id, rule_id, hostname, listen_port, created_at)
+SELECT id, rule_id, hostname, listen_port, created_at FROM proxy_hosts;
+
+DROP TABLE proxy_hosts;
+ALTER TABLE proxy_hosts_new RENAME TO proxy_hosts;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_hosts_rule_id ON proxy_hosts(rule_id);
diff --git a/migrations/011_proxy_remark.sql b/migrations/011_proxy_remark.sql
new file mode 100644
index 0000000..492c6fe
--- /dev/null
+++ b/migrations/011_proxy_remark.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN remark TEXT NOT NULL DEFAULT '';
diff --git a/migrations/012_proxy_name_sort.sql b/migrations/012_proxy_name_sort.sql
new file mode 100644
index 0000000..e233e59
--- /dev/null
+++ b/migrations/012_proxy_name_sort.sql
@@ -0,0 +1,7 @@
+ALTER TABLE proxy_rules RENAME COLUMN remark TO name;
+
+ALTER TABLE proxy_rules ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
+
+UPDATE proxy_rules SET sort_order = id;
+
+CREATE INDEX IF NOT EXISTS idx_proxy_rules_sort_order ON proxy_rules(sort_order);
diff --git a/migrations/013_proxy_security.sql b/migrations/013_proxy_security.sql
new file mode 100644
index 0000000..ab49f29
--- /dev/null
+++ b/migrations/013_proxy_security.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN security_json TEXT NOT NULL DEFAULT '{}';
diff --git a/migrations/014_nginx_custom.sql b/migrations/014_nginx_custom.sql
new file mode 100644
index 0000000..bc4ac52
--- /dev/null
+++ b/migrations/014_nginx_custom.sql
@@ -0,0 +1 @@
+ALTER TABLE proxy_rules ADD COLUMN nginx_mode TEXT NOT NULL DEFAULT 'auto';
diff --git a/migrations/015_frp.sql b/migrations/015_frp.sql
new file mode 100644
index 0000000..b832ceb
--- /dev/null
+++ b/migrations/015_frp.sql
@@ -0,0 +1,44 @@
+CREATE TABLE IF NOT EXISTS frp_servers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ server_addr TEXT NOT NULL,
+ server_port INTEGER NOT NULL CHECK (server_port > 0 AND server_port <= 65535),
+ auth_token_enc TEXT,
+ tls_enabled INTEGER NOT NULL DEFAULT 1 CHECK (tls_enabled IN (0, 1)),
+ tls_server_name TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE TABLE IF NOT EXISTS frp_proxies (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+ name TEXT NOT NULL COLLATE NOCASE,
+ type TEXT NOT NULL CHECK (type IN ('tcp', 'http', 'https')),
+ local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+ local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+ remote_port INTEGER CHECK (remote_port IS NULL OR (remote_port > 0 AND remote_port <= 65535)),
+ custom_domains_json TEXT NOT NULL DEFAULT '[]',
+ host_header_rewrite TEXT NOT NULL DEFAULT '',
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ remark TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(server_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS frp_runtime_status (
+ server_id INTEGER PRIMARY KEY REFERENCES frp_servers(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'stopped',
+ pid INTEGER,
+ frpc_version TEXT NOT NULL DEFAULT '',
+ last_started_at TEXT,
+ last_reloaded_at TEXT,
+ last_error TEXT NOT NULL DEFAULT '',
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
diff --git a/migrations/016_frp_server_options.sql b/migrations/016_frp_server_options.sql
new file mode 100644
index 0000000..6ac997b
--- /dev/null
+++ b/migrations/016_frp_server_options.sql
@@ -0,0 +1,2 @@
+ALTER TABLE frp_servers ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
+ALTER TABLE frp_servers ADD COLUMN oidc_client_secret_enc TEXT NOT NULL DEFAULT '';
diff --git a/migrations/017_frp_server_diagnostics.sql b/migrations/017_frp_server_diagnostics.sql
new file mode 100644
index 0000000..10c3940
--- /dev/null
+++ b/migrations/017_frp_server_diagnostics.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS frp_server_diagnostics (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+ checked_at TEXT NOT NULL DEFAULT (datetime('now')),
+ available INTEGER NOT NULL DEFAULT 0 CHECK (available IN (0, 1)),
+ latency_ms INTEGER,
+ error TEXT NOT NULL DEFAULT ''
+);
+
+CREATE INDEX IF NOT EXISTS idx_frp_server_diagnostics_server_time
+ON frp_server_diagnostics(server_id, checked_at);
diff --git a/migrations/018_frp_proxy_options.sql b/migrations/018_frp_proxy_options.sql
new file mode 100644
index 0000000..cd39db9
--- /dev/null
+++ b/migrations/018_frp_proxy_options.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_proxies ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}';
diff --git a/migrations/019_frp_runtime_states.sql b/migrations/019_frp_runtime_states.sql
new file mode 100644
index 0000000..a759d80
--- /dev/null
+++ b/migrations/019_frp_runtime_states.sql
@@ -0,0 +1,18 @@
+ALTER TABLE frp_runtime_status ADD COLUMN desired_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN process_state TEXT NOT NULL DEFAULT 'stopped';
+ALTER TABLE frp_runtime_status ADD COLUMN connection_state TEXT NOT NULL DEFAULT 'unknown';
+ALTER TABLE frp_runtime_status ADD COLUMN state_source TEXT NOT NULL DEFAULT 'legacy';
+ALTER TABLE frp_runtime_status ADD COLUMN process_pid INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN exit_code INTEGER;
+ALTER TABLE frp_runtime_status ADD COLUMN last_connected_at TEXT;
+ALTER TABLE frp_runtime_status ADD COLUMN last_disconnected_at TEXT;
+
+UPDATE frp_runtime_status
+SET desired_state = CASE WHEN status IN ('running', 'starting') THEN 'running' ELSE 'stopped' END,
+ process_state = CASE WHEN status IN ('running', 'starting') THEN 'unknown' ELSE 'stopped' END,
+ connection_state = CASE WHEN status = 'running' THEN 'unknown' ELSE 'unknown' END,
+ state_source = 'migration'
+WHERE desired_state = 'stopped' AND status IN ('running', 'starting');
+
+CREATE INDEX IF NOT EXISTS idx_frp_runtime_process_state
+ON frp_runtime_status(process_state, connection_state);
diff --git a/migrations/020_frp_dashboard.sql b/migrations/020_frp_dashboard.sql
new file mode 100644
index 0000000..4d6185b
--- /dev/null
+++ b/migrations/020_frp_dashboard.sql
@@ -0,0 +1 @@
+ALTER TABLE frp_servers ADD COLUMN dashboard_password_enc TEXT;
diff --git a/migrations/021_frp_proxy_types.sql b/migrations/021_frp_proxy_types.sql
new file mode 100644
index 0000000..220c085
--- /dev/null
+++ b/migrations/021_frp_proxy_types.sql
@@ -0,0 +1,42 @@
+-- FRP 规则类型与端口约束修正(文档 FRP_CROSS_MODULE_IMPLEMENTATION_PLAN.md 23.2)
+-- 原 014_frp.sql 的 type CHECK 只有 tcp/http/https,但校验器与前端已支持
+-- udp/tcpmux/stcp/sudp/xtcp:这些类型会在校验通过后因 SQLite CHECK 约束插入失败。
+-- SQLite 无法 ALTER CHECK 约束,这里重建 frp_proxies 表并统一约束。
+CREATE TABLE IF NOT EXISTS frp_proxies_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ server_id INTEGER NOT NULL REFERENCES frp_servers(id) ON DELETE CASCADE,
+ name TEXT NOT NULL COLLATE NOCASE,
+ type TEXT NOT NULL CHECK (type IN ('tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp', 'xtcp')),
+ local_ip TEXT NOT NULL DEFAULT '127.0.0.1',
+ local_port INTEGER NOT NULL CHECK (local_port > 0 AND local_port <= 65535),
+ remote_port INTEGER CHECK (
+ remote_port IS NULL
+ OR (remote_port > 0 AND remote_port <= 65535 AND type IN ('tcp', 'udp'))
+ ),
+ custom_domains_json TEXT NOT NULL DEFAULT '[]',
+ host_header_rewrite TEXT NOT NULL DEFAULT '',
+ config_json TEXT NOT NULL DEFAULT '{}',
+ enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
+ remark TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(server_id, name)
+);
+
+INSERT INTO frp_proxies_new
+ (id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, config_json, enabled, remark, created_at, updated_at)
+SELECT
+ id, server_id, name, type, local_ip, local_port, remote_port, custom_domains_json, host_header_rewrite, COALESCE(config_json, '{}'), enabled, remark, created_at, updated_at
+FROM frp_proxies;
+
+DROP TABLE frp_proxies;
+ALTER TABLE frp_proxies_new RENAME TO frp_proxies;
+
+-- tcp 与 udp 的远程端口在同一 server_id 内分别查重(原索引混用两种协议)
+DROP INDEX IF EXISTS idx_frp_proxies_tcp_remote_port;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_tcp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'tcp' AND remote_port IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_frp_proxies_udp_remote_port
+ON frp_proxies(server_id, remote_port)
+WHERE type = 'udp' AND remote_port IS NOT NULL;
diff --git a/migrations/022_ddns_custom_ip.sql b/migrations/022_ddns_custom_ip.sql
new file mode 100644
index 0000000..995a30f
--- /dev/null
+++ b/migrations/022_ddns_custom_ip.sql
@@ -0,0 +1,3 @@
+-- DDNS 支持自定义 IP:填写后更新记录时直接使用该 IP(如公网服务器 IP),不填则检测本机公网 IP。
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv4 TEXT NOT NULL DEFAULT '';
+ALTER TABLE ddns_configs ADD COLUMN custom_ipv6 TEXT NOT NULL DEFAULT '';
diff --git a/migrations/023_frp_server_agent.sql b/migrations/023_frp_server_agent.sql
new file mode 100644
index 0000000..066aa34
--- /dev/null
+++ b/migrations/023_frp_server_agent.sql
@@ -0,0 +1,9 @@
+-- 公网 fonu-agent 远程管理配置
+ALTER TABLE frp_servers ADD COLUMN agent_url TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN agent_token_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_host TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_port INTEGER NOT NULL DEFAULT 22;
+ALTER TABLE frp_servers ADD COLUMN ssh_user TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN ssh_auth TEXT NOT NULL DEFAULT 'key';
+ALTER TABLE frp_servers ADD COLUMN ssh_secret_enc TEXT NOT NULL DEFAULT '';
+ALTER TABLE frp_servers ADD COLUMN mgmt_enabled INTEGER NOT NULL DEFAULT 0 CHECK (mgmt_enabled IN (0, 1));
diff --git a/web/assets/image/429.png b/web/assets/image/429.png
new file mode 100644
index 0000000..ab1071c
Binary files /dev/null and b/web/assets/image/429.png differ
diff --git a/web/assets/image/aliyun.png b/web/assets/image/aliyun.png
new file mode 100644
index 0000000..a32460f
Binary files /dev/null and b/web/assets/image/aliyun.png differ
diff --git a/web/assets/image/banner.png b/web/assets/image/banner.png
new file mode 100644
index 0000000..9f745c4
Binary files /dev/null and b/web/assets/image/banner.png differ
diff --git a/web/assets/image/cloudflare.png b/web/assets/image/cloudflare.png
new file mode 100644
index 0000000..252ef9c
Binary files /dev/null and b/web/assets/image/cloudflare.png differ
diff --git a/web/assets/image/dnspod.png b/web/assets/image/dnspod.png
new file mode 100644
index 0000000..c06f2cd
Binary files /dev/null and b/web/assets/image/dnspod.png differ
diff --git a/web/assets/image/error.png b/web/assets/image/error.png
new file mode 100644
index 0000000..f68b754
Binary files /dev/null and b/web/assets/image/error.png differ
diff --git a/web/assets/image/issue.png b/web/assets/image/issue.png
new file mode 100644
index 0000000..4f77555
Binary files /dev/null and b/web/assets/image/issue.png differ
diff --git a/web/assets/image/logo.png b/web/assets/image/logo.png
new file mode 100644
index 0000000..45a4760
Binary files /dev/null and b/web/assets/image/logo.png differ
diff --git a/web/assets/image/tencentcloud.png b/web/assets/image/tencentcloud.png
new file mode 100644
index 0000000..e00336f
Binary files /dev/null and b/web/assets/image/tencentcloud.png differ
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..ac2f7e8
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Fonu
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..a49e148
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2055 @@
+{
+ "name": "fonu-web",
+ "version": "1.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fonu-web",
+ "version": "1.0.1",
+ "dependencies": {
+ "@codemirror/commands": "^6.11.1",
+ "@codemirror/lang-javascript": "^6.2.5",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/legacy-modes": "^6.5.4",
+ "@codemirror/state": "^6.7.5",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.43.12",
+ "@vicons/ionicons5": "^0.13.0",
+ "codemirror": "^6.0.2",
+ "echarts": "^6.1.0",
+ "naive-ui": "^2.40.1",
+ "sortablejs": "^1.15.7",
+ "vue": "^3.5.13",
+ "vue-codemirror": "^6.1.1",
+ "vue-echarts": "^8.3.0",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@types/sortablejs": "^1.15.9",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3",
+ "vue-tsc": "^2.2.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@codemirror/autocomplete": {
+ "version": "6.20.3",
+ "resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
+ "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.11.1",
+ "resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.11.1.tgz",
+ "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/lang-javascript": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmmirror.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
+ "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.17.0",
+ "@lezer/common": "^1.0.0",
+ "@lezer/javascript": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.5.4",
+ "resolved": "https://registry.npmmirror.com/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
+ "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
+ "node_modules/@codemirror/lint": {
+ "version": "6.9.7",
+ "resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.7.tgz",
+ "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.42.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.2.tgz",
+ "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.5",
+ "resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.7.5.tgz",
+ "integrity": "sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/theme-one-dark": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
+ "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0",
+ "@lezer/highlight": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.12",
+ "resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.12.tgz",
+ "integrity": "sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@css-render/plugin-bem": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
+ "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "css-render": "~0.15.14"
+ }
+ },
+ "node_modules/@css-render/vue3-ssr": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
+ "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz",
+ "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
+ "license": "MIT"
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@juggle/resize-observer": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmmirror.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
+ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/javascript": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmmirror.com/@lezer/javascript/-/javascript-1.5.4.tgz",
+ "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.2.0",
+ "@lezer/highlight": "^1.1.3",
+ "@lezer/lr": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz",
+ "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz",
+ "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz",
+ "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz",
+ "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz",
+ "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz",
+ "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz",
+ "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz",
+ "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz",
+ "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz",
+ "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz",
+ "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz",
+ "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz",
+ "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz",
+ "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz",
+ "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz",
+ "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz",
+ "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz",
+ "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz",
+ "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz",
+ "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz",
+ "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz",
+ "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz",
+ "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz",
+ "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
+ "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash-es": {
+ "version": "4.17.12",
+ "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+ "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
+ "node_modules/@types/sortablejs": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz",
+ "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vicons/ionicons5": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmmirror.com/@vicons/ionicons5/-/ionicons5-0.13.0.tgz",
+ "integrity": "sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
+ "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/shared": "3.5.42",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
+ "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
+ "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@vue/compiler-core": "3.5.42",
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/compiler-ssr": "3.5.42",
+ "@vue/shared": "3.5.42",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.19",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
+ "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz",
+ "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
+ "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
+ "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.42",
+ "@vue/runtime-core": "3.5.42",
+ "@vue/shared": "3.5.42",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
+ "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.42",
+ "@vue/runtime-dom": "3.5.42",
+ "@vue/shared": "3.5.42"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz",
+ "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-validator": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+ "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/codemirror": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
+ "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.0.0",
+ "@codemirror/commands": "^6.0.0",
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/lint": "^6.0.0",
+ "@codemirror/search": "^6.0.0",
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.0.0"
+ }
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/css-render": {
+ "version": "0.15.14",
+ "resolved": "https://registry.npmmirror.com/css-render/-/css-render-0.15.14.tgz",
+ "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "~0.8.0",
+ "csstype": "~3.0.5"
+ }
+ },
+ "node_modules/css-render/node_modules/csstype": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.0.11.tgz",
+ "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz",
+ "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-tz": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
+ "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "date-fns": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/echarts": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
+ "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "2.3.0",
+ "zrender": "6.1.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/evtd": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmmirror.com/evtd/-/evtd-0.2.4.tgz",
+ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/highlight.js": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.12.0.tgz",
+ "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "license": "MIT"
+ },
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/naive-ui": {
+ "version": "2.45.3",
+ "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.45.3.tgz",
+ "integrity": "sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/plugin-bem": "^0.15.14",
+ "@css-render/vue3-ssr": "^0.15.14",
+ "@types/lodash": "^4.17.20",
+ "@types/lodash-es": "^4.17.12",
+ "async-validator": "^4.2.5",
+ "css-render": "^0.15.14",
+ "csstype": "^3.1.3",
+ "date-fns": "^4.1.0",
+ "date-fns-tz": "^3.2.0",
+ "evtd": "^0.2.4",
+ "highlight.js": "^11.8.0",
+ "lodash": "^4.17.21",
+ "lodash-es": "^4.17.21",
+ "seemly": "^0.3.10",
+ "treemate": "^0.3.11",
+ "vdirs": "^0.1.8",
+ "vooks": "^0.2.12",
+ "vueuc": "^0.4.66"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.1",
+ "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.1.tgz",
+ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.1",
+ "@rollup/rollup-android-arm64": "4.63.1",
+ "@rollup/rollup-darwin-arm64": "4.63.1",
+ "@rollup/rollup-darwin-x64": "4.63.1",
+ "@rollup/rollup-freebsd-arm64": "4.63.1",
+ "@rollup/rollup-freebsd-x64": "4.63.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.1",
+ "@rollup/rollup-linux-arm64-musl": "4.63.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.1",
+ "@rollup/rollup-linux-loong64-musl": "4.63.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-gnu": "4.63.1",
+ "@rollup/rollup-linux-x64-musl": "4.63.1",
+ "@rollup/rollup-openbsd-x64": "4.63.1",
+ "@rollup/rollup-openharmony-arm64": "4.63.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.1",
+ "@rollup/rollup-win32-x64-gnu": "4.63.1",
+ "@rollup/rollup-win32-x64-msvc": "4.63.1",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/seemly": {
+ "version": "0.3.10",
+ "resolved": "https://registry.npmmirror.com/seemly/-/seemly-0.3.10.tgz",
+ "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
+ "license": "MIT"
+ },
+ "node_modules/sortablejs": {
+ "version": "1.15.7",
+ "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz",
+ "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/treemate": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmmirror.com/treemate/-/treemate-0.3.11.tgz",
+ "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+ "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/vdirs": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmmirror.com/vdirs/-/vdirs-0.1.8.tgz",
+ "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vooks": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmmirror.com/vooks/-/vooks-0.2.12.tgz",
+ "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "evtd": "^0.2.2"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz",
+ "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.42",
+ "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
+ "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.42",
+ "@vue/compiler-sfc": "3.5.42",
+ "@vue/runtime-dom": "3.5.42",
+ "@vue/server-renderer": "3.5.42",
+ "@vue/shared": "3.5.42"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-codemirror": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmmirror.com/vue-codemirror/-/vue-codemirror-6.1.1.tgz",
+ "integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/commands": "6.x",
+ "@codemirror/language": "6.x",
+ "@codemirror/state": "6.x",
+ "@codemirror/view": "6.x"
+ },
+ "peerDependencies": {
+ "codemirror": "6.x",
+ "vue": "3.x"
+ }
+ },
+ "node_modules/vue-echarts": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.3.0.tgz",
+ "integrity": "sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "echarts": "^6.0.0",
+ "vue": "^3.3.0"
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/vueuc": {
+ "version": "0.4.66",
+ "resolved": "https://registry.npmmirror.com/vueuc/-/vueuc-0.4.66.tgz",
+ "integrity": "sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@css-render/vue3-ssr": "^0.15.10",
+ "@juggle/resize-observer": "^3.3.1",
+ "css-render": "^0.15.10",
+ "evtd": "^0.2.4",
+ "seemly": "^0.3.6",
+ "vdirs": "^0.1.4",
+ "vooks": "^0.2.4"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.11"
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/zrender": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
+ "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tslib": "2.3.0"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..1d43ebc
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "fonu-web",
+ "private": true,
+ "version": "1.0.2",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@codemirror/commands": "^6.11.1",
+ "@codemirror/lang-javascript": "^6.2.5",
+ "@codemirror/language": "^6.12.4",
+ "@codemirror/legacy-modes": "^6.5.4",
+ "@codemirror/state": "^6.7.5",
+ "@codemirror/theme-one-dark": "^6.1.3",
+ "@codemirror/view": "^6.43.12",
+ "@vicons/ionicons5": "^0.13.0",
+ "codemirror": "^6.0.2",
+ "echarts": "^6.1.0",
+ "naive-ui": "^2.40.1",
+ "sortablejs": "^1.15.7",
+ "vue": "^3.5.13",
+ "vue-codemirror": "^6.1.1",
+ "vue-echarts": "^8.3.0",
+ "vue-router": "^4.5.0"
+ },
+ "devDependencies": {
+ "@types/sortablejs": "^1.15.9",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.3",
+ "vue-tsc": "^2.2.0"
+ }
+}
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
new file mode 100644
index 0000000..c83fcb9
--- /dev/null
+++ b/web/pnpm-lock.yaml
@@ -0,0 +1,1423 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@codemirror/commands':
+ specifier: ^6.11.1
+ version: 6.11.1
+ '@codemirror/lang-javascript':
+ specifier: ^6.2.5
+ version: 6.2.5
+ '@codemirror/language':
+ specifier: ^6.12.4
+ version: 6.12.4
+ '@codemirror/legacy-modes':
+ specifier: ^6.5.4
+ version: 6.5.4
+ '@codemirror/state':
+ specifier: ^6.7.5
+ version: 6.7.5
+ '@codemirror/theme-one-dark':
+ specifier: ^6.1.3
+ version: 6.1.3
+ '@codemirror/view':
+ specifier: ^6.43.12
+ version: 6.43.12
+ '@vicons/ionicons5':
+ specifier: ^0.13.0
+ version: 0.13.0
+ codemirror:
+ specifier: ^6.0.2
+ version: 6.0.2
+ echarts:
+ specifier: ^6.1.0
+ version: 6.1.0
+ naive-ui:
+ specifier: ^2.40.1
+ version: 2.45.3(vue@3.5.43(typescript@5.9.3))
+ sortablejs:
+ specifier: ^1.15.7
+ version: 1.15.7
+ vue:
+ specifier: ^3.5.13
+ version: 3.5.43(typescript@5.9.3)
+ vue-codemirror:
+ specifier: ^6.1.1
+ version: 6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3))
+ vue-echarts:
+ specifier: ^8.3.0
+ version: 8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3))
+ vue-router:
+ specifier: ^4.5.0
+ version: 4.6.4(vue@3.5.43(typescript@5.9.3))
+ devDependencies:
+ '@types/sortablejs':
+ specifier: ^1.15.9
+ version: 1.15.9
+ '@vitejs/plugin-vue':
+ specifier: ^5.2.1
+ version: 5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3))
+ typescript:
+ specifier: ^5.7.2
+ version: 5.9.3
+ vite:
+ specifier: ^6.0.3
+ version: 6.4.3
+ vue-tsc:
+ specifier: ^2.2.0
+ version: 2.2.12(typescript@5.9.3)
+
+packages:
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.8':
+ resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@codemirror/autocomplete@6.20.3':
+ resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
+
+ '@codemirror/commands@6.11.1':
+ resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
+
+ '@codemirror/lang-javascript@6.2.5':
+ resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==}
+
+ '@codemirror/language@6.12.4':
+ resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
+
+ '@codemirror/legacy-modes@6.5.4':
+ resolution: {integrity: sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==}
+
+ '@codemirror/lint@6.9.7':
+ resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
+
+ '@codemirror/search@6.7.2':
+ resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
+
+ '@codemirror/state@6.7.5':
+ resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
+
+ '@codemirror/theme-one-dark@6.1.3':
+ resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
+
+ '@codemirror/view@6.43.12':
+ resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
+
+ '@css-render/plugin-bem@0.15.14':
+ resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==}
+ peerDependencies:
+ css-render: ~0.15.14
+
+ '@css-render/vue3-ssr@0.15.14':
+ resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==}
+ peerDependencies:
+ vue: ^3.0.11
+
+ '@emotion/hash@0.8.0':
+ resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@juggle/resize-observer@3.4.0':
+ resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
+
+ '@lezer/common@1.5.2':
+ resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==}
+
+ '@lezer/highlight@1.2.3':
+ resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
+
+ '@lezer/javascript@1.5.4':
+ resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==}
+
+ '@lezer/lr@1.4.10':
+ resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
+
+ '@marijn/find-cluster-break@1.0.4':
+ resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-android-arm-eabi@4.63.3':
+ resolution: {integrity: sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.63.3':
+ resolution: {integrity: sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.63.3':
+ resolution: {integrity: sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.63.3':
+ resolution: {integrity: sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.63.3':
+ resolution: {integrity: sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.63.3':
+ resolution: {integrity: sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.63.3':
+ resolution: {integrity: sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.63.3':
+ resolution: {integrity: sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.63.3':
+ resolution: {integrity: sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.63.3':
+ resolution: {integrity: sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.63.3':
+ resolution: {integrity: sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.63.3':
+ resolution: {integrity: sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.63.3':
+ resolution: {integrity: sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.63.3':
+ resolution: {integrity: sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.63.3':
+ resolution: {integrity: sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.63.3':
+ resolution: {integrity: sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.63.3':
+ resolution: {integrity: sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.63.3':
+ resolution: {integrity: sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.63.3':
+ resolution: {integrity: sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.63.3':
+ resolution: {integrity: sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.63.3':
+ resolution: {integrity: sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.63.3':
+ resolution: {integrity: sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.63.3':
+ resolution: {integrity: sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.63.3':
+ resolution: {integrity: sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.63.3':
+ resolution: {integrity: sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/lodash-es@4.17.12':
+ resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
+
+ '@types/lodash@4.17.25':
+ resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==}
+
+ '@types/sortablejs@1.15.9':
+ resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
+
+ '@vicons/ionicons5@0.13.0':
+ resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==}
+
+ '@vitejs/plugin-vue@5.2.4':
+ resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ peerDependencies:
+ vite: ^5.0.0 || ^6.0.0
+ vue: ^3.2.25
+
+ '@volar/language-core@2.4.15':
+ resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
+
+ '@volar/source-map@2.4.15':
+ resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
+
+ '@volar/typescript@2.4.15':
+ resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/compiler-core@3.5.43':
+ resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==}
+
+ '@vue/compiler-dom@3.5.43':
+ resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==}
+
+ '@vue/compiler-sfc@3.5.43':
+ resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==}
+
+ '@vue/compiler-ssr@3.5.43':
+ resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==}
+
+ '@vue/compiler-vue2@2.7.16':
+ resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
+
+ '@vue/devtools-api@6.6.4':
+ resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
+
+ '@vue/language-core@2.2.12':
+ resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@vue/reactivity@3.5.43':
+ resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==}
+
+ '@vue/runtime-core@3.5.43':
+ resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==}
+
+ '@vue/runtime-dom@3.5.43':
+ resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==}
+
+ '@vue/server-renderer@3.5.43':
+ resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==}
+
+ '@vue/shared@3.5.43':
+ resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==}
+
+ alien-signals@1.0.13:
+ resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
+
+ async-validator@4.2.5:
+ resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
+
+ balanced-match@1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+
+ brace-expansion@2.1.7:
+ resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==}
+
+ codemirror@6.0.2:
+ resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==}
+
+ crelt@1.0.7:
+ resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
+
+ css-render@0.15.14:
+ resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==}
+
+ csstype@3.0.11:
+ resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ date-fns-tz@3.2.0:
+ resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==}
+ peerDependencies:
+ date-fns: ^3.0.0 || ^4.0.0
+
+ date-fns@4.4.0:
+ resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
+
+ de-indent@1.0.2:
+ resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
+
+ echarts@6.1.0:
+ resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
+
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
+ evtd@0.2.4:
+ resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ he@1.2.0:
+ resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
+ hasBin: true
+
+ highlight.js@11.12.0:
+ resolution: {integrity: sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==}
+ engines: {node: '>=12.0.0'}
+
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
+ magic-string@0.30.21:
+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ muggle-string@0.4.1:
+ resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
+
+ naive-ui@2.45.3:
+ resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ vue: ^3.0.0
+
+ nanoid@3.3.19:
+ resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ path-browserify@1.0.1:
+ resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.28:
+ resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rollup@4.63.3:
+ resolution: {integrity: sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ seemly@0.3.10:
+ resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==}
+
+ sortablejs@1.15.7:
+ resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ style-mod@4.1.3:
+ resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ treemate@0.3.11:
+ resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==}
+
+ tslib@2.3.0:
+ resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ vdirs@0.1.8:
+ resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==}
+ peerDependencies:
+ vue: ^3.0.11
+
+ vite@6.4.3:
+ resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vooks@0.2.12:
+ resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
+ peerDependencies:
+ vue: ^3.0.0
+
+ vscode-uri@3.2.0:
+ resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
+
+ vue-codemirror@6.1.1:
+ resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==}
+ peerDependencies:
+ codemirror: 6.x
+ vue: 3.x
+
+ vue-echarts@8.3.0:
+ resolution: {integrity: sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==}
+ peerDependencies:
+ echarts: ^6.0.0
+ vue: ^3.3.0
+
+ vue-router@4.6.4:
+ resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
+ peerDependencies:
+ vue: ^3.5.0
+
+ vue-tsc@2.2.12:
+ resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
+ hasBin: true
+ peerDependencies:
+ typescript: '>=5.0.0'
+
+ vue@3.5.43:
+ resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==}
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ vueuc@0.4.66:
+ resolution: {integrity: sha512-hHrZl+0AsrUvqYPBXSXQ2QaSrO3razjeUseLAhJWlOS/Hdfw/Y2MU05fOTP1qn1A3121gMxsHLaEz2V6CkWdTg==}
+ peerDependencies:
+ vue: ^3.0.11
+
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
+ zrender@6.1.0:
+ resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
+
+snapshots:
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.8':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@codemirror/autocomplete@6.20.3':
+ dependencies:
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/common': 1.5.2
+
+ '@codemirror/commands@6.11.1':
+ dependencies:
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/common': 1.5.2
+
+ '@codemirror/lang-javascript@6.2.5':
+ dependencies:
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/language': 6.12.4
+ '@codemirror/lint': 6.9.7
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/common': 1.5.2
+ '@lezer/javascript': 1.5.4
+
+ '@codemirror/language@6.12.4':
+ dependencies:
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/common': 1.5.2
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.10
+ style-mod: 4.1.3
+
+ '@codemirror/legacy-modes@6.5.4':
+ dependencies:
+ '@codemirror/language': 6.12.4
+
+ '@codemirror/lint@6.9.7':
+ dependencies:
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ crelt: 1.0.7
+
+ '@codemirror/search@6.7.2':
+ dependencies:
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ crelt: 1.0.7
+
+ '@codemirror/state@6.7.5':
+ dependencies:
+ '@marijn/find-cluster-break': 1.0.4
+
+ '@codemirror/theme-one-dark@6.1.3':
+ dependencies:
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ '@lezer/highlight': 1.2.3
+
+ '@codemirror/view@6.43.12':
+ dependencies:
+ '@codemirror/state': 6.7.5
+ crelt: 1.0.7
+ style-mod: 4.1.3
+ w3c-keyname: 2.2.8
+
+ '@css-render/plugin-bem@0.15.14(css-render@0.15.14)':
+ dependencies:
+ css-render: 0.15.14
+
+ '@css-render/vue3-ssr@0.15.14(vue@3.5.43(typescript@5.9.3))':
+ dependencies:
+ vue: 3.5.43(typescript@5.9.3)
+
+ '@emotion/hash@0.8.0': {}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@juggle/resize-observer@3.4.0': {}
+
+ '@lezer/common@1.5.2': {}
+
+ '@lezer/highlight@1.2.3':
+ dependencies:
+ '@lezer/common': 1.5.2
+
+ '@lezer/javascript@1.5.4':
+ dependencies:
+ '@lezer/common': 1.5.2
+ '@lezer/highlight': 1.2.3
+ '@lezer/lr': 1.4.10
+
+ '@lezer/lr@1.4.10':
+ dependencies:
+ '@lezer/common': 1.5.2
+
+ '@marijn/find-cluster-break@1.0.4': {}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rollup/rollup-android-arm-eabi@4.63.3':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.63.3':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.63.3':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.63.3':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.63.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.63.3':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.63.3':
+ optional: true
+
+ '@types/estree@1.0.9': {}
+
+ '@types/lodash-es@4.17.12':
+ dependencies:
+ '@types/lodash': 4.17.25
+
+ '@types/lodash@4.17.25': {}
+
+ '@types/sortablejs@1.15.9': {}
+
+ '@vicons/ionicons5@0.13.0': {}
+
+ '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.43(typescript@5.9.3))':
+ dependencies:
+ vite: 6.4.3
+ vue: 3.5.43(typescript@5.9.3)
+
+ '@volar/language-core@2.4.15':
+ dependencies:
+ '@volar/source-map': 2.4.15
+
+ '@volar/source-map@2.4.15': {}
+
+ '@volar/typescript@2.4.15(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ path-browserify: 1.0.1
+ vscode-uri: 3.2.0
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/compiler-core@3.5.43':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@vue/shared': 3.5.43
+ entities: 7.0.1
+ estree-walker: 2.0.2
+ source-map-js: 1.2.1
+
+ '@vue/compiler-dom@3.5.43':
+ dependencies:
+ '@vue/compiler-core': 3.5.43
+ '@vue/shared': 3.5.43
+
+ '@vue/compiler-sfc@3.5.43':
+ dependencies:
+ '@babel/parser': 7.29.8
+ '@vue/compiler-core': 3.5.43
+ '@vue/compiler-dom': 3.5.43
+ '@vue/compiler-ssr': 3.5.43
+ '@vue/shared': 3.5.43
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.28
+ source-map-js: 1.2.1
+
+ '@vue/compiler-ssr@3.5.43':
+ dependencies:
+ '@vue/compiler-dom': 3.5.43
+ '@vue/shared': 3.5.43
+
+ '@vue/compiler-vue2@2.7.16':
+ dependencies:
+ de-indent: 1.0.2
+ he: 1.2.0
+
+ '@vue/devtools-api@6.6.4': {}
+
+ '@vue/language-core@2.2.12(typescript@5.9.3)':
+ dependencies:
+ '@volar/language-core': 2.4.15
+ '@vue/compiler-dom': 3.5.43
+ '@vue/compiler-vue2': 2.7.16
+ '@vue/shared': 3.5.43
+ alien-signals: 1.0.13
+ minimatch: 9.0.9
+ muggle-string: 0.4.1
+ path-browserify: 1.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+
+ '@vue/reactivity@3.5.43':
+ dependencies:
+ '@vue/shared': 3.5.43
+
+ '@vue/runtime-core@3.5.43':
+ dependencies:
+ '@vue/reactivity': 3.5.43
+ '@vue/shared': 3.5.43
+
+ '@vue/runtime-dom@3.5.43':
+ dependencies:
+ '@vue/reactivity': 3.5.43
+ '@vue/runtime-core': 3.5.43
+ '@vue/shared': 3.5.43
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.43':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.43
+ '@vue/runtime-dom': 3.5.43
+ '@vue/shared': 3.5.43
+
+ '@vue/shared@3.5.43': {}
+
+ alien-signals@1.0.13: {}
+
+ async-validator@4.2.5: {}
+
+ balanced-match@1.0.2: {}
+
+ brace-expansion@2.1.7:
+ dependencies:
+ balanced-match: 1.0.2
+
+ codemirror@6.0.2:
+ dependencies:
+ '@codemirror/autocomplete': 6.20.3
+ '@codemirror/commands': 6.11.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/lint': 6.9.7
+ '@codemirror/search': 6.7.2
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+
+ crelt@1.0.7: {}
+
+ css-render@0.15.14:
+ dependencies:
+ '@emotion/hash': 0.8.0
+ csstype: 3.0.11
+
+ csstype@3.0.11: {}
+
+ csstype@3.2.3: {}
+
+ date-fns-tz@3.2.0(date-fns@4.4.0):
+ dependencies:
+ date-fns: 4.4.0
+
+ date-fns@4.4.0: {}
+
+ de-indent@1.0.2: {}
+
+ echarts@6.1.0:
+ dependencies:
+ tslib: 2.3.0
+ zrender: 6.1.0
+
+ entities@7.0.1: {}
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ estree-walker@2.0.2: {}
+
+ evtd@0.2.4: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ he@1.2.0: {}
+
+ highlight.js@11.12.0: {}
+
+ lodash-es@4.18.1: {}
+
+ lodash@4.18.1: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.7
+
+ muggle-string@0.4.1: {}
+
+ naive-ui@2.45.3(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ '@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
+ '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3))
+ '@types/lodash': 4.17.25
+ '@types/lodash-es': 4.17.12
+ async-validator: 4.2.5
+ css-render: 0.15.14
+ csstype: 3.2.3
+ date-fns: 4.4.0
+ date-fns-tz: 3.2.0(date-fns@4.4.0)
+ evtd: 0.2.4
+ highlight.js: 11.12.0
+ lodash: 4.18.1
+ lodash-es: 4.18.1
+ seemly: 0.3.10
+ treemate: 0.3.11
+ vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3))
+ vooks: 0.2.12(vue@3.5.43(typescript@5.9.3))
+ vue: 3.5.43(typescript@5.9.3)
+ vueuc: 0.4.66(vue@3.5.43(typescript@5.9.3))
+
+ nanoid@3.3.19: {}
+
+ path-browserify@1.0.1: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ postcss@8.5.28:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rollup@4.63.3:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.63.3
+ '@rollup/rollup-android-arm64': 4.63.3
+ '@rollup/rollup-darwin-arm64': 4.63.3
+ '@rollup/rollup-darwin-x64': 4.63.3
+ '@rollup/rollup-freebsd-arm64': 4.63.3
+ '@rollup/rollup-freebsd-x64': 4.63.3
+ '@rollup/rollup-linux-arm-gnueabihf': 4.63.3
+ '@rollup/rollup-linux-arm-musleabihf': 4.63.3
+ '@rollup/rollup-linux-arm64-gnu': 4.63.3
+ '@rollup/rollup-linux-arm64-musl': 4.63.3
+ '@rollup/rollup-linux-loong64-gnu': 4.63.3
+ '@rollup/rollup-linux-loong64-musl': 4.63.3
+ '@rollup/rollup-linux-ppc64-gnu': 4.63.3
+ '@rollup/rollup-linux-ppc64-musl': 4.63.3
+ '@rollup/rollup-linux-riscv64-gnu': 4.63.3
+ '@rollup/rollup-linux-riscv64-musl': 4.63.3
+ '@rollup/rollup-linux-s390x-gnu': 4.63.3
+ '@rollup/rollup-linux-x64-gnu': 4.63.3
+ '@rollup/rollup-linux-x64-musl': 4.63.3
+ '@rollup/rollup-openbsd-x64': 4.63.3
+ '@rollup/rollup-openharmony-arm64': 4.63.3
+ '@rollup/rollup-win32-arm64-msvc': 4.63.3
+ '@rollup/rollup-win32-ia32-msvc': 4.63.3
+ '@rollup/rollup-win32-x64-gnu': 4.63.3
+ '@rollup/rollup-win32-x64-msvc': 4.63.3
+ fsevents: 2.3.3
+
+ seemly@0.3.10: {}
+
+ sortablejs@1.15.7: {}
+
+ source-map-js@1.2.1: {}
+
+ style-mod@4.1.3: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ treemate@0.3.11: {}
+
+ tslib@2.3.0: {}
+
+ typescript@5.9.3: {}
+
+ vdirs@0.1.8(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ evtd: 0.2.4
+ vue: 3.5.43(typescript@5.9.3)
+
+ vite@6.4.3:
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.28
+ rollup: 4.63.3
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ vooks@0.2.12(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ evtd: 0.2.4
+ vue: 3.5.43(typescript@5.9.3)
+
+ vscode-uri@3.2.0: {}
+
+ vue-codemirror@6.1.1(codemirror@6.0.2)(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ '@codemirror/commands': 6.11.1
+ '@codemirror/language': 6.12.4
+ '@codemirror/state': 6.7.5
+ '@codemirror/view': 6.43.12
+ codemirror: 6.0.2
+ vue: 3.5.43(typescript@5.9.3)
+
+ vue-echarts@8.3.0(echarts@6.1.0)(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ echarts: 6.1.0
+ vue: 3.5.43(typescript@5.9.3)
+
+ vue-router@4.6.4(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.5.43(typescript@5.9.3)
+
+ vue-tsc@2.2.12(typescript@5.9.3):
+ dependencies:
+ '@volar/typescript': 2.4.15(typescript@5.9.3)
+ '@vue/language-core': 2.2.12(typescript@5.9.3)
+ typescript: 5.9.3
+
+ vue@3.5.43(typescript@5.9.3):
+ dependencies:
+ '@vue/compiler-dom': 3.5.43
+ '@vue/compiler-sfc': 3.5.43
+ '@vue/runtime-dom': 3.5.43
+ '@vue/server-renderer': 3.5.43
+ '@vue/shared': 3.5.43
+ optionalDependencies:
+ typescript: 5.9.3
+
+ vueuc@0.4.66(vue@3.5.43(typescript@5.9.3)):
+ dependencies:
+ '@css-render/vue3-ssr': 0.15.14(vue@3.5.43(typescript@5.9.3))
+ '@juggle/resize-observer': 3.4.0
+ css-render: 0.15.14
+ evtd: 0.2.4
+ seemly: 0.3.10
+ vdirs: 0.1.8(vue@3.5.43(typescript@5.9.3))
+ vooks: 0.2.12(vue@3.5.43(typescript@5.9.3))
+ vue: 3.5.43(typescript@5.9.3)
+
+ w3c-keyname@2.2.8: {}
+
+ zrender@6.1.0:
+ dependencies:
+ tslib: 2.3.0
diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml
new file mode 100644
index 0000000..00f6fc4
--- /dev/null
+++ b/web/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: set this to true or false
diff --git a/web/public/favicon.png b/web/public/favicon.png
new file mode 100644
index 0000000..eab0c1e
Binary files /dev/null and b/web/public/favicon.png differ
diff --git a/web/src/App.vue b/web/src/App.vue
new file mode 100644
index 0000000..0ba8861
--- /dev/null
+++ b/web/src/App.vue
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
new file mode 100644
index 0000000..8885dbd
--- /dev/null
+++ b/web/src/api/client.ts
@@ -0,0 +1,372 @@
+import type {
+ AccessLogEntry,
+ ApiError,
+ AppVersion,
+ AuthStatus,
+ CertificateCAOption,
+ CertificateRecord,
+ DashboardStatus,
+ DDNSConfig,
+ DDNSSavePayload,
+ DDNSTestPayload,
+ FRPResponse,
+ FRPSavePayload,
+ FRPSaveResponse,
+ FRPStatus,
+ FrpBinary,
+ FrpDownloadTask,
+ FrpProxy,
+ FrpProxyPayload,
+ FrpProxiesTraffic,
+ FrpRelease,
+ FrpRuntimeInfo,
+ FrpServer,
+ FrpServerDiagnostics,
+ FrpServerDetail,
+ FrpServerPayload,
+ FrpStatus,
+ DiscoveredService,
+ ProxyClientConn,
+ ChinaCIDRStatus,
+ ProxyRule,
+ ProxySavePayload,
+ ProxyTraffic,
+ GlobalNginxView,
+ RuleNginxView,
+ SettingsMap,
+ SystemLogEntry,
+ NotifyTestPayload,
+} from './types'
+
+export function asList(value: T[] | null | undefined): T[] {
+ return Array.isArray(value) ? value : []
+}
+
+const REQUEST_TIMEOUT_MS = 30_000
+const CERT_REQUEST_TIMEOUT_MS = 10 * 60_000
+
+async function request(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise {
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), timeoutMs)
+ try {
+ return await requestWithSignal(path, init, controller.signal)
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ throw new Error(
+ timeoutMs > REQUEST_TIMEOUT_MS
+ ? '证书操作超时,请稍后在证书列表查看状态,或在「日志 → 实时日志」查看 Nginx 输出'
+ : '请求超时,请检查 Fonu 服务或 Nginx 状态',
+ )
+ }
+ throw error
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
+async function requestWithSignal(path: string, init?: RequestInit, signal?: AbortSignal): Promise {
+ const response = await fetch(path, {
+ credentials: 'include',
+ signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(init?.headers ?? {}),
+ },
+ ...init,
+ })
+
+ if (!response.ok) {
+ let message = '请求失败'
+ try {
+ const body = (await response.json()) as ApiError
+ if (body.error) {
+ message = body.error
+ }
+ } catch {
+ // ignore
+ }
+ throw new Error(message)
+ }
+
+ if (response.status === 204) {
+ return undefined as T
+ }
+ return (await response.json()) as T
+}
+
+export const api = {
+ getVersion: () => request('/api/version'),
+ authStatus: () => request('/api/auth/status'),
+ login: (username: string, password: string) =>
+ request<{ message: string }>('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ username, password }),
+ }),
+ logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
+ changePassword: (old_password: string, new_password: string) =>
+ request<{ message: string }>('/api/auth/password', {
+ method: 'POST',
+ body: JSON.stringify({ old_password, new_password }),
+ }),
+
+ getStatus: () => request('/api/status'),
+ listProxies: () => request('/api/proxies'),
+ createProxy: (payload: ProxySavePayload) =>
+ request('/api/proxies', { method: 'POST', body: JSON.stringify(payload) }),
+ updateProxy: (id: number, payload: Partial) =>
+ request(`/api/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
+ deleteProxy: (id: number) => request(`/api/proxies/${id}`, { method: 'DELETE' }),
+ reorderProxies: (ids: number[]) =>
+ request('/api/proxies/reorder', { method: 'PUT', body: JSON.stringify({ ids }) }),
+ getProxyTraffic: () => request('/api/proxies/traffic'),
+ getProxyClients: (id: number) => request(`/api/proxies/${id}/clients`),
+ getProxyNginx: (id: number) => request(`/api/proxies/${id}/nginx`),
+ saveProxyNginx: (id: number, payload: { mode: 'auto' | 'custom'; content?: string }) =>
+ request(`/api/proxies/${id}/nginx`, { method: 'PUT', body: JSON.stringify(payload) }),
+ rollbackProxyNginx: (id: number, backup?: string) =>
+ request(`/api/proxies/${id}/nginx/rollback`, {
+ method: 'POST',
+ body: JSON.stringify(backup ? { backup } : {}),
+ }),
+
+ listDDNS: () => request('/api/ddns'),
+ listDDNSLite: () => request('/api/ddns?lite=1'),
+ createDDNS: (payload: DDNSSavePayload) =>
+ request('/api/ddns', { method: 'POST', body: JSON.stringify(payload) }),
+ updateDDNS: (id: number, payload: DDNSSavePayload) =>
+ request(`/api/ddns/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
+ deleteDDNS: (id: number) => request(`/api/ddns/${id}`, { method: 'DELETE' }),
+ testDDNS: (payload: DDNSTestPayload) =>
+ request<{ message: string }>('/api/ddns/test', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+ updateAllDDNS: () => request('/api/ddns/update', { method: 'POST' }),
+ updateDDNSOne: (id: number) => request(`/api/ddns/${id}/update`, { method: 'POST' }),
+
+ listCertificates: () => request('/api/certificates'),
+ listCertificateCAOptions: () => request('/api/certificates/ca-options'),
+ applyCertificate: (payload: {
+ dns_zone?: string
+ ddns_config_id?: number
+ domains: string[]
+ ca?: string
+ email?: string
+ }) =>
+ request<{ job_id: string }>('/api/certificates/apply', {
+ method: 'POST',
+ body: JSON.stringify({
+ dns_zone: payload.dns_zone ?? '',
+ ddns_config_id: payload.ddns_config_id ?? 0,
+ domains: payload.domains,
+ ca: payload.ca ?? '',
+ email: payload.email ?? '',
+ }),
+ }),
+ certificateApplyStreamURL: (jobId: string) => `/api/certificates/jobs/${encodeURIComponent(jobId)}/stream`,
+ importCertificate: (payload: {
+ certificate?: string
+ private_key?: string
+ cert_path?: string
+ key_path?: string
+ }) =>
+ request('/api/certificates/import', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+ async downloadCertificate(domain: string, part: 'zip' | 'cert' | 'key' = 'zip') {
+ const response = await fetch(
+ `/api/certificates/${encodeURIComponent(domain)}/download?part=${part}`,
+ { credentials: 'include' },
+ )
+ if (!response.ok) {
+ let message = '下载证书失败'
+ try {
+ const body = (await response.json()) as ApiError
+ if (body.error) message = body.error
+ } catch {
+ // ignore
+ }
+ throw new Error(message)
+ }
+ const blob = await response.blob()
+ const disposition = response.headers.get('Content-Disposition') ?? ''
+ const match = disposition.match(/filename="?([^";\n]+)"?/)
+ const filename = match?.[1] ?? `${domain}.zip`
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ link.click()
+ URL.revokeObjectURL(url)
+ },
+ deleteCertificate: (domain: string) =>
+ request<{ message: string }>(`/api/certificates/${encodeURIComponent(domain)}`, { method: 'DELETE' }),
+ renewCertificate: (domain?: string, ca?: string) =>
+ request(
+ '/api/certificates/renew',
+ {
+ method: 'POST',
+ body: JSON.stringify({ domain: domain ?? '', ca: ca ?? '' }),
+ },
+ CERT_REQUEST_TIMEOUT_MS,
+ ),
+
+ getSettings: () => request('/api/settings'),
+ saveSettings: (payload: SettingsMap) =>
+ request('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }),
+ getGlobalNginx: () => request('/api/settings/nginx/global'),
+ saveGlobalNginx: (payload: { mode: 'auto' | 'custom'; content?: string }) =>
+ request('/api/settings/nginx/global', { method: 'PUT', body: JSON.stringify(payload) }),
+ rollbackGlobalNginx: (backup?: string) =>
+ request('/api/settings/nginx/global/rollback', {
+ method: 'POST',
+ body: JSON.stringify(backup ? { backup } : {}),
+ }),
+ getChinaCIDRStatus: () => request('/api/settings/china-cidr'),
+ refreshChinaCIDR: () =>
+ request('/api/settings/china-cidr/refresh', { method: 'POST' }),
+ testNotify: (payload: NotifyTestPayload) =>
+ request<{ message: string }>('/api/settings/notify/test', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ }),
+
+ getFRP: () => request('/api/frp'),
+ saveFRP: (payload: FRPSavePayload) =>
+ request('/api/frp', {
+ method: 'PUT',
+ body: JSON.stringify(payload),
+ }),
+ getFRPStatus: () => request('/api/frp/status'),
+ syncFRPDomains: () =>
+ request('/api/frp/sync-domains', {
+ method: 'POST',
+ }),
+ getFRPLogs: (limit = 200) => request(`/api/frp/logs?limit=${limit}`),
+
+ // ===== FRP 多服务端模块(/api/frpmulti)=====
+ getFrpStatus: () => request('/api/frpmulti/status'),
+ getFrpRuntime: () => request('/api/frpmulti/runtime'),
+ setFrpDownloadProxy: (proxy: string) => request('/api/frpmulti/runtime/download-proxy', { method: 'PUT', body: JSON.stringify({ proxy }) }),
+ listFrpReleases: () => request('/api/frpmulti/releases'),
+ listFrpBinaries: () => request('/api/frpmulti/binaries'),
+ getFrpDownloadStatus: () => request('/api/frpmulti/download'),
+ downloadFrpRelease: (version: string) => request(`/api/frpmulti/releases/${encodeURIComponent(version)}/download`, { method: 'POST' }),
+ activateFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}/activate`, { method: 'POST' }),
+ deleteFrpBinary: (version: string) => request(`/api/frpmulti/binaries/${encodeURIComponent(version)}`, { method: 'DELETE' }),
+ listFrpServers: () => request('/api/frpmulti/servers'),
+ getFrpServerDiagnostics: (id: number) => request(`/api/frpmulti/servers/${id}/diagnostics`),
+ getFrpServerDetail: (id: number) => request(`/api/frpmulti/servers/${id}/detail`),
+ startFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/start`, { method: 'POST' }),
+ stopFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}/stop`, { method: 'POST' }),
+ createFrpServer: (payload: FrpServerPayload) =>
+ request('/api/frpmulti/servers', { method: 'POST', body: JSON.stringify(payload) }),
+ updateFrpServer: (id: number, payload: FrpServerPayload) =>
+ request(`/api/frpmulti/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
+ deleteFrpServer: (id: number) => request(`/api/frpmulti/servers/${id}`, { method: 'DELETE' }),
+ testFrpServer: (id: number) => request<{ message: string }>(`/api/frpmulti/servers/${id}/test`, { method: 'POST' }),
+ listFrpProxies: () => request('/api/frpmulti/proxies'),
+ getFrpProxyLogs: (id: number, limit = 200) => request(`/api/frpmulti/proxies/${id}/logs?limit=${limit}`),
+ listFrpProxiesTraffic: () => request('/api/frpmulti/proxies/traffic'),
+ createFrpProxy: (payload: FrpProxyPayload) =>
+ request('/api/frpmulti/proxies', { method: 'POST', body: JSON.stringify(payload) }),
+ updateFrpProxy: (id: number, payload: FrpProxyPayload) =>
+ request(`/api/frpmulti/proxies/${id}`, { method: 'PUT', body: JSON.stringify(payload) }),
+ deleteFrpProxy: (id: number) => request(`/api/frpmulti/proxies/${id}`, { method: 'DELETE' }),
+ startFrp: () => request('/api/frpmulti/start', { method: 'POST' }),
+ stopFrp: () => request('/api/frpmulti/stop', { method: 'POST' }),
+ reloadFrp: () => request('/api/frpmulti/reload', { method: 'POST' }),
+ getFrpLogs: (limit = 200, serverID?: number) => {
+ const query = new URLSearchParams({ limit: String(limit) })
+ if (serverID) query.set('server_id', String(serverID))
+ return request(`/api/frpmulti/logs?${query.toString()}`)
+ },
+ exportFrpConfig: () => request<{ content: string }>('/api/frpmulti/config/export'),
+ getFrpFRPSConfig: (id: number) => request<{ content: string }>(`/api/frpmulti/servers/${id}/frps-config`),
+ testFrpAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/test`, { method: 'POST' }),
+ probeFrpAgent: (id: number) => request>(`/api/frpmulti/servers/${id}/agent/probe`, { method: 'POST' }),
+ installFrpAgent: (id: number) => request(`/api/frpmulti/servers/${id}/agent/install`, { method: 'POST' }),
+ uninstallFrpAgent: (id: number, keepData: boolean) => request<{ ok: boolean; output: string }>(`/api/frpmulti/servers/${id}/agent/uninstall?keep_data=${keepData}`, { method: 'POST' }),
+ sshDiagnoseFrpAgent: (id: number) => request<{ output: string }>(`/api/frpmulti/servers/${id}/agent/ssh-diagnose`, { method: 'POST' }),
+ getFrpAgentStatus: (id: number) => request(`/api/frpmulti/servers/${id}/agent/status`),
+ getFrpAgentRoutes: (id: number) => request(`/api/frpmulti/servers/${id}/agent/routes`),
+ getFrpAgentRouteCandidates: (id: number) => request(`/api/frpmulti/servers/${id}/agent/route-candidates`),
+ deployFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'POST' }),
+ getFrpAgentRouteConf: (serverId: number, proxyId: number) =>
+ request<{ proxy_id: number; domain: string; content: string }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}/conf`),
+ installFrpAgentNginx: (serverId: number) =>
+ request<{ ok: boolean; manager?: string; output?: string; path?: string; error?: string }>(`/api/frpmulti/servers/${serverId}/agent/nginx/install`, { method: 'POST' }),
+ getFrpAgentRouteHealth: (serverId: number) =>
+ request(`/api/frpmulti/servers/${serverId}/agent/routes/health`),
+ removeFrpAgentRoute: (serverId: number, proxyId: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${serverId}/agent/routes/${proxyId}`, { method: 'DELETE' }),
+ pushFrpConfigToAgent: (id: number) => request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-config`, { method: 'POST' }),
+ frpsServerAction: (id: number, action: 'start' | 'stop' | 'restart') =>
+ request<{ ok: boolean }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
+ frpsServerActionRaw: (id: number, action: 'start' | 'stop' | 'restart') =>
+ request<{ ok: boolean; error?: string }>(`/api/frpmulti/servers/${id}/agent/frps-action`, { method: 'POST', body: JSON.stringify({ action }) }),
+ installFrpServer: (id: number, version: string, proxy: string) =>
+ request>(`/api/frpmulti/servers/${id}/agent/frps-install`, { method: 'POST', body: JSON.stringify({ version, proxy }) }),
+
+ getAccessLogs: (params?: { limit?: number; keyword?: string; status?: number }) => {
+ const q = new URLSearchParams()
+ if (params?.limit) q.set('limit', String(params.limit))
+ if (params?.keyword) q.set('keyword', params.keyword)
+ if (params?.status) q.set('status', String(params.status))
+ return request(`/api/logs/access?${q}`)
+ },
+ getErrorLogs: (params?: { limit?: number; keyword?: string }) => {
+ const q = new URLSearchParams()
+ if (params?.limit) q.set('limit', String(params.limit))
+ if (params?.keyword) q.set('keyword', params.keyword)
+ return request(`/api/logs/error?${q}`)
+ },
+ getSystemLogs: (params?: { limit?: number; level?: string; keyword?: string }) => {
+ const q = new URLSearchParams()
+ if (params?.limit) q.set('limit', String(params.limit))
+ if (params?.level) q.set('level', params.level)
+ if (params?.keyword) q.set('keyword', params.keyword)
+ return request(`/api/logs/system?${q}`)
+ },
+
+ scanDiscovery: (host?: string) => {
+ const q = new URLSearchParams()
+ if (host) q.set('host', host)
+ return request(`/api/discovery/scan?${q}`)
+ },
+
+ async exportBackup() {
+ const response = await fetch('/api/backup/export', { credentials: 'include' })
+ if (!response.ok) {
+ throw new Error('导出备份失败')
+ }
+ const blob = await response.blob()
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = `fonu-backup-${new Date().toISOString().slice(0, 10)}.tar.gz`
+ link.click()
+ URL.revokeObjectURL(url)
+ },
+
+ async restoreBackup(file: File) {
+ const form = new FormData()
+ form.append('file', file)
+ const response = await fetch('/api/backup/restore', {
+ method: 'POST',
+ credentials: 'include',
+ body: form,
+ })
+ if (!response.ok) {
+ let message = '恢复备份失败'
+ try {
+ const body = (await response.json()) as ApiError
+ if (body.error) message = body.error
+ } catch {
+ // ignore
+ }
+ throw new Error(message)
+ }
+ return (await response.json()) as { message: string }
+ },
+}
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
new file mode 100644
index 0000000..d452686
--- /dev/null
+++ b/web/src/api/types.ts
@@ -0,0 +1,794 @@
+export interface ProxyHost {
+ id: number
+ hostname: string
+ listen_port?: number | null
+}
+
+export interface BasicAuthConfig {
+ enabled?: boolean
+ username?: string
+ has_password?: boolean
+}
+
+export interface RateLimitConfig {
+ enabled?: boolean
+ rate?: number
+ burst?: number
+}
+
+export interface ConnLimitConfig {
+ enabled?: boolean
+ max?: number
+}
+
+export interface ProxySecurityConfig {
+ ip_blacklist?: string[]
+ ip_whitelist?: string[]
+ ip_whitelist_mode?: boolean
+ china_only?: boolean
+ basic_auth?: BasicAuthConfig
+ rate_limit?: RateLimitConfig
+ conn_limit?: ConnLimitConfig
+ proxy_ssl_verify_off?: boolean
+ proxy_host_upstream?: boolean
+ tls_min_13_only?: boolean
+ security_headers?: boolean
+}
+
+export interface ProxyRule {
+ id: number
+ domain: string
+ upstream: string
+ listen_port: number
+ listen_ipv4: boolean
+ listen_ipv6: boolean
+ hosts: ProxyHost[]
+ https_enabled: boolean
+ http_redirect: boolean
+ enabled: boolean
+ nginx_mode?: string
+ name: string
+ sort_order: number
+ security: ProxySecurityConfig
+ created_at: string
+ updated_at: string
+}
+
+export interface ChinaCIDRStatus {
+ updated_at?: string
+ entry_count_v4: number
+ entry_count_v6: number
+ last_error?: string
+ updating?: boolean
+ ready?: boolean
+}
+
+export interface ProxyTraffic {
+ rule_id: number
+ upload_total: number
+ download_total: number
+ upload_rate: number
+ download_rate: number
+ connections: number
+}
+
+export interface ProxyClientConn {
+ ip: string
+ last_seen: string
+}
+
+export interface ProxySecurityPayload {
+ ip_blacklist?: string[]
+ ip_whitelist?: string[]
+ ip_whitelist_mode?: boolean
+ ip_blacklist_text?: string
+ ip_whitelist_text?: string
+ china_only?: boolean
+ basic_auth?: {
+ enabled?: boolean
+ username?: string
+ password?: string
+ }
+ rate_limit?: RateLimitConfig
+ conn_limit?: ConnLimitConfig
+ proxy_ssl_verify_off?: boolean
+ proxy_host_upstream?: boolean
+ tls_min_13_only?: boolean
+ security_headers?: boolean
+}
+
+export interface ProxySavePayload {
+ upstream: string
+ listen_port?: number
+ listen_ipv4?: boolean
+ listen_ipv6?: boolean
+ hosts: string[]
+ https_enabled?: boolean
+ http_redirect?: boolean
+ enabled?: boolean
+ name?: string
+ security?: ProxySecurityPayload
+}
+
+export interface AuthStatus {
+ initialized: boolean
+ authenticated: boolean
+}
+
+export interface AppVersion {
+ version: string
+ nginx_http_port: number
+ nginx_https_port: number
+}
+
+export interface ApiError {
+ error: string
+}
+
+export interface DashboardStatus {
+ public_ipv4: string
+ public_ipv6: string
+ public_ip_source?: 'ddns' | 'detect' | 'none'
+ ddns_status: string
+ ddns_count: number
+ ddns_last_updated?: string
+ certificate_status: string
+ certificate_days: number
+ certificate_count: number
+ certificate_summary?: string
+ proxy_count: number
+ nginx_status: string
+ request_today: number
+ request_yesterday?: number
+ request_trend?: number | null
+ requests_hourly: number[]
+ requests_hourly_labels?: string[]
+ error_today: number
+ avg_response_ms: number
+ started_at: string
+ uptime_seconds: number
+}
+
+export interface DDNSDomainRecord {
+ domain: string
+ ipv4?: string
+ ipv6?: string
+ status: string
+ message?: string
+}
+
+export interface DDNSConfig {
+ id: number
+ provider: string
+ root_domain: string
+ record_name: string
+ record_names?: string[]
+ ipv4_enabled: boolean
+ ipv6_enabled: boolean
+ enabled: boolean
+ has_token: boolean
+ last_ipv4?: string
+ last_ipv6?: string
+ last_status?: string
+ last_error?: string
+ custom_ipv4?: string
+ custom_ipv6?: string
+ domain_records?: DDNSDomainRecord[]
+ last_updated_at?: string
+}
+
+export interface CertificateCAOption {
+ value: string
+ label: string
+}
+
+export interface CertificateJobDone {
+ ok: boolean
+ error?: string
+ domain?: string
+ cert_path?: string
+ key_path?: string
+ cert_dir?: string
+ expires_at?: string
+}
+
+export interface CertificateJobEvent {
+ type: 'log' | 'done'
+ level?: string
+ message?: string
+ result?: CertificateJobDone
+}
+
+export interface CertificateRecord {
+ id: number
+ domain: string
+ domains?: string[]
+ wildcard: boolean
+ acme_ca?: string
+ status: string
+ days_left: number
+ last_error?: string
+ expires_at?: string
+ last_renew_at?: string
+}
+
+export interface AccessLogEntry {
+ time: string
+ domain: string
+ server_port?: number
+ method: string
+ path: string
+ status: number
+ response_time: number
+ client_ip: string
+ upstream: string
+}
+
+export interface SystemLogEntry {
+ time: string
+ level: string
+ module: string
+ message: string
+}
+
+export type NotifyType = '' | 'email' | 'webhook' | 'telegram'
+export type WebhookProvider = 'bark' | 'ntfy' | 'gotify' | 'custom'
+
+export interface NotifyEmailConfig {
+ host: string
+ port: number
+ username: string
+ from: string
+ to: string[]
+ tls: boolean
+ has_password?: boolean
+}
+
+export interface NotifyWebhookConfig {
+ provider: WebhookProvider
+ server: string
+ key: string
+ topic: string
+ url: string
+ has_secret?: boolean
+}
+
+export interface NotifyTelegramConfig {
+ chat_id: string
+ proxy_url: string
+ has_bot_token?: boolean
+}
+
+export interface NotifyTestPayload {
+ type: NotifyType
+ email: NotifyEmailConfig
+ webhook: NotifyWebhookConfig
+ telegram: NotifyTelegramConfig
+ on_ddns_ip_change: boolean
+ on_ddns_failure: boolean
+ on_cert_expiry: boolean
+ on_cert_renew_success: boolean
+ on_cert_renew_failure: boolean
+ on_ip_frequent_access: boolean
+ on_login_failure: boolean
+ on_nginx_reload_failure: boolean
+ ip_frequent_threshold: number
+ ip_frequent_window_sec: number
+ login_failure_threshold: number
+ login_failure_window_sec: number
+ smtp_password?: string
+ webhook_secret?: string
+ telegram_token?: string
+}
+
+export type SettingsMap = Record
+
+export interface DiscoveredService {
+ name: string
+ port: number
+ host: string
+ upstream: string
+ detected: boolean
+ platform: string
+ title?: string
+ suggestion?: string
+}
+
+export interface DDNSSavePayload {
+ provider?: string
+ root_domain?: string
+ record_name?: string
+ record_names?: string[]
+ domains?: string[]
+ ipv4_enabled?: boolean
+ ipv6_enabled?: boolean
+ enabled?: boolean
+ custom_ipv4?: string
+ custom_ipv6?: string
+ api_token?: string
+ api_token_id?: string
+ api_secret?: string
+}
+
+export interface DDNSTestPayload {
+ config_id?: number
+ provider?: string
+ api_token?: string
+ api_token_id?: string
+ api_secret?: string
+}
+
+export interface FRPStatus {
+ enabled: boolean
+ connected: boolean
+ message: string
+ last_error?: string
+ server_endpoint?: string
+ connected_at?: string
+ uptime_seconds?: number
+ client_version?: string
+ http_gateway_enabled?: boolean
+ https_gateway_enabled?: boolean
+ synced_domain_count?: number
+}
+
+export interface FRPConfig {
+ enabled: boolean
+ server_addr: string
+ server_port: number
+ auth_token: string
+ has_auth_token: boolean
+ tls_enabled: boolean
+ custom_domains: string[]
+}
+
+export interface FRPResponse {
+ enabled: boolean
+ server_addr: string
+ server_port: number
+ auth_token: string
+ has_auth_token: boolean
+ tls_enabled: boolean
+ custom_domains: string[]
+ status: FRPStatus
+ frps_config: string
+ nginx_http_port: number
+ nginx_https_port: number
+}
+
+export interface FRPSaveResponse {
+ message: string
+ config: FRPConfig
+ status: FRPStatus
+ frps_config: string
+ nginx_http_port: number
+ nginx_https_port: number
+}
+
+export interface FRPSavePayload {
+ enabled: boolean
+ server_addr: string
+ server_port: number
+ auth_token?: string
+ tls_enabled: boolean
+ custom_domains: string[]
+}
+
+// ===== FRP 多服务端模块(/api/frpmulti,0.1.25 核心迁移)=====
+
+export interface FrpServer {
+ agent_url?: string
+ agent_configured?: boolean
+ ssh_configured?: boolean
+ mgmt_enabled?: boolean
+ ssh_host?: string
+ ssh_port?: number
+ ssh_user?: string
+ ssh_auth?: string
+ id: number
+ name: string
+ server_addr: string
+ server_port: number
+ tls_enabled: boolean
+ tls_server_name?: string
+ enabled: boolean
+ has_token: boolean
+ has_oidc_client_secret: boolean
+ dashboard_has_pwd?: boolean
+ options: FrpServerOptions
+ boot_status: 'starting' | 'running' | 'stopped' | 'error'
+ desired_state: 'starting' | 'running' | 'stopped' | 'error' | string
+ process_state: 'starting' | 'running' | 'stopped' | 'error' | 'unknown' | string
+ connection_state: 'connected' | 'connecting' | 'disconnected' | 'error' | 'unknown' | string
+ state_source: string
+ process_pid?: number
+ exit_code?: number
+ last_connected_at?: string
+ last_disconnected_at?: string
+ last_error?: string
+ last_started_at?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface FrpServerOptions {
+ user?: string
+ location?: string
+ /** frps 的 vhost 监听端口(http/https 反代回源端口),与 frps.toml 和 Nginx upstream 同源;留空用默认 8080/8443 */
+ vhost_http_port?: number
+ vhost_https_port?: number
+ /** frps 进阶:子域名根(规则 subdomain 生效前提)*/
+ sub_domain_host?: string
+ /** HTTP vhost 404 页面路径(VPS 本地路径)*/
+ custom_404_page?: string
+ /** 强制 TLS:只接受 TLS 连接(frpc 必须开 transport.tls.enable)*/
+ tls_force?: boolean
+ /** 端口白名单:逗号分隔的单端口或范围,如 6000,6005-6010;留空不限制 */
+ allow_ports?: string
+ /** 每客户端可绑定端口上限;0/留空不限 */
+ max_ports_per_client?: number
+ /** tcpmux 规则的 HTTP CONNECT 监听端口 */
+ tcpmux_http_connect_port?: number
+ /** frps 写文件日志 /var/log/frps.log(journalctl 之外)*/
+ frps_log_to_file?: boolean
+ /** 文件日志保留天数,默认 3 */
+ frps_log_max_days?: number
+ /** frps 管理接口(webServer),仅用于 Fonu 拉取穿透流量等只读信息 */
+ dashboard_addr?: string
+ dashboard_port?: number
+ dashboard_user?: string
+ dashboard_has_pwd?: boolean
+ auth_method: 'none' | 'token' | 'oidc'
+ oidc_client_id?: string
+ oidc_audience?: string
+ oidc_scope?: string
+ oidc_token_endpoint_url?: string
+ oidc_trusted_ca_path?: string
+ oidc_insecure_skip_verify?: boolean
+ oidc_proxy_url?: string
+ log_level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
+ log_max_days: number
+ protocol: 'tcp' | 'kcp' | 'quic' | 'websocket' | 'wss'
+ proxy_url?: string
+ dial_server_timeout: number
+ dial_server_keepalive: number
+ connect_server_local_ip?: string
+ dns_server?: string
+ pool_count: number
+ tcp_mux: boolean
+ tcp_mux_keepalive_interval: number
+ heartbeat_interval: number
+ heartbeat_timeout: number
+ login_fail_exit: boolean
+ tls_disable_custom_first_byte: boolean
+ auto_start: boolean
+ remark?: string
+ tls_certificate_configured?: boolean
+ tls_key_configured?: boolean
+ tls_trusted_ca_configured?: boolean
+}
+
+export interface FrpProxy {
+ id: number
+ server_id: number
+ name: string
+ type: 'tcp' | 'udp' | 'http' | 'https' | 'tcpmux' | 'stcp' | 'sudp' | 'xtcp'
+ local_ip: string
+ local_port: number
+ remote_port?: number | null
+ custom_domains: string[]
+ host_header_rewrite?: string
+ options: FrpProxyOptions
+ enabled: boolean
+ remark?: string
+ created_at: string
+ updated_at: string
+}
+
+export interface FrpProxyOptions {
+ subdomain?: string
+ /** WebSocket 开关:仅作用于公网 Nginx 反代模板(加 Upgrade/Connection 头) */
+ websocket?: boolean
+ /** 公网反代:HTTP 80 强制 301 跳转 HTTPS(需公网证书已部署)*/
+ redirect_https?: boolean
+ /** 公网反代:IP 白名单(allow/deny),空则不限制 */
+ allow_ips?: string[]
+ /** 公网反代:Basic Auth 用户名;密码由 agent 首次部署自动生成(见配置注释)*/
+ basic_auth_user?: string
+ /** 公网反代:IP 黑名单(deny),与白名单可同时使用 */
+ deny_ips?: string[]
+ /** 公网反代:Basic Auth 自定义密码;留空则首次部署自动生成 */
+ basic_auth_password?: string
+ /** 公网反代:显式关闭 HTTPS(证书已推送也不监听 443);缺省 = 启用 */
+ https_disabled?: boolean
+ /** 公网反代:客户端上传大小限制,如 50m */
+ client_max_body_size?: string
+ /** 公网反代:代理读超时,如 3600s(长轮询 / 流媒体 / 大文件必需)*/
+ proxy_read_timeout?: string
+ /** 公网反代:安全响应头(HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy)*/
+ security_headers?: boolean
+ /** 公网反代:仅允许 TLS 1.3 */
+ tls13_only?: boolean
+ /** 公网反代:请求限流(次/秒);0 = 不限制 */
+ rate_limit_rate?: number
+ /** 公网反代:限流突发允许量 */
+ rate_limit_burst?: number
+ /** 公网反代:每 IP 并发连接数上限;0 = 不限制 */
+ conn_limit_max?: number
+ /** 公网反代:仅允许中国大陆 IP(部署时自动下发本地维护的 IP 段)*/
+ china_only?: boolean
+ locations?: string[]
+ route_by_http_user?: string
+ http_user?: string
+ http_password?: string
+ multiplexer?: string
+ secret_key?: string
+ allow_users?: string[]
+ transport: {
+ bandwidth_limit?: string
+ bandwidth_limit_mode?: 'client' | 'server' | string
+ use_encryption?: boolean
+ use_compression?: boolean
+ proxy_protocol_version?: 'v1' | 'v2' | string
+ }
+ health_check: {
+ type?: 'tcp' | 'http' | string
+ path?: string
+ interval_seconds?: number
+ max_failed?: number
+ timeout_seconds?: number
+ http_headers?: Array<{ name: string; value: string }>
+ }
+ load_balancer: { group?: string; group_key?: string }
+ metadatas?: Record
+ request_headers?: Record
+ response_headers?: Record
+ plugin?: {
+ type: string
+ unix_path?: string
+ local_path?: string
+ strip_prefix?: string
+ local_addr?: string
+ http_user?: string
+ http_password?: string
+ username?: string
+ password?: string
+ crt_path?: string
+ key_path?: string
+ host_header_rewrite?: string
+ destination_ip?: string
+ }
+ nat_traversal: { disable_assisted_addrs?: boolean }
+}
+
+/** frps 侧单条穿透规则的流量快照;速率为两次采样间隔内的均值 */
+export interface FrpProxyTraffic {
+ traffic_in: number
+ traffic_out: number
+ cur_conns: number
+ traffic_in_rate: number
+ traffic_out_rate: number
+}
+
+/** 按 frp 规则 ID 聚合的流量数据;errors 记录采集失败的服务端/规则错误 */
+export interface FrpProxiesTraffic {
+ items: Record
+ errors?: Record
+ sampled_at: string
+}
+
+export interface FrpStatus {
+ status: 'running' | 'stopped' | 'unknown'
+ pid?: number
+ frpc_version?: string
+ last_started_at?: string
+ last_reloaded_at?: string
+ last_error?: string
+ configured: boolean
+ enabled_proxies: number
+}
+
+export interface FrpRuntimeInfo {
+ platform: string
+ configured_bin: string
+ active_bin?: string
+ active_version?: string
+ detected_version?: string
+ download_proxy: string
+ config_path: string
+ config_paths?: string[]
+ config_exists: boolean
+}
+
+export interface FrpRelease {
+ version: string
+ published_at?: string
+ asset_name: string
+ size: number
+}
+
+export interface FrpBinary {
+ version: string
+ path: string
+ size: number
+ installed_at: string
+ active: boolean
+}
+
+/** 单项诊断结果;state 为 ok/failed/unknown,unknown 表示无法确认而非失败 */
+export interface FrpDiagnosticCheck {
+ name: 'tcp_reachable' | 'frpc_login' | 'proxy_reachable' | 'dns_resolution' | 'location' | string
+ state: 'ok' | 'failed' | 'unknown' | string
+ detail?: string
+ latency_ms?: number
+}
+
+export interface FrpServerDiagnostics {
+ server_id: number
+ available: boolean
+ latency_ms?: number
+ location?: string
+ checks?: FrpDiagnosticCheck[]
+ error?: string
+}
+
+export interface FrpServerSample {
+ checked_at: string
+ available: boolean
+ latency_ms?: number
+ error?: string
+}
+
+export interface FrpServerStatusDetail {
+ available: boolean
+ availability: number
+ average_latency_ms: number
+ peak_latency_ms: number
+ samples: FrpServerSample[]
+ last_checked_at?: string
+ last_error?: string
+}
+
+export interface FrpServerDetail {
+ server: FrpServer
+ status: FrpServerStatusDetail
+ proxies: FrpProxy[]
+}
+
+export interface FrpDownloadTask {
+ version?: string
+ status: 'idle' | 'queued' | 'downloading' | 'verifying' | 'extracting' | 'completed' | 'failed'
+ message: string
+ downloaded: number
+ total: number
+ error?: string
+ updated_at?: string
+}
+
+export interface FrpServerPayload {
+ agent_url?: string
+ agent_token?: string
+ clear_agent_token?: boolean
+ ssh_host?: string
+ ssh_port?: number
+ ssh_user?: string
+ ssh_auth?: string
+ ssh_secret?: string
+ clear_ssh_secret?: boolean
+ mgmt_enabled?: boolean
+ name: string
+ server_addr: string
+ server_port: number
+ tls_enabled?: boolean
+ tls_server_name?: string
+ enabled?: boolean
+ token?: string
+ clear_token?: boolean
+ options: FrpServerOptions
+ oidc_client_secret?: string
+ clear_oidc_client_secret?: boolean
+ dashboard_password?: string
+ clear_dashboard_password?: boolean
+ tls_certificate?: string
+ tls_key?: string
+ tls_trusted_ca?: string
+ clear_tls_certificate?: boolean
+ clear_tls_key?: boolean
+ clear_tls_trusted_ca?: boolean
+}
+
+export interface FrpAgentRouteCandidate {
+ id: number
+ name: string
+ type: 'http' | 'https'
+ custom_domains: string[]
+ local_ip: string
+ local_port: number
+ enabled: boolean
+}
+
+export interface FrpAgentRouteHealth {
+ proxy_id: number
+ domain: string
+ dns_ok: boolean
+ dns_detail?: string
+ cert_ok: boolean
+ cert_detail?: string
+ tunnel_ok: boolean
+ tunnel_name?: string
+ tunnel_detail?: string
+ service_ok: boolean
+}
+
+export interface FrpAgentRouteDetail {
+ domain: string
+ file_exists: boolean
+ syntax_ok: boolean
+ loaded: boolean
+}
+
+export interface FrpAgentStatus {
+ agent_version?: string
+ ports?: Record
+ frps_active?: boolean
+ frps_enabled?: boolean
+ frps_version?: string
+ frps_log_tail?: string
+ frps_unit_exec?: string
+ dashboard_addr?: string
+ service: string
+ nginx_binary: string
+ nginx_conf_dir: string
+ /** nginx -t 是否通过 */
+ nginx_ok?: boolean
+ /** 未找到 nginx 可执行文件 */
+ nginx_missing?: boolean
+ /** 解析到的 nginx 路径 */
+ nginx_path?: string
+ /** 有 nginx 进程在运行 */
+ nginx_running?: boolean
+ /** nginx 版本,如 nginx/1.24.0 */
+ nginx_version?: string
+ /** 探测失败原因(缺失或配置有误) */
+ nginx_error?: string
+ frps_binary: string
+ frps_config_path: string
+ routes: string[]
+ checked_at: string
+}
+
+export interface FrpProxyPayload {
+ server_id: number
+ name: string
+ type: FrpProxy['type']
+ local_ip: string
+ local_port: number
+ remote_port?: number | null
+ custom_domains?: string[]
+ host_header_rewrite?: string
+ options?: FrpProxyOptions
+ enabled?: boolean
+ remark?: string
+}
+
+export interface NginxBackupEntry {
+ name: string
+ created_at: string
+}
+
+export interface RuleNginxView {
+ mode: 'auto' | 'custom'
+ enabled: boolean
+ active: boolean
+ generated: string
+ content: string
+ backups: NginxBackupEntry[]
+}
+
+export interface GlobalNginxView {
+ mode: 'auto' | 'custom'
+ generated_framework: string
+ generated_snippet: string
+ content: string
+ backups: NginxBackupEntry[]
+}
diff --git a/web/src/assets/brand/banner-art.png b/web/src/assets/brand/banner-art.png
new file mode 100644
index 0000000..36928c0
Binary files /dev/null and b/web/src/assets/brand/banner-art.png differ
diff --git a/web/src/assets/brand/banner.png b/web/src/assets/brand/banner.png
new file mode 100644
index 0000000..0604b2a
Binary files /dev/null and b/web/src/assets/brand/banner.png differ
diff --git a/web/src/assets/brand/ca/buypass.png b/web/src/assets/brand/ca/buypass.png
new file mode 100644
index 0000000..9d0aa72
Binary files /dev/null and b/web/src/assets/brand/ca/buypass.png differ
diff --git a/web/src/assets/brand/ca/ca-logos.png b/web/src/assets/brand/ca/ca-logos.png
new file mode 100644
index 0000000..2a3942e
Binary files /dev/null and b/web/src/assets/brand/ca/ca-logos.png differ
diff --git a/web/src/assets/brand/ca/letsencrypt.png b/web/src/assets/brand/ca/letsencrypt.png
new file mode 100644
index 0000000..d963dee
Binary files /dev/null and b/web/src/assets/brand/ca/letsencrypt.png differ
diff --git a/web/src/assets/brand/ca/zerossl.png b/web/src/assets/brand/ca/zerossl.png
new file mode 100644
index 0000000..270f348
Binary files /dev/null and b/web/src/assets/brand/ca/zerossl.png differ
diff --git a/web/src/assets/brand/dns/aliyun.png b/web/src/assets/brand/dns/aliyun.png
new file mode 100644
index 0000000..d6fb921
Binary files /dev/null and b/web/src/assets/brand/dns/aliyun.png differ
diff --git a/web/src/assets/brand/dns/cloudflare.png b/web/src/assets/brand/dns/cloudflare.png
new file mode 100644
index 0000000..eb8303d
Binary files /dev/null and b/web/src/assets/brand/dns/cloudflare.png differ
diff --git a/web/src/assets/brand/dns/dnspod.png b/web/src/assets/brand/dns/dnspod.png
new file mode 100644
index 0000000..c6f2731
Binary files /dev/null and b/web/src/assets/brand/dns/dnspod.png differ
diff --git a/web/src/assets/brand/dns/tencentcloud.png b/web/src/assets/brand/dns/tencentcloud.png
new file mode 100644
index 0000000..e00336f
Binary files /dev/null and b/web/src/assets/brand/dns/tencentcloud.png differ
diff --git a/web/src/assets/brand/dns/volcengine.png b/web/src/assets/brand/dns/volcengine.png
new file mode 100644
index 0000000..97deb99
Binary files /dev/null and b/web/src/assets/brand/dns/volcengine.png differ
diff --git a/web/src/assets/brand/logo.png b/web/src/assets/brand/logo.png
new file mode 100644
index 0000000..eab0c1e
Binary files /dev/null and b/web/src/assets/brand/logo.png differ
diff --git a/web/src/components/ConfiguredSecretField.vue b/web/src/components/ConfiguredSecretField.vue
new file mode 100644
index 0000000..04131f6
--- /dev/null
+++ b/web/src/components/ConfiguredSecretField.vue
@@ -0,0 +1,45 @@
+
+
+
+ {{ label }}
+ {{ CONFIGURED_SECRET_TAG }}
+
+
+
+
+
+
+
+
diff --git a/web/src/components/EmptyState.vue b/web/src/components/EmptyState.vue
new file mode 100644
index 0000000..a4f2f10
--- /dev/null
+++ b/web/src/components/EmptyState.vue
@@ -0,0 +1,61 @@
+
+
+ {{ icon }}
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/FonuCard.vue b/web/src/components/FonuCard.vue
new file mode 100644
index 0000000..a01635e
--- /dev/null
+++ b/web/src/components/FonuCard.vue
@@ -0,0 +1,72 @@
+
+
+
+
+ {{ title }}
+
+
+
+ {{ subtitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/LoadError.vue b/web/src/components/LoadError.vue
new file mode 100644
index 0000000..133d924
--- /dev/null
+++ b/web/src/components/LoadError.vue
@@ -0,0 +1,28 @@
+
+
+
+
+ 重新加载
+
+
+
+
+
+
+
+
diff --git a/web/src/components/MiniBarChart.vue b/web/src/components/MiniBarChart.vue
new file mode 100644
index 0000000..0e4f58f
--- /dev/null
+++ b/web/src/components/MiniBarChart.vue
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/MiniDonutChart.vue b/web/src/components/MiniDonutChart.vue
new file mode 100644
index 0000000..c6cf38c
--- /dev/null
+++ b/web/src/components/MiniDonutChart.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/MiniTrafficChart.vue b/web/src/components/MiniTrafficChart.vue
new file mode 100644
index 0000000..7a9cd3c
--- /dev/null
+++ b/web/src/components/MiniTrafficChart.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
diff --git a/web/src/components/NginxCodeEditor.vue b/web/src/components/NginxCodeEditor.vue
new file mode 100644
index 0000000..24d8897
--- /dev/null
+++ b/web/src/components/NginxCodeEditor.vue
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue
new file mode 100644
index 0000000..9e53c7d
--- /dev/null
+++ b/web/src/components/PageHeader.vue
@@ -0,0 +1,52 @@
+
+
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/ProxyAccessLogBox.vue b/web/src/components/ProxyAccessLogBox.vue
new file mode 100644
index 0000000..c66bf5e
--- /dev/null
+++ b/web/src/components/ProxyAccessLogBox.vue
@@ -0,0 +1,307 @@
+
+
+
+
+
+
+ {{ item.parsed.method }}
+
+ {{ item.parsed.path }}
+ {{ item.parsed.status }}
+ {{ item.parsed.ms }}ms
+ {{ item.parsed.client }}
+
+ → {{ formatUpstream(item.parsed.upstream) }}
+
+
+ {{ item.raw }}
+
+ 暂无记录,通过反代域名访问后会显示在这里。
+
+
+
+
+
+
diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue
new file mode 100644
index 0000000..d9f3985
--- /dev/null
+++ b/web/src/components/StatCard.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
+ {{ value }}
+
+ {{ sub }}
+
+
+
+
+
+
+
diff --git a/web/src/components/StatusBadge.vue b/web/src/components/StatusBadge.vue
new file mode 100644
index 0000000..c585841
--- /dev/null
+++ b/web/src/components/StatusBadge.vue
@@ -0,0 +1,87 @@
+
+
+
+ {{ label }}
+
+
+
+
+
+
diff --git a/web/src/components/TaskProviderForm.vue b/web/src/components/TaskProviderForm.vue
new file mode 100644
index 0000000..a21a67b
--- /dev/null
+++ b/web/src/components/TaskProviderForm.vue
@@ -0,0 +1,255 @@
+
+
+
+ {{ editing ? '编辑服务商' : '添加服务商' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IPv4
+
+
+
+ IPv6
+
+
+
+ 启用
+
+
+
+
+
+ 取消
+ 测试连接
+ 保存
+
+
+
+
+
+
+
diff --git a/web/src/composables/useTheme.ts b/web/src/composables/useTheme.ts
new file mode 100644
index 0000000..73a0b19
--- /dev/null
+++ b/web/src/composables/useTheme.ts
@@ -0,0 +1,136 @@
+import { computed, ref, watch } from 'vue'
+import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
+
+const themeKey = 'fonu-theme'
+const themeMode = ref(localStorage.getItem(themeKey) ?? 'system')
+
+function systemDark() {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+}
+
+const shared = {
+ primaryColor: '#10B981',
+ primaryColorHover: '#059669',
+ primaryColorPressed: '#047857',
+ primaryColorSuppl: '#34D399',
+ borderRadius: '8px',
+ fontFamily: 'var(--fonu-font)',
+}
+
+const lightOverrides: GlobalThemeOverrides = {
+ common: {
+ ...shared,
+ bodyColor: '#F8FAFC',
+ cardColor: '#FFFFFF',
+ modalColor: '#FFFFFF',
+ popoverColor: '#FFFFFF',
+ borderColor: '#E5E7EB',
+ dividerColor: '#E5E7EB',
+ textColor1: '#111827',
+ textColor2: '#6B7280',
+ textColor3: '#9CA3AF',
+ hoverColor: 'rgba(16, 185, 129, 0.08)',
+ },
+ Card: { borderRadius: '12px', color: '#FFFFFF' },
+ Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
+ Input: { borderRadius: '8px' },
+ DataTable: { borderRadius: '12px', thColor: '#F9FAFB', tdColor: '#FFFFFF' },
+ Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
+ Layout: { color: '#FFFFFF', siderColor: '#FFFFFF' },
+ Menu: {
+ itemColorActive: 'rgba(16, 185, 129, 0.12)',
+ itemTextColorActive: '#047857',
+ itemIconColorActive: '#10B981',
+ itemColorActiveHover: 'rgba(16, 185, 129, 0.16)',
+ },
+ Radio: {
+ buttonBorderColorActive: '#10B981',
+ buttonTextColorActive: '#047857',
+ buttonColorActive: 'rgba(16, 185, 129, 0.12)',
+ buttonBoxShadowHover: 'none',
+ buttonBoxShadowFocus: 'none',
+ },
+}
+
+const darkOverrides: GlobalThemeOverrides = {
+ common: {
+ ...shared,
+ bodyColor: '#0F172A',
+ cardColor: '#1E293B',
+ modalColor: '#1E293B',
+ popoverColor: '#1E293B',
+ borderColor: '#334155',
+ dividerColor: '#334155',
+ textColor1: '#F1F5F9',
+ textColor2: '#94A3B8',
+ textColor3: '#64748B',
+ hoverColor: 'rgba(16, 185, 129, 0.12)',
+ },
+ Card: { borderRadius: '12px', color: '#1E293B', borderColor: '#334155' },
+ Button: { borderRadiusMedium: '8px', heightMedium: '36px' },
+ Input: { borderRadius: '8px', color: '#0F172A' },
+ DataTable: { borderRadius: '12px', thColor: '#0F172A', tdColor: '#1E293B', borderColor: '#334155' },
+ Tabs: { tabGapMediumLine: '32px', tabPaddingMediumLine: '12px 4px' },
+ Layout: { color: '#0F172A', siderColor: '#1E293B' },
+ Menu: {
+ itemColorActive: 'rgba(16, 185, 129, 0.18)',
+ itemTextColorActive: '#6EE7B7',
+ itemIconColorActive: '#34D399',
+ itemColorActiveHover: 'rgba(16, 185, 129, 0.24)',
+ },
+ Radio: {
+ buttonBorderColorActive: '#34D399',
+ buttonTextColorActive: '#6EE7B7',
+ buttonColorActive: 'rgba(16, 185, 129, 0.22)',
+ buttonBoxShadowHover: 'none',
+ buttonBoxShadowFocus: 'none',
+ },
+}
+
+function resolveDark(mode: string) {
+ if (mode === 'dark') return true
+ if (mode === 'light') return false
+ return systemDark()
+}
+
+function applyDocumentTheme(dark: boolean) {
+ document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
+ document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
+ document.documentElement.classList.toggle('dark', dark)
+}
+
+export function useTheme() {
+ const isDark = computed(() => resolveDark(themeMode.value))
+
+ const naiveTheme = computed(() => (isDark.value ? darkTheme : null))
+ const themeOverrides = computed(() =>
+ isDark.value ? darkOverrides : lightOverrides,
+ )
+
+ function setThemeMode(mode: string) {
+ themeMode.value = mode
+ }
+
+ function toggleTheme() {
+ themeMode.value = isDark.value ? 'light' : 'dark'
+ }
+
+ watch(
+ themeMode,
+ (value) => {
+ localStorage.setItem(themeKey, value)
+ applyDocumentTheme(resolveDark(value))
+ },
+ { immediate: true },
+ )
+
+ if (typeof window !== 'undefined') {
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (themeMode.value === 'system') {
+ applyDocumentTheme(systemDark())
+ }
+ })
+ }
+
+ return { themeMode, naiveTheme, themeOverrides, isDark, toggleTheme, setThemeMode }
+}
diff --git a/web/src/constants/app.ts b/web/src/constants/app.ts
new file mode 100644
index 0000000..969b450
--- /dev/null
+++ b/web/src/constants/app.ts
@@ -0,0 +1,4 @@
+export const GITHUB_REPO = 'O96u/Fonu'
+export const GITHUB_REPO_URL = `https://github.com/${GITHUB_REPO}`
+export const GITHUB_REPO_DISPLAY = `github.com/${GITHUB_REPO}`
+export const GITHUB_RELEASES_URL = `${GITHUB_REPO_URL}/releases`
diff --git a/web/src/constants/ipPolicy.ts b/web/src/constants/ipPolicy.ts
new file mode 100644
index 0000000..e747ddf
--- /dev/null
+++ b/web/src/constants/ipPolicy.ts
@@ -0,0 +1,26 @@
+/** Mirrors internal/proxy.DefaultPrivateCIDRs — always exempt from china_only / global allow. */
+export const DEFAULT_PRIVATE_CIDRS = [
+ '10.0.0.0/8',
+ '127.0.0.0/8',
+ '172.16.0.0/12',
+ '192.168.0.0/16',
+ '::1/128',
+ 'fc00::/7',
+]
+
+export function mergeIPList(defaults: string[], custom: string[]): string[] {
+ const seen = new Set()
+ const out: string[] = []
+ for (const cidr of [...defaults, ...custom]) {
+ const value = cidr.trim()
+ if (!value || seen.has(value)) continue
+ seen.add(value)
+ out.push(value)
+ }
+ return out
+}
+
+export function stripDefaultIPList(defaults: string[], list: string[]): string[] {
+ const defaultSet = new Set(defaults)
+ return list.map((item) => item.trim()).filter((item) => item && !defaultSet.has(item))
+}
diff --git a/web/src/constants/notifyEvents.ts b/web/src/constants/notifyEvents.ts
new file mode 100644
index 0000000..710ee27
--- /dev/null
+++ b/web/src/constants/notifyEvents.ts
@@ -0,0 +1,81 @@
+export interface NotifyEventOption {
+ key: string
+ label: string
+ hint?: string
+ hasThreshold?: boolean
+ thresholdKey?: 'ip_frequent' | 'login_failure'
+}
+
+export interface NotifyEventGroup {
+ title: string
+ events: NotifyEventOption[]
+}
+
+export const NOTIFY_EVENT_GROUPS: NotifyEventGroup[] = [
+ {
+ title: 'DDNS',
+ events: [
+ {
+ key: 'on_ddns_ip_change',
+ label: 'IP 变更',
+ hint: '公网 IP 变更并成功同步到 DNS',
+ },
+ {
+ key: 'on_ddns_failure',
+ label: '更新失败',
+ },
+ ],
+ },
+ {
+ title: '证书',
+ events: [
+ {
+ key: 'on_cert_expiry',
+ label: '即将到期',
+ hint: '进入续签阈值后每天提醒一次',
+ },
+ {
+ key: 'on_cert_renew_success',
+ label: '续签成功',
+ },
+ {
+ key: 'on_cert_renew_failure',
+ label: '续签失败',
+ },
+ ],
+ },
+ {
+ title: '安全',
+ events: [
+ {
+ key: 'on_ip_frequent_access',
+ label: 'IP 频繁访问',
+ hint: '同一 IP 在统计窗口内超过阈值',
+ hasThreshold: true,
+ thresholdKey: 'ip_frequent',
+ },
+ {
+ key: 'on_login_failure',
+ label: '登录异常',
+ hint: '同一 IP 在窗口内多次登录失败',
+ hasThreshold: true,
+ thresholdKey: 'login_failure',
+ },
+ ],
+ },
+ {
+ title: '系统',
+ events: [
+ {
+ key: 'on_nginx_reload_failure',
+ label: 'Nginx 重载失败',
+ hint: '保存反代或证书后 Nginx 未能成功重载',
+ },
+ ],
+ },
+]
+
+export const DEFAULT_IP_FREQUENT_THRESHOLD = 100
+export const DEFAULT_IP_FREQUENT_WINDOW_SEC = 60
+export const DEFAULT_LOGIN_FAILURE_THRESHOLD = 5
+export const DEFAULT_LOGIN_FAILURE_WINDOW_SEC = 300
diff --git a/web/src/constants/notifyProviders.ts b/web/src/constants/notifyProviders.ts
new file mode 100644
index 0000000..1b07dd3
--- /dev/null
+++ b/web/src/constants/notifyProviders.ts
@@ -0,0 +1,19 @@
+import type { NotifyType, WebhookProvider } from '../api/types'
+
+export const NOTIFY_TYPE_OPTIONS: { label: string; value: NotifyType }[] = [
+ { label: '关闭', value: '' },
+ { label: '邮件(SMTP)', value: 'email' },
+ { label: 'Webhook', value: 'webhook' },
+ { label: 'Telegram 机器人', value: 'telegram' },
+]
+
+export const WEBHOOK_PROVIDER_OPTIONS: { label: string; value: WebhookProvider }[] = [
+ { label: 'Bark', value: 'bark' },
+ { label: 'ntfy', value: 'ntfy' },
+ { label: 'Gotify', value: 'gotify' },
+ { label: '自定义 Webhook', value: 'custom' },
+]
+
+export const DEFAULT_BARK_SERVER = 'https://api.day.app'
+export const DEFAULT_NTFY_SERVER = 'https://ntfy.sh'
+export const MASKED_SECRET = '********'
diff --git a/web/src/constants/secretField.ts b/web/src/constants/secretField.ts
new file mode 100644
index 0000000..f9aa4b7
--- /dev/null
+++ b/web/src/constants/secretField.ts
@@ -0,0 +1,3 @@
+/** 敏感凭据字段:标签与占位符全站统一 */
+export const CONFIGURED_SECRET_TAG = '已配置'
+export const CONFIGURED_SECRET_PLACEHOLDER = '已配置,留空表示不修改'
diff --git a/web/src/env.d.ts b/web/src/env.d.ts
new file mode 100644
index 0000000..fcde624
--- /dev/null
+++ b/web/src/env.d.ts
@@ -0,0 +1,14 @@
+///
+
+declare const __APP_VERSION__: string
+
+declare module '*.png' {
+ const src: string
+ export default src
+}
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent, Record, unknown>
+ export default component
+}
diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue
new file mode 100644
index 0000000..36d75e3
--- /dev/null
+++ b/web/src/layouts/AppLayout.vue
@@ -0,0 +1,484 @@
+
+
+
+
+
+
+ Fonu
+ 让 NAS 访问更简单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..1f9fa5f
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,12 @@
+import { createApp } from 'vue'
+import naive from 'naive-ui'
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+
+app.config.errorHandler = (err, _instance, info) => {
+ console.error('[fonu]', info, err)
+}
+
+app.use(router).use(naive).mount('#app')
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
new file mode 100644
index 0000000..6d58c19
--- /dev/null
+++ b/web/src/router/index.ts
@@ -0,0 +1,92 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import { api } from '../api/client'
+import AppLayout from '../layouts/AppLayout.vue'
+import LoginView from '../views/LoginView.vue'
+import ProxyView from '../views/ProxyView.vue'
+import DashboardView from '../views/DashboardView.vue'
+import DdnsView from '../views/DdnsView.vue'
+import CertificatesView from '../views/CertificatesView.vue'
+import LogsView from '../views/LogsView.vue'
+import SettingsView from '../views/SettingsView.vue'
+import FrpView from '../views/FrpView.vue'
+import AgentView from '../views/AgentView.vue'
+import ProxyRemoteView from '../views/ProxyRemoteView.vue'
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes: [
+ { path: '/setup', redirect: '/login' },
+ { path: '/login', name: 'login', component: LoginView, meta: { public: true } },
+ {
+ path: '/',
+ component: AppLayout,
+ meta: { requiresAuth: true },
+ children: [
+ { path: '', redirect: '/dashboard' },
+ {
+ path: 'dashboard',
+ name: 'dashboard',
+ component: DashboardView,
+ meta: { title: '仪表盘', description: '查看 Fonu 运行状态与关键指标' },
+ },
+ {
+ path: 'ddns',
+ name: 'ddns',
+ component: DdnsView,
+ meta: { title: 'DDNS', description: '自动同步公网 IP 到 DNS 解析' },
+ },
+ {
+ path: 'certificates',
+ name: 'certificates',
+ component: CertificatesView,
+ meta: { title: '证书管理', description: '通过 ACME 自动申请和续签证书' },
+ },
+ {
+ path: 'proxies',
+ name: 'proxies',
+ component: ProxyView,
+ meta: { title: '反向代理', description: '管理通过域名访问的 NAS 服务' },
+ },
+ {
+ path: 'frp',
+ name: 'frp',
+ component: FrpView,
+ meta: { title: '内网穿透', description: '通过 FRP 将公网流量转发到 Fonu Nginx' },
+ },
+ {
+ path: 'frp-agent',
+ name: 'frp-agent',
+ component: AgentView,
+ meta: { title: '公网 Agent', description: '管理部署在公网 VPS 上的 fonu-agent 与 frps' },
+ },
+ {
+ path: 'public-proxy',
+ name: 'public-proxy',
+ component: ProxyRemoteView,
+ meta: { title: '公网反代', description: '管理 VPS 上 Nginx 反代的部署/移除与安全选项' },
+ },
+ {
+ path: 'logs',
+ name: 'logs',
+ component: LogsView,
+ meta: { title: '日志中心', description: '查看系统、访问与 Nginx 错误日志' },
+ },
+ {
+ path: 'settings',
+ name: 'settings',
+ component: SettingsView,
+ meta: { title: '系统设置', description: '配置系统参数与运行策略' },
+ },
+ ],
+ },
+ ],
+})
+
+router.beforeEach(async (to) => {
+ const status = await api.authStatus()
+ if (to.meta.requiresAuth && !status.authenticated) return { name: 'login' }
+ if (to.name === 'login' && status.authenticated) return { name: 'dashboard' }
+ return true
+})
+
+export default router
diff --git a/web/src/styles/variables.css b/web/src/styles/variables.css
new file mode 100644
index 0000000..f8f7fad
--- /dev/null
+++ b/web/src/styles/variables.css
@@ -0,0 +1,63 @@
+:root {
+ --fonu-brand: #10b981;
+ --fonu-brand-hover: #059669;
+ --fonu-brand-soft: rgba(16, 185, 129, 0.12);
+ --fonu-brand-text: #047857;
+ --fonu-brand-sidebar: #10b981;
+
+ --fonu-success: #10b981;
+ --fonu-warning: #f59e0b;
+ --fonu-error: #ef4444;
+ --fonu-disabled: #94a3b8;
+ --fonu-info: #3b82f6;
+ --fonu-info-soft: rgba(59, 130, 246, 0.1);
+
+ --fonu-bg: #f8fafc;
+ --fonu-bg-muted: #f3f4f6;
+ --fonu-banner-bg: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 40%, #a7f3d0 100%);
+ --fonu-banner-border: rgba(16, 185, 129, 0.2);
+ --fonu-surface: #ffffff;
+ --fonu-border: #e5e7eb;
+ --fonu-text: #111827;
+ --fonu-text-secondary: #6b7280;
+ --fonu-text-muted: #9ca3af;
+
+ --fonu-radius: 12px;
+ --fonu-radius-sm: 8px;
+ --fonu-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
+ --fonu-shadow-md: 0 4px 6px -1px rgba(15, 23, 42, 0.08), 0 2px 4px -2px rgba(15, 23, 42, 0.06);
+
+ --fonu-sidebar-width: 240px;
+ --fonu-topbar-height: 56px;
+ --fonu-form-max: 720px;
+
+ --fonu-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
+ --fonu-mono: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', monospace;
+
+ --fonu-space-1: 4px;
+ --fonu-space-2: 8px;
+ --fonu-space-3: 12px;
+ --fonu-space-4: 16px;
+ --fonu-space-5: 24px;
+ --fonu-space-6: 32px;
+}
+
+html.dark,
+[data-theme='dark'] {
+ --fonu-brand-soft: rgba(16, 185, 129, 0.18);
+ --fonu-brand-text: #6ee7b7;
+ --fonu-info-soft: rgba(59, 130, 246, 0.16);
+
+ --fonu-bg: #0f172a;
+ --fonu-bg-muted: #1e293b;
+ --fonu-surface: #1e293b;
+ --fonu-border: #334155;
+ --fonu-text: #f1f5f9;
+ --fonu-text-secondary: #94a3b8;
+ --fonu-text-muted: #64748b;
+ --fonu-banner-bg: linear-gradient(135deg, #064e3b 0%, #065f46 45%, #047857 100%);
+ --fonu-banner-border: rgba(52, 211, 153, 0.25);
+
+ --fonu-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ --fonu-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
+}
diff --git a/web/src/utils/accessService.ts b/web/src/utils/accessService.ts
new file mode 100644
index 0000000..168a269
--- /dev/null
+++ b/web/src/utils/accessService.ts
@@ -0,0 +1,138 @@
+import type { AccessLogEntry, ProxyHost, ProxyRule } from '../api/types'
+
+export type ProxyBinding = {
+ name: string
+ upstream: string
+ host: string
+ port: number
+}
+
+function bindingKey(host: string, port: number): string {
+ return `${host.toLowerCase()}:${port}`
+}
+
+function normalizeAccessHost(domain: string): string {
+ const raw = domain.trim().toLowerCase()
+ if (!raw) return ''
+ if (raw.startsWith('[')) {
+ const end = raw.indexOf(']')
+ if (end > 0) return raw.slice(0, end + 1)
+ }
+ if (raw.includes(':') && !raw.includes(']')) {
+ const idx = raw.lastIndexOf(':')
+ const maybePort = Number.parseInt(raw.slice(idx + 1), 10)
+ if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
+ return raw.slice(0, idx)
+ }
+ }
+ return raw
+}
+
+export function buildProxyBindingIndex(rules: ProxyRule[]): Map {
+ const index = new Map()
+ for (const rule of rules) {
+ const fallbackName = rule.domain?.trim() || rule.upstream?.trim() || `规则 #${rule.id}`
+ const name = rule.name?.trim() || fallbackName
+ const hosts: ProxyHost[] = rule.hosts?.length
+ ? rule.hosts
+ : rule.domain
+ ? [{ id: 0, hostname: rule.domain }]
+ : []
+ for (const host of hosts) {
+ const hostname = host.hostname?.trim()
+ if (!hostname) continue
+ const port = host.listen_port ?? rule.listen_port
+ if (port <= 0) continue
+ index.set(bindingKey(hostname, port), {
+ name,
+ upstream: rule.upstream,
+ host: hostname.toLowerCase(),
+ port,
+ })
+ }
+ }
+ return index
+}
+
+function formatUpstreamShort(upstream: string): string {
+ return upstream.replace(/^https?:\/\//i, '')
+}
+
+/** Raw host:port / domain / IP shown when no proxy rule matches. */
+export function formatAccessFallback(entry: AccessLogEntry): string {
+ const rawDomain = entry.domain?.trim()
+ const host = normalizeAccessHost(entry.domain)
+ const port = entry.server_port ?? 0
+
+ if (rawDomain?.includes(':') && (rawDomain.includes(']') || rawDomain.lastIndexOf(':') > rawDomain.lastIndexOf(']'))) {
+ return rawDomain
+ }
+ if (rawDomain?.includes(':') && !rawDomain.includes(']')) {
+ const idx = rawDomain.lastIndexOf(':')
+ const maybePort = Number.parseInt(rawDomain.slice(idx + 1), 10)
+ if (idx > 0 && maybePort > 0 && maybePort <= 65535) {
+ return rawDomain
+ }
+ }
+
+ if (host && port > 0) return `${host}:${port}`
+ if (rawDomain) return rawDomain
+ if (host) return host
+ return entry.client_ip?.trim() || '—'
+}
+
+export function resolveAccessServiceLabel(
+ entry: AccessLogEntry,
+ index: Map,
+): string {
+ const host = normalizeAccessHost(entry.domain)
+ const port = entry.server_port ?? 0
+
+ // 域名 + 端口(同域多端口)
+ if (host && port > 0) {
+ const hit = index.get(bindingKey(host, port))
+ if (hit) return hit.name
+ }
+
+ // 仅域名(单规则或同域单端口)
+ if (host) {
+ const matches = [...index.values()].filter((binding) => binding.host === host)
+ if (matches.length === 1) return matches[0].name
+ if (matches.length > 1 && port > 0) {
+ const byPort = matches.find((binding) => binding.port === port)
+ if (byPort) return byPort.name
+ }
+ }
+
+ // 仅 IP / 域名直连、无 Host 匹配时走 upstream
+ const upstream = entry.upstream?.trim()
+ if (upstream && upstream !== '-') {
+ return formatUpstreamShort(upstream)
+ }
+
+ return formatAccessFallback(entry)
+}
+
+export function accessServiceTooltip(entry: AccessLogEntry, index: Map): string {
+ const host = normalizeAccessHost(entry.domain)
+ const port = entry.server_port ?? 0
+ const label = resolveAccessServiceLabel(entry, index)
+ const parts: string[] = [label]
+
+ const requestHost = formatAccessFallback(entry)
+ if (requestHost !== label) {
+ parts.push(`请求 ${requestHost}`)
+ } else if (host) {
+ parts.push(port > 0 ? `${host}:${port}` : host)
+ }
+
+ const binding = host && port > 0 ? index.get(bindingKey(host, port)) : undefined
+ if (binding?.upstream) {
+ parts.push(`→ ${binding.upstream}`)
+ } else if (entry.upstream && entry.upstream !== '-') {
+ parts.push(`→ ${entry.upstream}`)
+ }
+ if (entry.client_ip) parts.push(`来自 ${entry.client_ip}`)
+ if (entry.path) parts.push(entry.path)
+ return parts.join(' · ')
+}
diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts
new file mode 100644
index 0000000..f61e73f
--- /dev/null
+++ b/web/src/utils/format.ts
@@ -0,0 +1,103 @@
+export function formatBytes(bytes: number): string {
+ if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
+ let value = bytes
+ let unit = 0
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024
+ unit++
+ }
+ const digits = value >= 100 || unit === 0 ? 0 : value >= 10 ? 1 : 2
+ return `${value.toFixed(digits)} ${units[unit]}`
+}
+
+export function formatRate(bytesPerSec: number): string {
+ if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return '0 B/s'
+ return `${formatBytes(bytesPerSec)}/s`
+}
+
+export function formatUptime(seconds: number): string {
+ const h = Math.floor(seconds / 3600)
+ const m = Math.floor((seconds % 3600) / 60)
+ if (h > 0) return `${h} 小时 ${m} 分钟`
+ return `${m} 分钟`
+}
+
+export function formatRelativeTime(iso?: string): string {
+ if (!iso) return '-'
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) return iso
+ const diff = Date.now() - date.getTime()
+ const minutes = Math.floor(diff / 60000)
+ if (minutes < 1) return '刚刚'
+ if (minutes < 60) return `${minutes} 分钟前`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours} 小时前`
+ const days = Math.floor(hours / 24)
+ return `${days} 天前`
+}
+
+export function formatMs(seconds: number): string {
+ const ms = Math.round(seconds * 1000)
+ return `${ms} ms`
+}
+
+export function formatDate(iso?: string): string {
+ if (!iso) return '-'
+ const date = new Date(iso)
+ if (Number.isNaN(date.getTime())) return iso
+ return date.toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+/** Normalize log timestamps to YYYY-MM-DD HH:mm:ss */
+export function formatLogTime(value?: string): string {
+ if (!value) return '-'
+
+ const trimmed = value.trim()
+
+ const standard = trimmed.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/)
+ if (standard) return standard[1]
+
+ const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})/)
+ if (iso) return `${iso[1]}-${iso[2]}-${iso[3]} ${iso[4]}:${iso[5]}:${iso[6]}`
+
+ const nginx = trimmed.match(/^(\d{4})\/(\d{2})\/(\d{2}) (\d{2}):(\d{2}):(\d{2})/)
+ if (nginx) return `${nginx[1]}-${nginx[2]}-${nginx[3]} ${nginx[4]}:${nginx[5]}:${nginx[6]}`
+
+ const date = new Date(trimmed)
+ if (!Number.isNaN(date.getTime())) {
+ const pad = (n: number) => String(n).padStart(2, '0')
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
+ }
+
+ return trimmed
+}
+
+/** Normalize leading timestamp in a raw log line */
+export function formatLogLine(line: string): string {
+ const trimmed = line.trim()
+ if (!trimmed) return line
+
+ const nginx = trimmed.match(/^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2})(.*)$/)
+ if (nginx) return formatLogTime(nginx[1]) + nginx[2]
+
+ const iso = trimmed.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}|Z)?)(.*)$/)
+ if (iso) return formatLogTime(iso[1]) + iso[2]
+
+ if (trimmed.startsWith('{')) {
+ try {
+ const raw = JSON.parse(trimmed) as { time?: string }
+ if (raw.time) return trimmed.replace(raw.time, formatLogTime(raw.time))
+ } catch {
+ // not JSON
+ }
+ }
+
+ return trimmed
+}
diff --git a/web/src/utils/logDisplay.ts b/web/src/utils/logDisplay.ts
new file mode 100644
index 0000000..d8b695a
--- /dev/null
+++ b/web/src/utils/logDisplay.ts
@@ -0,0 +1,165 @@
+import type { SystemLogEntry } from '../api/types'
+import { formatLogTime } from './format'
+
+export type LogTagType = 'success' | 'info' | 'warning' | 'error' | 'default'
+
+export interface DisplaySystemLogEntry extends SystemLogEntry {
+ displayLevel: string
+ displayModule: string
+}
+
+const SYSTEM_LEVEL_LABELS: Record = {
+ INFO: '信息',
+ WARN: '警告',
+ ERROR: '错误',
+ DEBUG: '调试',
+}
+
+const SYSTEM_MODULE_LABELS: Record = {
+ SYSTEM: '系统',
+ NGINX: 'Nginx',
+ DDNS: '域名解析',
+ ACME: '证书',
+ CERT: '证书',
+ CHINA_CIDR: '中国 IP 段',
+ API: 'API',
+ HTTP: 'HTTP',
+ FRP: '内网穿透',
+}
+
+export function systemLevelLabel(level: string): string {
+ const upper = level.trim().toUpperCase()
+ return SYSTEM_LEVEL_LABELS[upper] ?? level
+}
+
+export function systemModuleLabel(module: string): string {
+ const trimmed = module.trim()
+ if (!trimmed) return '系统'
+ const upper = trimmed.toUpperCase()
+ return SYSTEM_MODULE_LABELS[upper] ?? trimmed
+}
+
+export function displaySystemLog(entry: SystemLogEntry): DisplaySystemLogEntry {
+ const message = entry.message.trim()
+ const msgLower = message.toLowerCase()
+ let level = entry.level.trim().toUpperCase()
+ let displayModule = systemModuleLabel(entry.module)
+
+ if (
+ level === 'INFO' &&
+ (msgLower.includes('server error') ||
+ msgLower.includes(' failed') ||
+ msgLower.startsWith('error') ||
+ msgLower.includes('bind:'))
+ ) {
+ level = 'ERROR'
+ }
+
+ return {
+ ...entry,
+ displayLevel: systemLevelLabel(level),
+ displayModule,
+ }
+}
+
+export interface ParsedNginxError {
+ time: string
+ level: string
+ message: string
+ raw: string
+}
+
+export function parseNginxErrorLine(line: string): ParsedNginxError {
+ const raw = line.trim()
+ const match = raw.match(/^(\d{4}[-/]\d{2}[-/]\d{2} \d{2}:\d{2}:\d{2})\s+\[([^\]]+)\]\s+(.+)$/)
+ if (!match) {
+ return { time: '', level: 'unknown', message: raw, raw }
+ }
+ return {
+ time: formatLogTime(match[1]),
+ level: match[2].toLowerCase(),
+ message: match[3],
+ raw,
+ }
+}
+
+export function nginxLevelTagType(level: string): LogTagType {
+ switch (level.toLowerCase()) {
+ case 'emerg':
+ case 'alert':
+ case 'crit':
+ case 'error':
+ return 'error'
+ case 'warn':
+ case 'warning':
+ return 'warning'
+ case 'notice':
+ case 'info':
+ return 'info'
+ default:
+ return 'default'
+ }
+}
+
+export function systemLevelTagType(level: string): LogTagType {
+ const upper = level.trim().toUpperCase()
+ if (upper === 'ERROR' || level === '错误') return 'error'
+ if (upper === 'WARN' || level === '警告') return 'warning'
+ if (upper === 'INFO' || level === '信息') return 'info'
+ return 'default'
+}
+
+export function systemModuleTagType(module: string): LogTagType {
+ const upper = module.trim().toUpperCase()
+ switch (upper) {
+ case 'NGINX':
+ case 'Nginx':
+ return 'warning'
+ case 'SYSTEM':
+ case '系统':
+ return 'success'
+ case 'CHINA_CIDR':
+ case '中国 IP 段':
+ return 'info'
+ case 'DDNS':
+ case '域名解析':
+ return 'info'
+ case 'CERT':
+ case 'ACME':
+ case '证书':
+ return 'warning'
+ default:
+ return 'default'
+ }
+}
+
+export function latencyClass(seconds: number): string {
+ const ms = seconds * 1000
+ if (ms >= 3000) return 'log-duration log-duration--critical'
+ if (ms >= 1000) return 'log-duration log-duration--slow'
+ return 'log-duration'
+}
+
+export function streamLineClass(line: string): string {
+ const parsed = parseNginxErrorLine(line)
+ if (parsed.level !== 'unknown') {
+ const type = nginxLevelTagType(parsed.level)
+ if (type === 'error') return 'level-error'
+ if (type === 'warning') return 'level-warn'
+ if (type === 'info') return 'level-info'
+ }
+
+ const lower = line.toLowerCase()
+ if (lower.includes('[error]') || lower.includes(' emerg ') || lower.includes(' alert ') || lower.includes(' crit ')) {
+ return 'level-error'
+ }
+ if (lower.includes('[warn]') || lower.includes(' warning ')) return 'level-warn'
+
+ const statusMatch = line.match(/\s(\d{3})\s+\d/)
+ if (statusMatch) {
+ const code = Number(statusMatch[1])
+ if (code >= 500) return 'level-error'
+ if (code >= 400) return 'level-warn'
+ }
+ return ''
+}
diff --git a/web/src/utils/status.ts b/web/src/utils/status.ts
new file mode 100644
index 0000000..6e06148
--- /dev/null
+++ b/web/src/utils/status.ts
@@ -0,0 +1,42 @@
+export type StatusKind = 'success' | 'warning' | 'error' | 'disabled' | 'unknown'
+
+const LABELS: Record = {
+ ok: '正常',
+ running: '运行中',
+ warning: '部分异常',
+ error: '异常',
+ disabled: '停用',
+ none: '未配置',
+ stopped: '已停止',
+ unknown: '未知',
+}
+
+export function statusLabel(value?: string): string {
+ if (!value) return '未知'
+ return LABELS[value] ?? value
+}
+
+export function statusKind(value?: string): StatusKind {
+ switch (value) {
+ case 'ok':
+ case 'running':
+ return 'success'
+ case 'warning':
+ return 'warning'
+ case 'error':
+ return 'error'
+ case 'disabled':
+ case 'stopped':
+ case 'none':
+ return 'disabled'
+ default:
+ return 'unknown'
+ }
+}
+
+export function httpStatusKind(code: number): StatusKind {
+ if (code >= 500) return 'error'
+ if (code >= 400) return 'warning'
+ if (code >= 300) return 'unknown'
+ return 'success'
+}
diff --git a/web/src/utils/tableActions.ts b/web/src/utils/tableActions.ts
new file mode 100644
index 0000000..636843b
--- /dev/null
+++ b/web/src/utils/tableActions.ts
@@ -0,0 +1,34 @@
+import { h, type VNode } from 'vue'
+import { NButton, NSpace } from 'naive-ui'
+import type { ButtonProps } from 'naive-ui'
+
+export interface TableRowAction {
+ label: string
+ onClick: () => void
+ type?: ButtonProps['type']
+ /** 为 false 时隐藏该操作,默认显示 */
+ show?: boolean
+}
+
+/** 表格操作列:所有按钮平铺展示,不使用下拉菜单 */
+export function renderTableRowActions(actions: TableRowAction[]): VNode {
+ const buttons = actions
+ .filter((action) => action.show !== false)
+ .map((action) =>
+ h(
+ NButton,
+ {
+ size: 'small',
+ quaternary: true,
+ type: action.type,
+ onClick: (e: MouseEvent) => {
+ e.stopPropagation()
+ action.onClick()
+ },
+ },
+ () => action.label,
+ ),
+ )
+
+ return h(NSpace, { size: 4, wrap: false }, () => buttons)
+}
diff --git a/web/src/utils/version.ts b/web/src/utils/version.ts
new file mode 100644
index 0000000..a83cce6
--- /dev/null
+++ b/web/src/utils/version.ts
@@ -0,0 +1,56 @@
+import { GITHUB_REPO } from '../constants/app'
+
+export interface LatestRelease {
+ version: string
+ url: string
+}
+
+function parseVersion(version: string): number[] {
+ const clean = version.replace(/^v/i, '').split('-')[0]
+ return clean.split('.').map((part) => Number.parseInt(part, 10) || 0)
+}
+
+export function compareVersions(a: string, b: string): number {
+ const left = parseVersion(a)
+ const right = parseVersion(b)
+ const length = Math.max(left.length, right.length)
+
+ for (let i = 0; i < length; i += 1) {
+ const diff = (left[i] ?? 0) - (right[i] ?? 0)
+ if (diff !== 0) {
+ return diff > 0 ? 1 : -1
+ }
+ }
+
+ return 0
+}
+
+export function isNewerVersion(latest: string, current: string): boolean {
+ if (!latest || !current || current === 'dev') {
+ return false
+ }
+ return compareVersions(latest, current) > 0
+}
+
+export async function fetchLatestRelease(): Promise {
+ try {
+ const response = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
+ headers: { Accept: 'application/vnd.github+json' },
+ })
+ if (!response.ok) {
+ return null
+ }
+
+ const data = (await response.json()) as { tag_name?: string; html_url?: string }
+ if (!data.tag_name || !data.html_url) {
+ return null
+ }
+
+ return {
+ version: data.tag_name.replace(/^v/i, ''),
+ url: data.html_url,
+ }
+ } catch {
+ return null
+ }
+}
diff --git a/web/src/views/AgentView.vue b/web/src/views/AgentView.vue
new file mode 100644
index 0000000..4b83f81
--- /dev/null
+++ b/web/src/views/AgentView.vue
@@ -0,0 +1,368 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 去内网穿透页
+
+
+
+
+
+
+
+
+
+
+ Agent 连接
+ {{ agentStatusOk ? '在线' : '未连接' }}
+
+
+
+
+
+
+
+
+ frps 服务
+ {{ agentStatus?.frps_active ? '运行中' : '未运行' }}
+
+
+
+
+
+
+
+
+ 80 端口
+ {{ agentStatus?.ports?.['80'] ? '监听中' : '未监听' }}
+
+
+
+
+
+
+
+
+ 443 端口
+ {{ agentStatus?.ports?.['443'] ? '监听中' : '未监听' }}
+
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+ {{ agentStatus?.frps_unit_exec || '-' }}
+ {{ agentStatus?.frps_config_path || '-' }}
+
+
+
+
+ 停止 frps
+ 启动 frps
+ 重启 frps
+ frps 管理
+
+ 未配置 frps 管理接口:在「内网穿透」页服务端表单的「frps 管理接口」填写地址与端口后保存即可开启面板入口
+
+
+
+
+
+
+
+ {{ agentStatus?.frps_binary || '-' }}
+
+
+ 下发 frps 配置
+
+
+
+ 安装/升级 frps
+
+ VPS 无 frps 二进制时使用:自动下载并注册 frps.service,完成后自动下发同源配置。
+
+
+
+
+
+
+
+ 反代管理已独立成「公网反代」页面:逐条部署/移除、配置查看、强制跳转、IP 白名单、Basic Auth、WebSocket 等都在那里。
+ 前往公网反代
+
+
+
+
+
+ {{ agentStatus?.frps_log_tail || '暂无日志' }}
+
+
+
+
+
+
+
+ 探测环境
+ {{ server.agent_configured ? '升级 Agent' : '安装 Agent' }}
+ 卸载(含数据)
+ 卸载(保留路由/证书)
+ SSH 诊断
+
+
+
+
+ 首次安装、升级、卸载需要 SSH(在「内网穿透」页服务端表单的「公网 Agent 管理」区填写);安装成功后自动回填 Agent 地址与 Token。
+ 日常管理走 Agent API,无需 SSH。SSH 信息仅用于生命周期操作,凭据加密存储、不回显。
+
+ {{ agentOutput }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/CertificatesView.vue b/web/src/views/CertificatesView.vue
new file mode 100644
index 0000000..0d94813
--- /dev/null
+++ b/web/src/views/CertificatesView.vue
@@ -0,0 +1,1875 @@
+
+
+
+ 导入证书
+
+
+ 申请证书
+
+
+
+
+
+
+
+
+
+
+ {{ records.length }}
+ 证书总数
+ 当前已配置的证书
+
+
+
+
+ {{ normalCount }}
+ 正常证书
+ 运行正常
+
+
+
+
+ {{ expiringCount }}
+ 即将过期
+ 30 天内到期
+
+
+
+
+ {{ expiredCount }}
+ 已过期
+ 需要处理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 导入证书
+
+
+ 申请证书
+
+
+
+
+
+
+
+
+
+
+
+ 使用提示:
+ 证书属于域名本身;DNS 服务商仅作为申请/续签时的 DNS-01 验证通道,不会在证书上长期绑定。一张证书可包含多个域名和通配符,续签时系统会自动选用可用的 DNS 凭证完成验证。
+
+
+
+
+
+
+
+
+ 申请证书
+ 通过 DNS-01 验证自动申请证书,证书归属于域名而非 DNS 服务商
+
+
+
+
+
+ 1
+ 填写信息
+
+
+
+ 2
+ DNS 验证
+
+
+
+ 3
+ 申请完成
+
+
+
+
+ 首次申请证书需要注册 ACME 账户,请填写有效邮箱(如 admin@example.com),用于账户注册与到期提醒,保存后下次无需重复填写。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 使用 ZeroSSL 需在「设置」中配置 API Key
+
+
+ Buypass 不支持通配符,单张证书最多 5 个域名
+
+
+
+
+
+
+ 自动续期
+ 在证书到期前自动续期
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 选择用于本次 DNS-01 验证的 DNS 任务。这只是验证通道,选错会在下一步报错,不会与证书长期绑定。
+
+
+
+ 还没有 DNS 任务。
+ 没有已启用的 DNS 任务。
+
+ {{ ddnsConfigs.length === 0
+ ? '请先在 DDNS 页面添加 DNS API 凭证,然后返回继续申请。'
+ : '请先在 DDNS 页面启用至少一个 DNS 任务,然后返回继续申请。' }}
+
+
+
+
+
+
+
+
+
+
+ DNS 验证与证书签发可能需要 1~3 分钟,请保持窗口打开
+
+
+
+
+ {{ line.text }}
+
+ 等待日志输出…
+
+
+
+
+ 证书目录{{ applyResult.cert_dir || '-' }}
+ 证书文件{{ applyResult.cert_path || '-' }}
+ 私钥文件{{ applyResult.key_path || '-' }}
+ 到期时间{{ formatDate(applyResult.expires_at) }}
+
+
+
+
+
+
+
+
+ 取消
+ 下一步
+
+
+ 上一步
+
+ 开始申请
+
+
+
+ 申请进行中…
+ 完成
+
+
+
+
+
+
+
+
+ 导入证书
+ 提供证书与私钥后,自动解析域名、通配符和到期时间
+
+
+
+
+
+
+ 粘贴 PEM
+ 文件路径
+
+
+
+
+
+
+
+ 从本机文件读取
+
+
+
+
+ 从本机文件读取
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 导入
+
+
+
+
+
+
+
+
diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue
new file mode 100644
index 0000000..71cc28c
--- /dev/null
+++ b/web/src/views/DashboardView.vue
@@ -0,0 +1,1214 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ publicIPv4Label }}
+
+ {{ publicIPv6Label }}
+
+ {{ publicIPSourceLabel }}
+ 上次更新 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}
+
+
+
+
+
+
+ {{ domainCountLabel }}
+ {{ domainSubLabel }}
+
+ {{ domainExamplesLabel }}
+ 上次检查 {{ formatRelativeTime(status?.ddns_last_updated) || '刚刚' }}
+
+
+
+
+
+
+ {{ certCountLabel }}
+ {{ certSubLabel }}
+
+ {{ certDisplayName }}
+
+ 到期 {{ formatDate(primaryCert.expires_at) }} · 剩余 {{ primaryCert.days_left }} 天
+
+
+
+
+
+
+
+ {{ coreServiceCountLabel }}
+ 核心服务运行正常
+
+ Fonu / Nginx / DDNS / 证书
+ 上次检查 {{ formatRelativeTime(status?.started_at) || '刚刚' }}
+
+
+
+
+
+
+
+
+
+ 今日请求
+
+
+
+
+ {{ (status?.request_today ?? 0).toLocaleString() }}
+
+ 较昨日 {{ requestTrend >= 0 ? '+' : '' }}{{ requestTrend }}%
+
+
+
+
+
+
+
+
+
+
+
+
+ 实时流量
+
+ 最近 5 分钟
+
+
+
+
+
+ 上传
+ {{ formatRate(trafficTotals.uploadRate) }}
+
+
+
+ 下载
+ {{ formatRate(trafficTotals.downloadRate) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 运行状态
+
+
+ 查看详情
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+ {{ item.meta }}
+
+
+
+
+
+
+
+
+
+
+
+ 最近访问
+
+
+
+ 查看全部
+
+
+
+
+
+
+
+
+
+
+
+
+ 最新日志
+
+
+
+ 查看全部
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/DdnsView.vue b/web/src/views/DdnsView.vue
new file mode 100644
index 0000000..9637514
--- /dev/null
+++ b/web/src/views/DdnsView.vue
@@ -0,0 +1,1746 @@
+
+
+
+
+
+ 全部刷新
+
+
+
+
+
+
+
+ 请将域名 DNS 解析指向 FRP 服务器(VPS)的公网 IP,而非 NAS 本机 IP。可在
+ 内网穿透 页管理 FRP 配置。
+
+
+
+
+
+
+
+
+ 外网 IP 检测
+
+ 刷新检测
+
+
+
+ IPv4
+ {{ publicIPv4 || '-' }}
+
+
+
+
+
+ IPv6
+ {{ publicIPv6 || '-' }}
+
+
+
+
+
+ 上次检测 {{ ipCheckedLabel }}
+
+
+
+
+ {{ taskCount }}
+ 任务总数
+ 已配置的 DDNS 任务
+
+
+
+
+ {{ normalTaskCount }}
+ 正常任务
+ 运行正常
+
+
+
+
+ {{ abnormalTaskCount }}
+ 异常任务
+ 需要处理
+
+
+
+
+ {{ lastSyncRelative }}
+ 最后同步
+ {{ lastSyncAbsolute }}
+
+
+
+
+
+
+
+
+
+ 添加任务
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ providerLabel(selectedTask.provider) }}
+
+
+
+
+
+
+ 立即同步
+
+ 自定义 IP
+ 删除
+
+
+
+
+ 基本设置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IPv4
+ !isDraftSelected && patchTaskFlags(selectedTask!, { ipv4_enabled: v })"
+ />
+
+
+ IPv6
+ !isDraftSelected && patchTaskFlags(selectedTask!, { ipv6_enabled: v })"
+ />
+
+
+
+ 同步周期
+
+ 分钟
+ 保存
+
+
+
+
+
+
+
+ 解析记录({{ displayRecords.length }})
+
+
+ 添加记录
+
+
+
+
+
+
+
+ 主机记录
+ 类型
+ 当前解析值
+ 上次结果
+ 上次更新
+ 状态
+ 操作
+
+
+
+
+
+
+
+
+
+
+ -
+ -
+ -
+ -
+
+
+ 保存
+ 取消
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ -
+ -
+ -
+
+
+ 保存
+ 取消
+
+
+
+
+ {{ record.domain }}
+
+
+
+
+ {{ recordValue(record) }}
+ -
+
+ {{ record.message || recordResultLabel(record.status) }}
+ {{ formatRelativeTime(selectedTask.last_updated_at) || '-' }}
+
+
+ {{ recordStatusLabel(record.status) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 暂无解析记录,点击「添加记录」创建
+
+
+
+
+
+
+
+
+
+ {{ detailError.title }}
+ {{ detailError.text }}
+ 查看日志
+
+
+
+
+
+
+ 提示
+
+ 每条记录请填写完整域名(如 s.example.com、www.sub.example.com)。同一服务商任务可管理多个不同域名,Fonu 会按同步周期自动检测公网 IP 并更新对应解析记录。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 填写后 DDNS 更新将直接使用该 IP(如公网服务器 IP),不再检测本机出口 IP;须为公网地址,否则同步会被拒绝。
+
+
+
+ 取消
+ 保存
+
+
+ 清除已保存的自定义 IP
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/FrpView-1.0.2.vue b/web/src/views/FrpView-1.0.2.vue
new file mode 100644
index 0000000..12bddd1
--- /dev/null
+++ b/web/src/views/FrpView-1.0.2.vue
@@ -0,0 +1,1099 @@
+
+
+
+
+
+ 使用说明
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ connectionTitle }}
+ {{ connectionSubtitle }}
+
+
+
+
+ 查看日志
+
+
+
+
+ 外网用户
+ →
+ frps
+ →
+ frpc
+ →
+ Fonu Nginx
+
+
+
+
+
+
+ 服务器地址
+ {{ displayServerEndpoint }}
+
+
+ 连接时间
+ {{ connectedAtLabel }}
+
+
+ 运行时长
+ {{ uptimeLabel }}
+
+
+ 客户端版本
+ {{ frpStatus.client_version || '—' }}
+
+
+
+
+
+
+
+ HTTP 网关
+ {{ httpGatewayOn ? '转发到 Nginx HTTP' : '等待配置域名' }}
+
+
+
+
+
+ HTTPS 网关
+ {{ httpsGatewayOn ? '转发到 Nginx HTTPS' : '等待配置域名' }}
+
+
+
+
+
+ 穿透域名
+
+ {{ domain }}
+
+ +{{ domainCount - domainPreview.length }}
+
+
+
+
+
+
+
+
+
+ 连接设置
+
+
+
+
+ 启用内网穿透
+
+
+
+
+
+
+ 服务器地址 *
+
+
+
+ 服务器端口 *
+
+
+
+
+ Token *
+
+ {{ CONFIGURED_SECRET_TAG }}
+
+
+
+
+
+
+ 启用 TLS
+ 使用 TLS 连接到 frps,建议开启
+
+
+
+
+
+
+
+
+ {{ frpForm.enabled ? '保存并连接' : '保存并关闭' }}
+
+
+
+
+
+
+
+
+ Web 穿透
+
+
+
+
+ 同步域名
+
+
+
+
+ 将当前启用的反向代理域名同步到 FRP Web 网关,所有域名将通过 Fonu Nginx 进行分流。点击「已同步域名」可查看或编辑。
+
+
+
+
+
+
+ HTTP 网关
+ {{ httpGatewayOn ? '已启用' : '未启用' }}
+ 转发到 Fonu Nginx (HTTP)
+
+
+
+
+
+ HTTPS 网关
+ {{ httpsGatewayOn ? '已启用' : '未启用' }}
+ 转发到 Fonu Nginx (HTTPS)
+
+
+
+
+
+
+
+
+
+ 高级设置
+
+
+ 在 VPS 部署 frps 后,将下方配置保存为 frps.toml 并启动。配置会随上方连接设置实时更新,保存 Fonu 后同步生效。
+
+
+
+ VPS 服务端配置 (frps.toml)
+
+
+ 网关端口与 Fonu Nginx 一致(HTTP {{ nginxHttpPort }} / HTTPS {{ nginxHttpsPort }})。VPS 防火墙需放行
+ {{ frpForm.server_port || 7000 }}、{{ nginxHttpPort }}、{{ nginxHttpsPort }}。
+
+
+ 复制配置
+
+
+ 查看 frpc 日志
+
+
+
+
+
+
+
+
+
+ 1. 在 VPS 部署 frps,放行 7000、80、443 端口。
+ 2. 在本页填写 frps 地址、端口与 Token,保存并连接。
+ 3. 点击「同步域名」,从反代规则导入域名到 FRP Web 网关。
+ 4. 在 DDNS 将域名解析到 VPS 公网 IP。
+ 5. HTTPS 证书推荐继续使用 DNS-01,由 Fonu Nginx 终结 TLS。
+
+
+
+
+
+ 可手动编辑,或点击「同步域名」从已启用的反向代理规则导入。
+
+ 取消
+ 保存
+
+
+
+
+
+ {{ logText || '暂无日志' }}
+
+ 刷新
+
+
+
+
+
+
diff --git a/web/src/views/FrpView.vue b/web/src/views/FrpView.vue
new file mode 100644
index 0000000..f255d10
--- /dev/null
+++ b/web/src/views/FrpView.vue
@@ -0,0 +1,2095 @@
+
+
+
+
+
+ 应用配置
+
+
+
+ 使用说明
+
+
+
+ 启动 frpc
+
+
+
+ 停止 frpc
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 客户端状态
+
+
+
+
+
+
+ 启用规则
+ {{ status.enabled_proxies }}
+
+
+
+
+
+ frpc 版本
+ {{ status.frpc_version || '未检测' }}
+
+
+
+
+
+ 最近应用
+ {{ formatDate(status.last_reloaded_at) || '尚未应用' }}
+
+
+
+
+
+
+
+
+ 添加服务端
+
+
+
+
+ 添加服务端
+
+
+
+
+
+ {{ server.name }}
+
+ {{ server.server_addr }}
+ {{ server.server_port }}
+
+ {{ latencyText(server) }}
+
+ {{ locationText(server) }}
+
+ TLS {{ server.tls_enabled ? '已启用' : '未启用' }}
+ Token {{ server.has_token ? '已配置' : '缺失' }}
+
+ {{ serverStartupText(server) }} · {{ connectionStateText(server) }}
+
+
+
+
+
+
+
+
+ 查看脱敏配置
+
+
+ 添加规则
+
+
+
+
+
+ 添加规则
+
+
+
+
+
+ {{ status.last_error }}
+
+
+
+
+
+
+
+ {{ editingServer ? '编辑服务端' : '添加服务端' }}
+
+
+
+
+
+
+
+
+
+ 基本信息
+
+
+
+
+
+
+
+
+ 认证
+
+
+ 无
+ 令牌
+ OIDC(开放身份连接)
+
+
+
+
+
+
+
+ OIDC(OpenID Connect,开放身份连接)会向身份提供商申请访问令牌,再用于连接 FRP 服务端。
+
+
+
+
+
+
+
+
+
+
+ 跳过 OIDC TLS 校验
+
+
+
+
+ 描述
+
+
+
+
+
+ frps vhost 反代端口
+ frps 的 vhostHTTPPort / vhostHTTPSPort,即 Nginx 反代的回源端口(proxy_pass http://127.0.0.1:端口)。修改后需「下发 frps 配置」并重启 frps,且重新部署各规则的反代。留空用默认 8080 / 8443。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 强制 TLS(frps 只接受 TLS 连接)
+
+ frps 写文件日志(/var/log/frps.log)
+
+
+
+
+ frps 管理接口
+ 用于 Fonu 拉取穿透规则的实时连接与流量信息;需在 frps 的 frps.toml 中开启 webServer(dashboard),与 frpc 连接用的令牌无关。
+
+
+
+
+
+
+
+
+
+
+ 清除管理密码
+
+
+
+ 公网 Agent 管理
+ Agent 部署在公网 VPS 上,保存 HTTP/HTTPS 规则后可由 Agent 自动生成反代;「公网 Agent」页提供完整管理面板。首次安装/升级需要 SSH,日常管理走 Agent API。
+
+
+
+
+
+
+
+
+
+ 启用 Agent 日常管理
+
+ 测试连接
+ 下发 frps 配置
+ 打开管理面板
+
+
+
+
+
+ 日志
+
+
+
+
+
+
+
+ 传输
+
+ 启用 TLS 禁用自定义 TLS 首字节
+
+
+
+ 证书文件内容从文件加载
+
+
+
+ 密钥文件内容从文件加载
+
+
+
+ 受信任的 CA 内容从文件加载
+
+
+
+
+
+
+ 清除证书
+ 清除密钥
+ 清除 CA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TCP 多路复用 登录失败即退出 自动连接
+
+
+
+
+ 取消 保存
+
+
+
+
+ {{ serverFormTab === 'basic' ? '配置说明' : '高级配置说明' }}
+
+ - {{ item.title }}:{{ item.text }}
+
+
+
+ {{ serverHelpTip }}
+
+
+
+
+
+
+
+
+
+ {{ editingProxy ? '编辑穿透规则' : '添加穿透规则' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ HTTP / HTTPS 配置
+
+
+
+ WebSocket / 强制跳转 / IP 白名单 / Basic Auth 已移至「公网反代」页逐条规则的「部署设置」中配置;此处填写的值会保留。已填的 IP 白名单:{{ (proxyForm.options.allow_ips || []).join(', ') || '无' }}
+
+
+
+
+
+
+
+
+
+ TCPMUX 配置
+
+
+
+ 访问控制
+
+
+
+ 启用规则
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 启用流量加密 启用压缩
+
+
+
+
+
+
+ 禁用 NAT 辅助地址
+
+
+
+ 取消 保存
+
+
+
+
+ {{ proxyFormTab === 'basic' ? '配置说明' : '高级配置说明' }}
+
+ - {{ item.title }}:{{ item.text }}
+
+
+
+ {{ proxyHelpTip }}
+
+
+
+
+ {{ proxyQuickExample.title }}示例
+ {{ proxyQuickExample.description }}
+
+
+
+ 快速配置
+
+
+ {{ proxyQuickExample.lines.join('\n') }}
+
+
+
+
+
+
+
+
+
+ {{ detailProxy.name }}
+ {{ detailProxy.enabled ? '运行中' : '已停止' }}
+
+
+ {{ proxyTraffic[detailProxy.id]?.cur_conns ?? 0 }} 连接
+
+
+ 关闭
+
+
+
+
+
+
+ 服务端
+ {{ proxyDetailServerName(detailProxy) }}
+
+
+ 类型
+
+ {{ detailProxy.type.toUpperCase() }}
+
+
+
+ 内网目标
+ {{ proxyDetailValue(detailProxy, 'local') }}
+
+
+ 公网入口
+ {{ proxyDetailValue(detailProxy, 'public') }}
+
+
+
+ 传输策略
+
+ 加密
+ 压缩
+ PP {{ detailProxy.options.transport.proxy_protocol_version }}
+ 限速 {{ detailProxy.options.transport.bandwidth_limit }}
+
+ 未启用
+
+
+
+
+ 实时流量 最近 5 分钟
+ 实时
+
+
+ 上传 {{ formatRate(proxyTraffic[detailProxy.id]?.traffic_in_rate ?? 0) }}
+ 下载 {{ formatRate(proxyTraffic[detailProxy.id]?.traffic_out_rate ?? 0) }}
+
+
+
+
+
+
+
+
+ frpc 规则日志(按规则名过滤所属服务端日志)
+ 刷新
+
+ {{ proxyLogLines.length ? proxyLogLines.join('\n') : '暂无该规则的日志' }}
+
+
+
+
+
+
+
+ {{ serverLogLines.length ? serverLogLines.join('\n') : '暂无该服务端的运行日志' }}
+
+
+
+
+ {{ viewingServer.name }}
服务端详情和运行监测
停止 启动 frps.toml
+
+ 基本信息
{{ viewingServer.server_addr }}{{ viewingServer.server_port }}{{ viewingServer.options.user || '未设置' }} {{ locationText(viewingServer) }} {{ viewingServer.options.protocol.toUpperCase() }}{{ formatDate(viewingServer.created_at) }}{{ formatDate(viewingServer.updated_at) }}
+
+ 配置
+
+ {{ viewingServer.options.remark || '未提供描述。' }}
+
+
+ 连接与认证
+
+ {{ viewingServer.tls_enabled ? '已启用' : '未启用' }}
+ {{ authMethodText(viewingServer.options.auth_method) }}{{ viewingServer.has_token ? '(Token 已配置)' : '' }}
+ {{ runtime.active_version || runtime.detected_version || '当前版本' }}
+ {{ serverStartupText(viewingServer) }}
+ {{ processStateText(viewingServer) }}{{ viewingServer.process_pid ? `(PID ${viewingServer.process_pid})` : '' }}
+ {{ connectionStateText(viewingServer) }}
+ {{ viewingServer.state_source || '未知' }}
+
+
+
+ 诊断
+ {{ checkLabel(check.name) }}·{{ checkStateText(check.state) }}
+ 暂无诊断数据,可点「立即诊断」获取
+
+
+
+ 服务器状态
{{ detailAvailability.toFixed(1) }}%{{ detailAverageLatency }}ms{{ detailPeakLatency }}ms
+ 延迟曲线
最近 30 分钟 暂无最近 30 分钟诊断采样
+ 代理
刷新
+ 连接日志
刷新 {{ detailLogs.length ? detailLogs.join('\n') : '暂无该服务端连接日志' }}
+
+
+
+
+ {{ exportContent || '暂无可导出的启用配置' }}
+
+
+
+
+
+ 与该服务端的 frpc 配置同源生成(bindPort / 认证 / 管理接口一致);复制到 VPS 保存为 frps.toml 后执行 ./frps -c frps.toml。包含真实 Token,请勿外传。
+ {{ frpsConfigContent || '生成中…' }}
+
+
+
+
+
+
+ FRP 穿透使用说明
+
+
+
+
+
+
+
+
+
+ 一、配置流程(推荐顺序)
+
+ - VPS 侧部署 frps:在服务端「详情」弹窗点「frps.toml」生成与本服务端同源的配置(bindPort / 认证 / 管理接口一致),复制到 VPS 保存为 frps.toml 并执行
./frps -c frps.toml;按文件末尾的防火墙清单放行端口。
+ - 添加服务端:主机地址 / 端口 / 认证方式与 frps 一致;Token 加密存储,编辑时留空表示不修改;frps 管理接口(webServer)选填,仅用于拉取穿透流量与连接数,与连接认证无关。
+ - 添加穿透规则:8 种类型——tcp / udp 需远程端口(同一服务端内查重);http / https / tcpmux 需自定义域名(走 frps 的 vhost 端口);stcp / sudp / xtcp 无公网监听端口(凭 secretKey 访问);内网目标可填 local_ip:port,或启用插件(static_file / socks5 / https2http 等,按类型校验必填字段)。
+ - 校验并应用:右上「应用配置」弹窗可管理 frpc 版本(下载 / 启用 / 删除)、配置下载代理,并校验应用配置;单台服务端可独立「启动 / 停止」。配置 verify 通过才落盘,坏配置不会覆盖现有运行。
+ - 验证:服务端「详情」含五项诊断(TCP 可达 / frpc 登录 / 代理可达 / DNS 解析 / 归属地)与可用率、延迟曲线;「日志」为该台服务端独立的 frpc 运行日志;规则「详情 → 日志」按规则名过滤。
+
+
+
+
+ 二、与 DDNS 的组合
+
+ - IP 来源留空(默认):每次同步自动检测本机公网 IP——家庭宽带 NAS 场景,域名指向家里出口。
+ - IP 来源自定义:DDNS 页选中任务 → 「自定义 IP」填 VPS 的 IP,域名直接解析到 VPS(Web 服务经 VPS 对外时使用)。须为公网地址,内网 IP 会被拒绝。
+ - VPS 动态 IP:DDNS 自定义 IP 填当前 VPS IP,FRP 服务端「主机地址」填 DDNS 域名而非裸 IP——VPS 换 IP 后域名自动跟随,frpc 重连无需改 Fonu 配置。
+
+
+
+
+ 三、与反向代理的组合(按需求选型)
+
+ - 多 Web 应用统一入口(推荐):FRP 一条 tcp 规则,远程端口 443,内网目标 127.0.0.1:443(Fonu Nginx 的 HTTPS 监听);反向代理页按域名分流到各内网应用,TLS 与证书都在本地 Nginx 终止。加规则只改反代,不动 FRP。
+ - HTTP/HTTPS 直通:frps 按域名分流,不需要本地反代。存在 http/https 规则时,本页生成的 frps.toml 会自动带上 vhostHTTPPort / vhostHTTPSPort;FRP 规则选 http / https 类型并填自定义域名。TLS 终止在 VPS 侧或上游。
+ - 端口映射:SSH、远程桌面、数据库等非 HTTP 服务——tcp / udp 规则 + 远程端口,访问
VPS_IP:远程端口 即达内网。
+ - STCP 安全穿透:管理入口不想暴露公网端口时用 stcp,只放行 frps 的 bindPort;访问端运行同 secretKey 的 visitor 配置连回内网。
+ - 有公网 IP 的直连:无需 FRP——DDNS 指向家庭宽带出口 + 路由器端口映射 + 反代页的域名 / HTTPS / 证书即可,少一跳延迟但暴露出口 IP。
+
+
+
+
+ 四、与证书的配合
+
+ - 证书页用 ACME 自动申请(DNS-01 验证复用 DDNS 的服务商凭证),通配符证书
*.example.com 一张即可覆盖反代的多个域名。
+ - 「统一入口」方案:证书装在本地 Nginx(反代规则里选择);「HTTP/HTTPS 直通」方案:证书在 VPS 侧或上游终止,本地不需要。
+ - 反代指定证书不可用(过期 / 不覆盖域名)时保存会被阻断,不会静默降级为 HTTP。
+
+
+
+
+ 五、常见问题速查
+
+ 现象 排查
+
+ 校验失败 / login to server failed Token 不一致或 frps 未启动;用服务端详情的「frps.toml」对照两端配置
+ 登录成功但外部访问失败 依次查:DNS 解析 → VPS 防火墙 → frps 端口(vhost / 远程端口)→ 本地 Nginx
+ 域名打不开、IP 能通 frps 未开 vhostHTTPPort / vhostHTTPSPort(http/https 规则必需)
+ 流量 / 连接数全为 0 frps 未配置 webServer,或服务端表单未填管理地址 / 端口;非必须功能
+ 改了配置没生效 「应用配置」里点「校验并应用配置」,或对单台服务端重启
+
+
+
+
+
+
+ 六、组合场景详解
+ 按需求选型,点开场景查看拓扑与操作步骤;步骤均基于当前页面实际功能(服务端 / 穿透规则 / frps.toml 同源生成 / DDNS / 反向代理 / 证书)。
+
+
+ 适用:家庭宽带 NAS,多个 Web 应用统一 443 对外,VPS 做公网入口中转。
+ 用户 → https://app.example.com (443)
+ → DNS 解析到 VPS IP
+ → VPS:443 → frps → FRP TCP 隧道(远程端口 443)
+ → NAS frpc → 127.0.0.1:443
+ → Fonu Nginx(本地证书终止 TLS,按域名分流)→ 内网应用
+
+ - VPS 部署 frps:服务端「详情 → frps.toml」生成同源配置,放行 bindPort 与 443;
+ - DDNS:解析 example.com / *.example.com 到 VPS IP(「自定义 IP」填 VPS IP);
+ - 证书:为 *.example.com 申请通配符证书(DNS-01 复用 DDNS 凭证);
+ - FRP 服务端:添加 VPS,Token 与 frps 一致;
+ - FRP 规则:类型 tcp,远程端口 443,内网目标 127.0.0.1:443(Fonu Nginx 的 HTTPS 监听);
+ - 反向代理:按域名建规则(HTTPS + 强制跳转 + 选证书),目标为各内网应用;以后加应用只改反代,不动 FRP;
+ - 「应用配置」校验并应用 → 服务端「详情」看诊断与日志验证。
+
+ 域名也可解析到家庭宽带 IP(DDNS 留空自动检测),VPS 仅做隧道;两种都可行,关键是 frps 监听的 443 必须有流量进来。
+
+
+ 适用:VPS 是动态 IP / 周期变化 / 多台轮换,域名始终指向当前 VPS。
+
+ - DDNS「自定义 IP」填当前 VPS IP(注意:留空检测的是 NAS 本机出口 IP,两者语义不同);
+ - FRP 服务端「主机地址」填 DDNS 域名(如 vps.example.com)而非裸 IP;
+ - VPS IP 变化后 DDNS 定时任务自动跟随,frpc 重连无需改 Fonu 配置;
+ - 服务端「日志」确认重新出现 login to server success。
+
+ 通常与场景 A / C 叠加:DDNS 保证「地址跟着 VPS 走」,FRP 保证「隧道通」。
+
+
+ 适用:少量 Web 服务、不想配本地反代,frps 直接按域名分流。
+ 用户 → http://app.example.com:8080
+ → DNS → VPS
+ → frps(vhostHTTPPort,按 Host 分流)
+ → FRP HTTP 隧道 → NAS 内网应用
+
+ - 存在 http/https 规则时,本页生成的 frps.toml 已自动带 vhostHTTPPort / vhostHTTPSPort(80/443);用非标准端口请自行调整并放行防火墙;
+ - DDNS「自定义 IP」把 app.example.com 解析到 VPS IP;
+ - FRP 规则:类型 http(或 https),自定义域名 app.example.com,内网目标为应用地址;
+ - 应用配置后验证。
+
+ 此方案 TLS 终止在 VPS 侧或上游,本地 Nginx 不参与;HTTPS 场景必须明确 TLS 终止位置。
+
+
+ 适用:SSH、远程桌面、数据库、游戏等非 HTTP 服务。
+
+ - FRP 规则:类型 tcp(或 udp),远程端口如 6022,内网目标 127.0.0.1:22;
+ - 应用配置后访问 VPS_IP:6022 即达内网 SSH;
+ - 可选:传输加密 / 压缩;同一服务端内 tcp、udp 的远程端口分别查重。
+
+ 管理入口不想暴露公网端口时改用 stcp(见场景 G)。
+
+
+ 适用:多台 VPS、规则分组管理。
+
+ - 添加多个 FRP 服务端(各自地址 / Token / 认证方式),规则分别归属;
+ - 每台服务端独立「启动 / 停止 / 日志 / 诊断 / frps.toml 生成」,互不干扰;
+ - 「应用配置」弹窗统一管理 frpc 版本(下载 / 启用 / 删除)与下载代理,对所有服务端生效;
+ - 顶部状态卡显示全局客户端状态与启用规则数;规则「详情 → 流量」看各规则实时连接与速率(需 frps 配置 webServer)。
+
+
+
+ 适用:有公网 IPv4 / IPv6 的家庭宽带,不需要中转。
+ 用户 → https://app.example.com
+ → DNS(DDNS 自动更新到宽带出口 IP)
+ → 家庭宽带路由器端口映射 :443
+ → NAS Fonu Nginx(本地证书)→ 内网应用
+
+ - 确认宽带有公网 IP(没有则只能走 FRP 中转或 IPv6 直连);
+ - DDNS IP 来源留空(自动检测本机出口);
+ - 路由器把 WAN 80/443 映射到 NAS;
+ - 反向代理 + 证书按需配置,浏览器直接访问域名验证。
+
+ 与场景 A 的取舍:直连少一跳延迟、不依赖 VPS,但暴露家庭出口 IP 且需路由器权限;FRP 中转隐藏出口、多一层安全,但流量过 VPS。
+
+
+ 适用:SSH、远程桌面等管理入口,不想在 VPS 上开任何业务监听端口。
+
+ - FRP 规则:类型 stcp,无远程端口;填 secret_key(可选 allow_users 限定访问者),内网目标如 127.0.0.1:22;
+ - 访问端设备运行同 secretKey 的 frpc visitor 配置,生成本地入口(如 127.0.0.1:6022);
+ - 访问:ssh -p 6022 127.0.0.1(在装有 visitor 的设备上)。
+
+ VPS 防火墙只需放行 bindPort,扫描器扫不到服务;sudp / xtcp 同理(xtcp 优先 P2P 直连,失败回退中转)。
+
+
+ 适用:不建反代、不起本地应用,用 frpc 内置插件直接提供能力;插件必填字段在保存规则时按类型校验。
+
+ 插件 用途 必填
+
+ static_file 快速共享一个目录(简单文件服务器) 本地目录路径
+ unix_domain_socket 暴露本机 Unix Socket 服务 Socket 路径
+ http_proxy / socks5 把 NAS 变成出口代理(配合 visitor 内网穿透用) 本地地址
+ https2http / https2https / tls2raw frpc 侧 TLS 卸载 / 转换后转发到本地 HTTP 本地地址 + 证书 crt/key
+
+
+ 操作:添加 / 编辑规则 → 「内网目标」启用插件 → 按类型填写字段 → 保存(校验不过会明确提示缺哪个字段);远程端口 / 域名要求与普通规则相同。
+
+
+ 适用:域名挂在 Cloudflare 等 CDN 后面,隐藏源站 IP、边缘加速。
+ 用户 → CDN(边缘 TLS)
+ → 回源 → VPS frps :远程端口(FRP TCP 隧道)
+ → NAS Fonu Nginx(HTTP 或本地证书)→ 内网应用
+
+ - CDN 侧添加域名,DNS 由 CDN 托管(此时可不建 DDNS,或 DDNS 只用于 FRP 服务端地址,见场景 B);
+ - FRP tcp 规则:远程端口如 8443,内网目标指向 Fonu Nginx 的对应监听;
+ - 反代 HTTPS 按回源模式决定(Flexible → HTTP 回源可关 443;Full → 需本地证书);
+ - 真实客户端 IP 来自 CDN,需在设置中配置信任代理网段,IP 黑白名单 / 限流才基于真实 IP 生效。
+
+
+
+ 适用:对外服务加访问控制,多层叠加。
+
+ - IP 白名单:仅办公室 / 家庭出口可访问管理面板类应用;
+ - 大陆 IP 白名单:只允许境内用户(China CIDR 数据源在设置中配置);
+ - 全局 IP 黑名单:设置页配置,对所有反代规则生效(如封扫描源);
+ - Basic Auth:简单口令保护无登录页的内网工具;
+ - 限流 / 连接数限制:防 CC / 爬虫;
+ - 组合建议:管理入口 = STCP(场景 G)或白名单 + Basic Auth;公开 Web = CDN + 大陆白名单 / 黑名单 + 限流。规则修改保存后 Nginx 原子应用,失败自动回滚。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 刷新
+
+
+ 校验并应用配置
+
+
+
+
+ 本机运行环境
+
+ {{ runtime.platform || '检测中' }}
+ {{ runtime.active_bin || '未检测到' }}
+ {{ runtime.detected_version || '未检测到' }}
+ {{ runtime.config_paths.join('、') }}{{ runtime.config_path }}{{ runtime.config_exists ? '(已生成)' : '(尚未生成)' }}
+
+
+
+
+ 下载加速代理
仅代理 FRP 官方 Release 下载地址
+
+
+ 保存
+
+
+
+
+ 已下载客户端
{{ binaries.length }} 个版本
+
+
+
+ {{ binary.version }}
+
+ 当前使用
+ 启用
+ 删除
+
+
+
+
+
+
+ FRP 官方下载
仅显示当前系统 {{ runtime.platform || '' }} 可用的稳定版本
+
+
+
+ {{ release.version }}
+
+
+
+ {{ downloadTask.total > 0 ? `${downloadPercent}%` : '...' }}
+
+
+
+ {{ isDownloaded(release.version) ? '已下载' : '下载' }}
+
+
+
+
+
+ {{ releasesExpanded ? '收起列表' : `显示更多(剩余 ${releases.length - releaseLimit} 条)` }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue
new file mode 100644
index 0000000..a509f42
--- /dev/null
+++ b/web/src/views/LoginView.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Fonu
+
+ 让 NAS 访问更简单
+
+
+
+
+
+ 登录管理后台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+ 首次使用请查看系统日志中的初始密码,登录后请在「设置」中修改。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/LogsView.vue b/web/src/views/LogsView.vue
new file mode 100644
index 0000000..0d2fb97
--- /dev/null
+++ b/web/src/views/LogsView.vue
@@ -0,0 +1,637 @@
+
+
+
+
+
+
+
+
+
+ Fonu 全部运行日志(API、DDNS、证书、通知、Nginx 重载等),不含 Nginx 访问与错误文件。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nginx 错误日志(error.log),记录 SSL 握手失败、上游连接异常、配置冲突等。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/ProxyRemoteView.vue b/web/src/views/ProxyRemoteView.vue
new file mode 100644
index 0000000..5815216
--- /dev/null
+++ b/web/src/views/ProxyRemoteView.vue
@@ -0,0 +1,583 @@
+
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+ 去内网穿透页
+
+
+
+
+
+
+ VPS 未安装 Nginx
+ 公网反代需要 Nginx 承接 80/443 并终结 HTTPS。可点右侧按钮由 agent 自动安装(识别 apt-get / dnf / yum / apk,需 root),或在 VPS 手动执行:apt install -y nginx / yum install -y nginx
+
+ 一键安装 Nginx
+
+
+
+ Nginx 配置校验未通过
+ {{ agentStatus.nginx_error || 'nginx -t 执行失败' }}
+
+
+
+
+
+
+
+ 状态
+
+ {{ nginxState().label }}
+
+
+
+ 进程
+ {{ agentStatus?.nginx_running ? '运行中' : '未运行' }}
+
+
+ 版本
+ {{ agentStatus?.nginx_version || '—' }}
+
+
+ 可执行文件
+ {{ agentStatus?.nginx_path || agentStatus?.nginx_binary || '—' }}
+
+
+ vhost 配置目录
+ {{ agentStatus?.nginx_conf_dir || '—' }}
+
+
+ 配置校验
+ {{ agentStatus?.nginx_ok ? 'nginx -t 通过' : '未通过' }}
+
+
+ 端口监听
+ 80 {{ agentStatus?.ports?.['80'] ? '✓' : '✗' }} · 443 {{ agentStatus?.ports?.['443'] ? '✓' : '✗' }}
+
+
+ 反代配置文件
+ {{ agentStatus?.routes?.length ?? 0 }} 个
+
+
+ {{ agentStatus.nginx_error }}
+
+ 一键安装 Nginx
+ 重新检测
+
+
+
+
+
+
+ 穿透规则保存后不会自动修改 VPS。点击「部署反代」把该规则的域名发布到公网 Nginx;部署时自动推送证书并做回源自检。
+
+
+
+ {{ candidate.name }}
+ {{ candidate.custom_domains.join('、') }}
+ {{ candidate.local_ip }}:{{ candidate.local_port }}(回源 127.0.0.1:{{ vhostPortOf(candidate) }})
+
+ {{ h.label }} {{ h.ok ? '✓' : '✗' }}
+
+
+ 连接 {{ trafficOf(candidate).cur_conns }}
+ 当前上传 {{ formatRate(trafficOf(candidate).traffic_in_rate) }}
+ 当前下载 {{ formatRate(trafficOf(candidate).traffic_out_rate) }}
+ 总上传 {{ formatBytes(trafficOf(candidate).traffic_in) }}
+ 总下载 {{ formatBytes(trafficOf(candidate).traffic_out) }}
+ 流量采集失败
+
+
+
+ {{ stateMeta(candidate).label }}
+ 查看配置
+ 部署设置
+ 部署反代
+ 移除反代
+
+
+
+ 暂无可部署的 HTTP/HTTPS 穿透规则——先到「内网穿透」页创建启用的 http 类型规则(填自定义域名)
+
+ VPS 上 agent 管理的路由文件({{ routeDetails.length }})
+
+ {{ detail.domain }} — {{ detail.loaded ? '已加载' : detail.syntax_ok ? '已写入未加载' : '语法错误' }}
+
+
+
+
+
+
+
+ 部署选项(启用 HTTPS / 强制跳转 / WebSocket / 上传大小 / 代理超时 / IP 白名单与黑名单 / Basic Auth / 仅 TLS 1.3 / 安全响应头)在本页规则行的「部署设置」里配置,点「保存并重新部署」即生效。Basic Auth 密码留空时首次部署自动生成,写在生成的配置注释里(点「查看配置」可见)。注意:这些选项需要 VPS 上已升级到新版 agent。
+
+
+
+
+
+
+ 部署设置 — {{ settingsTarget?.name }}
+ 取消
+
+
+
+
+
+
+
+ 启用 HTTPS
+ 证书已部署时监听 443;关闭后仅保留 80 明文入口
+
+
+
+
+
+ HTTP 强制跳转 HTTPS
+ 80 端口访问自动 301 到 HTTPS(需启用 HTTPS)
+
+
+
+
+
+ WebSocket 支持
+ 附加 Upgrade / Connection 头,实时服务(家庭影院、监控等)需要
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ IP 访问控制
+
+
+
+
+
+
+
+
+
+
+
+ 仅中国大陆 IP
+ 部署时自动把本地维护的中国 IP 段下发到 VPS;非大陆 IP 返回 403
+
+
+
+
+ 认证
+
+
+
+
+
+
+
+
+ 流量控制
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 安全响应头
+ HSTS / X-Frame-Options / X-Content-Type-Options / Referrer-Policy
+
+
+
+
+
+ 仅 TLS 1.3
+ 只允许 TLS 1.3 握手(需启用 HTTPS)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 反代配置 — {{ confDomain }}
+ 关闭
+
+ {{ confContent || '加载中…' }}
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/ProxyView.vue b/web/src/views/ProxyView.vue
new file mode 100644
index 0000000..5f08e07
--- /dev/null
+++ b/web/src/views/ProxyView.vue
@@ -0,0 +1,3484 @@
+
+
+
+
+
+ 扫描服务
+
+
+
+ 新建规则
+
+
+
+
+
+
+
+
+
+
+ {{ rules.length }}
+ 服务总数
+ {{ enabledCount }} 运行 · {{ disabledCount }} 停止
+
+
+
+ {{ formatBytes(trafficTotals.upload) }}
+ 总上传
+ 累计流量
+
+
+
+ {{ formatBytes(trafficTotals.download) }}
+ 总下载
+ 累计流量
+
+
+
+ {{ formatRate(trafficTotals.uploadRate) }}
+ 当前上传
+ 实时速率
+
+
+
+ {{ formatRate(trafficTotals.downloadRate) }}
+ 当前下载
+ 实时速率
+
+
+
+ {{ trafficTotals.connections }}
+ 当前连接
+ 全部规则合计
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 新建规则
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ ruleName(selectedRule) }}
+
+
+
+ {{ selectedTraffic?.connections ?? 0 }} 连接
+
+
+
+ 复制
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+ 域名
+
+ {{ ruleHosts(selectedRule).join('、') }}
+
+
+
+ 监听
+ {{ listenLabel(selectedRule) }}
+
+
+ 目标
+ {{ selectedRule.upstream }}
+
+
+ 协议
+
+
+ {{ selectedRule.https_enabled ? 'HTTPS' : 'HTTP' }}
+
+
+
+
+
+
+ 安全策略
+
+ 未启用
+
+
+
+
+
+ 实时流量 最近 5 分钟
+ 实时
+
+
+
+
+ 上传 {{ formatRate(selectedTraffic?.upload_rate ?? 0) }}
+
+
+
+ 下载 {{ formatRate(selectedTraffic?.download_rate ?? 0) }}
+
+
+
+
+
+
+
+
+
+
+
+ 实时访问日志
+
+ 全屏
+ 清空
+
+
+
+
+
+
+
+ 规则已停用,以下配置不会写入 Nginx。
+
+
+ 当前为手动编辑配置。如需修改,请使用右上角「编辑」。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 实时访问日志
+ {{ ruleName(selectedRule) }}
+
+
+ 清空
+ 退出全屏
+
+
+
+
+
+
+
+
+
+
+ {{ editing ? '编辑规则' : '新增规则' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 前端域名
+
+
+
+
+ 每行一个域名;如需单独端口可写 example.com:6893
+
+
+
+
+
+ 支持多个域名,每行一个,可包含端口
+
+
+
+
+
+ 监听端口 *
+
+
+
+
+
+ 监听协议
+
+
+ IPv4
+ IPv6
+
+
+
+
+
+
+
+
+ 支持 http://、https://,也可以是 IP 地址或内网域名
+
+
+
+
+
+
+ 启用 HTTPS
+ 为前端域名启用 HTTPS 访问
+
+
+
+
+
+ HTTP 跳转 HTTPS
+ 将 HTTP 请求自动跳转为 HTTPS
+
+
+
+
+
+ 启用规则
+ 保存后立即开始转发请求
+
+
+
+
+
+
+
+
+
+
+
+ 已启用
+
+ 暂无
+
+
+
+ 一键推荐
+
+
+
+ IP 策略依赖「设置 → 信任代理」;白名单 IP 可豁免「仅中国大陆」限制。
+
+
+
+
+
+
+
+
+ 仅中国大陆 IP
+
+ 需先在设置页更新中国 IP 段;内网(10/8、172.16/12、192.168/16 等)默认放行
+
+
+
+
+
+
+
+ 黑名单模式
+ 启用后拒绝列表中的 IP 访问
+
+
+
+
+
+
+
+
+
+
+
+ 白名单模式
+ 启用后仅允许白名单 IP 访问
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Basic Auth
+ 浏览器弹窗认证
+
+
+
+
+
+
+
+
+
+
+ 密码
+
+ {{ CONFIGURED_SECRET_TAG }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 请求限流
+ 超出速率返回 429
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 连接数限制
+ 每 IP 最大并发连接
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 忽略后端 TLS 证书
+ 上游为自签 https:// 时使用
+
+
+
+
+
+ 使用目标 Host 头
+ 转发时使用上游地址作为 Host
+
+
+
+
+
+ 仅 TLS 1.3
+
+
+
+
+
+ 安全响应头
+ HSTS、X-Frame-Options 等
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 去填写基础配置
+
+
+
+
+
+ 手动模式下,修改基础/安全设置后需先保存规则,再在此处同步 Nginx 文本。
+
+
+
+
+ 自动生成
+
+
+ 手动编辑
+
+
+
+
+ 保存 Nginx
+
+
+ 回滚
+
+
+ 恢复自动生成
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 配置说明
+
+ -
+ 前端域名:支持多个域名,每行一个;单独端口可写
+
example.com:6893。
+
+ -
+ 目标地址:内网服务地址,支持
http://、https:// 或 IP。
+
+ -
+ HTTPS:需在「证书」页为域名申请或上传证书。
+
+
+
+
+ 请确保域名已解析到本机,且内网服务可访问。
+
+
+
+ Nginx 说明
+
+ - 自动生成:根据基础配置与安全设置生成,并附带中文注释。
+ - 手动编辑:保存前会自动备份,支持回滚。
+ - 修改基础/安全项后请先点「保存」,再回到此页刷新预览。
+
+
+
+ 详情页的 Nginx 页签仅展示当前生效配置。
+
+
+
+ 小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。
+
+
+
+ 安全说明
+
+ -
+ IP 策略:经 CDN 访问时,请在「设置」配置信任代理,否则限流与 IP 规则可能不准。
+
+ -
+ 仅中国大陆:需先在设置页更新中国 IP 段;内网 IP 默认放行,额外豁免 IP 可写在白名单里(无需开启白名单模式)。
+
+ -
+ 一键推荐:HTTPS + 限流 + 安全响应头,适合公网暴露场景。
+
+
+
+
+ 安全策略保存后会自动重载 Nginx 配置。
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue
new file mode 100644
index 0000000..58929cf
--- /dev/null
+++ b/web/src/views/SettingsView.vue
@@ -0,0 +1,1672 @@
+
+
+
+
+
+
+
+
+
+
+
+ 主题模式
+
+
+
+ 切换后立即预览,保存后写入配置
+
+
+
+ 时区
+
+ 影响日志、证书到期时间等显示
+
+
+
+
+
+
+
+ DDNS 检查周期(分钟)
+
+
+ 每隔 N 分钟检查公网 IP 是否变化并更新 DNS
+
+
+
+ 证书续签检查(天)
+
+
+ 当证书剩余有效期少于此天数时自动续签
+
+
+
+ 日志保留天数
+
+
+ 超过保留天数的日志将被自动清理
+
+
+
+
+
+
+
+ ACME 邮箱
+
+ 用于 Let's Encrypt / ZeroSSL / Buypass 账户注册
+
+
+ 申请 ZeroSSL 证书时必填,可在 ZeroSSL 控制台获取
+
+
+
+
+
+
+
+
+
+ 恢复配置
+ 从备份文件还原系统配置
+
+
+
+ 恢复配置将覆盖当前数据,操作前请确保已备份。
+
+
+
+
+
+
+
+
+
+ 通知方式
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+ SMTP 主机
+
+
+
+ 端口
+
+
+
+
+ 用户名
+
+
+
+
+ 发件人
+
+
+
+ 收件人(每行一个)
+
+
+
+
+ 启用 TLS
+
+
+
+
+
+
+
+ Webhook 预设
+
+
+
+
+ Bark 服务地址
+
+
+
+
+
+
+ ntfy 服务地址
+
+
+
+ Topic
+
+
+
+
+
+
+ Gotify 服务地址
+
+
+
+
+
+
+ Webhook URL
+
+ 将以 JSON 发送:event、category、icon、title、message、formatted、time
+
+
+
+
+
+
+
+ Chat ID
+
+
+
+ 代理地址(可选)
+
+ 仅在无法直连 Telegram API 时填写 HTTP 代理
+
+
+
+
+ 告警事件
+
+ {{ group.title }}
+
+
+
+
+ {{ event.label }}
+ {{ event.hint }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 测试通知
+ 使用当前表单配置发送测试消息,无需先保存
+
+
+
+
+
+
+
+
+
+
+
+ IP 黑白名单、仅中国大陆等策略依赖正确识别客户端 IP。若域名经 CDN/多层反代,请在此配置信任代理。
+
+
+
+ 启用信任代理
+ 从请求头解析真实客户端 IP
+
+
+
+
+
+ 预设
+
+
+
+
+ 信任 CIDR(每行一个)
+
+
+
+ IP 头字段
+
+
+
+
+
+
+ 全局 IP 黑名单
+
+
+
+ 每行一个,优先于所有规则拦截
+
+
+ 全局 IP 白名单
+
+
+
+ 内网段已默认放行(已预填),可在下方追加额外 IP,每行一个
+
+
+
+
+ 中国 IP 段
+
+
+ {{ refreshingCIDR || chinaCIDR.updating ? '更新中…' : '立即更新' }}
+
+
+
+
+ 上次更新:{{ formatDate(chinaCIDR.updated_at) }}({{ formatRelativeTime(chinaCIDR.updated_at) }}) · IPv4
+ {{ chinaCIDR.entry_count_v4 }} 条 · IPv6 {{ chinaCIDR.entry_count_v6 }} 条
+
+
+ 上次更新:{{ formatDate(chinaCIDR.updated_at) }} · IPv4 {{ chinaCIDR.entry_count_v4 }} 条 · IPv6
+ {{ chinaCIDR.entry_count_v6 }} 条(当前不可用,请重新更新)
+
+
+ 正在下载 IPv4 / IPv6 段并校验 Nginx 配置,通常需要 10–60 秒…
+
+ 尚未下载中国 IP 段,启用「仅中国大陆」前请先更新
+
+
+ {{ chinaCIDR.last_error }}
+
+
+
+
+ 更新间隔(小时)
+
+
+ 自动检查并更新中国 IP 段库的间隔
+
+
+
+
+
+
+
+
+ 当前密码
+
+
+
+ 新密码
+
+
+
+ 确认新密码
+
+
+
+
+ 修改管理员密码
+
+
+
+
+
+
+
+
+
+
+ 仅作用于 http {} 内部、各反代 server {} 之前。单条规则的 server 块请在反代详情 → Nginx 中编辑。
+
+
+ 查看自动生成的框架说明
+
+
+
+
+
+ 自动生成
+
+
+ 手动编辑
+
+
+
+
+ 保存
+
+
+ 回滚
+
+
+ 恢复自动生成
+
+
+
+
+
+ 上传大小默认 50M。如需针对单条规则单独设置,请到对应反代 → 编辑 → Nginx 中修改
+ client_max_body_size。
+
+
+ 小白勿碰。若保存后 Nginx 启动失败,请点「恢复自动生成」或「回滚」还原配置。
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..af40b2d
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "preserve",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/web/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..6ec660b
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,33 @@
+import { readFileSync } from 'node:fs'
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as { version: string }
+
+export default defineConfig({
+ plugins: [vue()],
+ define: {
+ __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || pkg.version),
+ },
+ server: {
+ port: 5173,
+ allowedHosts: true,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:6893',
+ configure(proxy) {
+ proxy.on('proxyRes', (proxyRes, req) => {
+ if (req.url?.includes('/stream')) {
+ proxyRes.headers['cache-control'] = 'no-cache'
+ proxyRes.headers['x-accel-buffering'] = 'no'
+ }
+ })
+ },
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+})