docs: trim the completed plans and record the sowerd rejected directions

admin-console.md and site-routing.md still carried the full design and
implementation checklists for work that shipped and was written back to
ARCHITECTURE.md. Keep only what cannot be recovered from the code -- the two
frontend constraints and the site-routing trade-offs -- and point at the
authoritative documents. Drop the plan-to-plan "Supersedes" reference from
admin-persistence-config.md so plans stop sourcing rules from other plans.

security.md gains the rejected directions those rewrites now point at:
wildcard site_routes domains (rejected in validateSiteRoutes, and the SNI
dispatch is an exact-match siteRouter.lookup), additional listen addresses or
configurable 80/443 ports, per-site certificate binding, and domain routing on
plain HTTP.
This commit is contained in:
wweir
2026-10-10 14:57:00 +08:00
parent ea243feb00
commit 512515db9b
4 changed files with 43 additions and 230 deletions
+4 -126
View File
@@ -1,131 +1,9 @@
# Admin Console Plan (MVP)
Status: 已实现;本文为历史计划,非权威来源。权威规则见根目录 `ARCHITECTURE.md`。
Scope: `cmd/sower`(客户端)管理控制台,前端 embed 进二进制
Superseded by: [admin-persistence-config.md](admin-persistence-config.md)(扩展了本文的 MVP 边界:规则持久化与配置页)。
## 目标
原 MVP 计划的后端设计(`RuleSet`、流量统计、admin HTTP 服务、`cmd/sower` 接线、构建与测试清单)已全部落地,并回写到 `ARCHITECTURE.md` 的 Layer Responsibilities、sower Data Flow 第 11–12 步与 Design Decisions,此处不再重复。仅保留两条仍在生效、且容易从代码复现不出来的前端约束:
给 `sower`(客户端)加一个本地 admin 管理页面:
- 执行管理规则:查看 / 添加 / 删除 block、direct、proxy 三类路由规则(运行期生效)
- 监控流量:DNS 查询数、各入口连接数、上下行字节数、按域名聚合的流量统计
- 前端为 Svelte 5 + Vite(由 bun 驱动),构建产物 embed 进 Go 二进制
## 范围与边界(MVP 明确不做)
- **只做 `cmd/sower`,不做 `sowerd`**。sowerd 是服务端 TLS 入口,没有规则与流量管理面。
- ~~**规则变更仅运行期生效,重启后重置**,不改写 TOML、不持久化、不静默暗示持久化。~~ **已被 [`admin-persistence-config.md`](./admin-persistence-config.md) 取代**:规则变更改为增量持久化到 admin 状态文件,重启后重放;配置页提供全量展示与白名单字段(`log_level`、`dns.upstream`、`dns.fallback`)在线调整。
- **监控的是代理 payload 字节与请求/连接计数**,不是网卡级网络计量。
- 不做配置热加载、不做规则文件管理(文件仍走启动时加载路径)。
## 配置
`config/sower.go` 新增 `[admin]` 段:
```toml
[admin]
disable = true # 默认禁用
addr = "127.0.0.1:19090" # 默认仅回环
password = "" # 为空时启动自动生成
```
校验规则:`disable=false` 时,`addr` 必须是合法 `host:port`;`password` 为空时启动自动生成一次性随机密码并打印到日志(已实现变更,见 `ARCHITECTURE.md`)。
## 后端设计
### 1. `router/ruleset.go` — 线程安全规则集
```go
type RuleSet struct {
mu sync.RWMutex
rules []string // 全部规则(配置内联 + 文件 + 运行期添加)的原始串
set map[string]struct{} // 去重键
tree *suffixtree.Node // 由 rules 派生的匹配树
}
```
- `Match`(RLock)、`List`(返回拷贝)、`Count`、`Add`(O(1) 去重,追加 + 入树,不立即 GC)、`Compact`(GC)、`Remove`(从列表删除 + 从 rules 全量重建树,重建即 GC)
- 启动批量加载沿用现有模式:逐条 `Add`,结束时一次 `Compact`,避免大规则文件 O(n²) GC
- 删除时从**保留的完整 rules 列表**重建树,保证"配置/文件规则 + 运行期增删"内部一致
- 迁移 `Router.BlockRule/DirectRule/ProxyRule` 为 `*RuleSet`,`dnsRuleMatch` 的 `Match(string) bool` 接口兼容,测试调用点机械替换
### 2. `internal/admin/stats.go` — 流量统计
- 聚合原子计数:`dnsQueries`、`httpConns`、`httpsConns`、`socksConns`、`bytesUp`、`bytesDown`
- 有界 per-domain 表(上限 100000):`domain → {conns, bytesUp, bytesDown, lastSeen, bySource, byClient}`;超限时按 lastSeen 批量淘汰(域名 1024/次、客户端 64/次),用固定大小堆单次扫描选出最旧批次,避免每插入一个新域名都全表扫描
- `WrapConn(conn) net.Conn`:**在协议解析前**包装客户端连接,先累计聚合字节(保证 HTTP 头 / TLS ClientHello 不被漏计),解析出域名后 `BindConn(conn, kind, domain)` 归属到域名并自增该域名连接数
- 方向语义(以客户端连接为参照):从客户端读到 = 上行(bytesUp),向客户端写入 = 下行(bytesDown)
- DNS 计数:`RecordDNS(qname, clientIP)`,由 cmd/sower 的 `dns.Handler` 装饰器调用,**不给 router 加 admin 回调字段**
- `Snapshot()` 返回不可变快照:锁内仅复制标量与域名/客户端明细,排序与 Top-N 截断在锁外完成(Top-N 用固定大小堆,避免为只展示 100/50 项而全量排序),缩短锁持有时间以降低对流量记账的阻塞
- 默认视图(bytes + all + 无客户端筛选)的 SSE 快照由 server 缓存一个 tick(TTL 与 5s tick 相等),多个默认流共享一次快照计算与一次 history 采样;筛选视图仍即时计算,避免无界按客户端缓存
### 3. `internal/admin/server.go` — HTTP 服务
依赖注入接口(便于测试与替换):
```go
type RuleManager interface {
RuleList(category Category) ([]string, error)
RuleAdd(category Category, rules ...string) error
RuleRemove(category Category, rule string) (bool, error)
RuleCount(category Category) uint64
}
```
API:
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/session` | 密码换取会话;进程内随机 token + HttpOnly/SameSite=Strict cookie |
| DELETE | `/api/session` | 登出,吊销 token |
| GET | `/api/status` | version/date/uptime/规则计数/监听器状态 |
| GET | `/api/rules?category=block\|direct\|proxy` | 列出某类规则 |
| POST | `/api/rules` | `{"category":..., "rules":[...]}` 添加 |
| DELETE | `/api/rules` | 同上语义删除(幂等) |
| GET | `/api/traffic` | 聚合计数 + 按域名流量 TopN |
| GET | `/` 及 SPA 路由 | 静态资源(embed FS),未匹配路径回退 index.html |
安全约束:
- 密码**不落 localStorage**;认证后由 HttpOnly cookie 携带
- 会话 token:`crypto/rand` 32 字节 hex,服务端 map 持有,24h 过期,惰性清理
- 密码比对用 SHA-256 摘要后 `subtle.ConstantTimeCompare`(长度无关恒定时间)
- 变更类请求(非 GET/HEAD)校验 Origin 同源,不一致 403
- JSON body 限额 64KB,`DisallowUnknownFields` 严格解码,类别白名单校验,单次规则条数与单条长度受限
- 静态资源与 API 同 server,优雅关闭走 `http.Server.Shutdown`
### 4. `cmd/sower` 接线
- `proxy.go`:三个 handler(HTTP/HTTPS/SOCKS5)在协议解析前 `stats.WrapConn(conn)`,解析出域名后 `BindConn`
- DNS:`dnsStatsHandler{dns.Handler, *admin.Stats}` 装饰器包 `dns.Server.Handler`
- `startAdminListener(ctx, cfg, r, stats, errCh)`:`disable` 时直接返回;否则建 listener + `srv.Serve`,ctx 取消时 `Shutdown`
- 版本/日期经 Options 注入
## 前端设计(`web/`)
- **UI 层用 shadcn-svelte 风格组件**:Tailwind CSS v4 + lucide-svelte,浅色主题(`main.ts` 不挂 dark 类)。组件手维护在 `src/lib/components/ui/`(button/card/input/textarea/label/badge/dropdown-menu/table/alert),原生 Svelte 5 实现,不依赖 bits-ui。不要用 `shadcn-svelte add` 覆盖这些文件(registry 产物会重新引入 bits-ui)。
- Svelte 5(runes)+ Vite,`bun install` / `bun run build` / `bun run check`(svelte-check)
- 视图:Login(Card + 密码 → POST /api/session)、Overview(卡片栅格)、Rules(按面包屑 category 上下文切换分类:列表 + 添加 + 删除)、Traffic(Table)
- **高级菜单面包屑**(参照 cyberguard 的 `AppBreadcrumbHeader`):品牌 → 当前分区(DropdownMenu 下拉列出全部页面,带图标/描述/当前态)→ 动态上下文段(Rules 的 category 段可下拉切换 block/direct/proxy;Overview 的 version 段、Traffic 的 scope 段为静态段);右侧收藏星标(localStorage 持久化)+ 页面菜单 + 状态徽章 + 退出。定位/键盘导航/外点关闭由手维护的 DropdownMenu(fixed + 原生焦点/键盘)承担。
- 有界轮询(3–5s),401 时回登录;loading/error/empty 三态齐全;不引图表库。已实现改为 SSE 实时流(`/api/stream`,会话续期/过期事件),图表用自研 `AreaChart`(`src/lib/components/AreaChart.svelte`)。
- `traffic.domains` 为空时服务端返回 `[]` 而非 `null`(Go nil slice 会序列化成 null,前端 `null.length` 会抛错);前端同时做 `?? []` 防御
- `web/embed.go`:`//go:embed all:dist` 导出 `fs.Sub(FS, "dist")`
- 构建产物 `web/dist` **提交进仓库**(go:embed 要求编译期文件存在,占位符会产生"能编译但 admin 页面是坏的"的二进制)
## 构建
`Makefile`:
- `build: web sower sowerd`;`sower` 目标依赖 `web`(`bun install --frozen-lockfile` + `bun run build`),`sowerd` 不依赖
- 保留独立 `web` 目标;`.gitignore` 增加 `web/node_modules`、`web/dist` 的排除处理(dist 需提交,故不 ignore)
## 测试清单
- Go:`RuleSet` 并发 Add/Remove/Match(-race);admin 认证 / 畸形 JSON / 非法 category / CRUD / 静态回退 / 关闭;stats 方向与字节核算 / 域名上限淘汰;config 校验
- 前端:`bun run check`、`bun run build`
- 全量:`go vet ./...`、`go test -race ./...`、`make build`
- 端到端:启动真实 admin listener,浏览器截图桌面/移动宽度验证渲染与控件无重叠
## 文档收尾
- 定稿后回写 `ARCHITECTURE.md`(admin 边界、数据流、设计决策)与 `README.md`(admin 用法);`[admin]` 段示例以 `config/sower.toml` 为准
- **不要用 `shadcn-svelte add` 覆盖 `web/src/lib/components/ui/`**:registry 产物会重新引入 bits-ui,而项目坚持手维护原生 Svelte 5 组件。UI 为 Tailwind v4 + lucide-svelte 浅色主题。
- **`web/dist` 必须提交进仓库**:`go:embed all:dist` 要求编译期文件存在,用占位符会产出"能编译但 admin 页面是坏的"的二进制。
+2 -2
View File
@@ -1,7 +1,7 @@
# Admin 规则持久化与配置页设计
Status: 已实现并部署验证(2026-08-07);本文为历史计划,非权威来源。权威规则见根目录 `ARCHITECTURE.md`。
Supersedes: `docs/plans/admin-console.md` 中的 MVP 边界「规则变更仅运行期生效,重启后重置,不改写 TOML、不持久化」
Supersedes: 早期 admin console MVP 计划的边界「规则变更仅运行期生效,重启后重置,不改写 TOML、不持久化」
Scope: `cmd/sower` admin 控制台
## 后续演进(超出本文 v1 范围,已实现)
@@ -174,4 +174,4 @@ PATCH /api/config
2. 规则持久化接通(RuleManager 适配层 + 启动顺序 + GC)+ 规则页保存状态/Undo/变更汇总;
3. `slog.LevelVar` 接线 + `router.SetDNS`;
4. `GET/PATCH /api/config` + 配置页(展示先行,编辑随后);
5. 文档:定稿后回写 `docs/plans/admin-console.md` 的范围说明(或沉淀为主设计文档)。
5. 文档:定稿后把规则与配置规则沉淀到主设计文档(`ARCHITECTURE.md` 设计决策)。
+15 -102
View File
@@ -1,116 +1,29 @@
# Plan: sowerd Site Routing by SNI
状态:已实现;本文为非权威历史计划。权威规则见根目录 `ARCHITECTURE.md`。
状态:已实现;本文为非权威历史计划。权威规则见根目录 `ARCHITECTURE.md`,安全规则见 `docs/security.md`。
## 背景与现状
`sowerd` 在 443 上对每条连接先探测 `sower` transport,不匹配时 fallback 到单一 `fake_site`:
- 若 `fake_site` 是本地目录:在 `127.0.0.1:80` 用 `http.FileServer` 服务,443 fallback 通过 `relay.RelayTo` 转发到 `127.0.0.1:80`。
- 若 `fake_site` 是 `host:port`:443 fallback 直接裸 TCP relay 到该地址。
该 fallback 语义是"单一伪装/兜底站点",无法按访问域名路由到不同上游。
本文只保留已定稿设计里**不容易从代码复现的取舍记录**;配置字段、校验分支与运行流程以代码和 `ARCHITECTURE.md` 为准。
## 目标
将 443 的 fallback 从单一目标扩展为按 TLS SNI 路由:
- 配置若干 `site_routes`,每条映射一组精确域名到一个 upstream URL(`http://` 或 `https://`)。
- 命中某 route 的连接转发到对应 upstream。
- 未命中任何 route 的连接走配置的 `fake_site` 兜底;当 `fake_site` 是目录时,由本地 fileserver 服务。
将 443 的 fallback 从单一 `fake_site` 扩展为按 TLS SNI 路由:若干 `site_routes` 把一组精确域名映射到 upstream URL,未命中回落 `fake_site`。
## 非目标
- 不动 `sower` 认证流量的目标路由(由 transport 帧解码决定,与域名无关)。
- 不支持通配域名(`*.example.com`)。精确匹配即可。
- 不在 `sower` 客户端侧实现;本功能仅限 `sowerd`。
- HTTP(:80)请求只做重定向到 HTTPS,不参与 site routing。
- 不支持通配域名(`*.example.com`),只做精确匹配;拒绝理由见 `docs/security.md` 的 "Rejected Directions"。
- 不在 `sower` 客户端侧实现。
- HTTP(:80)不参与 site routing(拒绝理由见 `docs/security.md`)。
## 设计
## 关键取舍
### 配置
- **精确 SNI 匹配,不做通配**:SNI 路由是精确 map 命中,通配要么让所有子域塌到同一 upstream(失去按域分流的意义),要么在边缘最热路径上引入优先级歧义。
- **用 reverse proxy 而非裸 TCP relay**:只有交给 `http.Server` 解析 HTTP 才能拿到路径做前缀分流,并统一重写 outbound `Host` 与转发头。代价是每条连接多一层 HTTP 解析。
- **单连接 listener 模式**:把 `rereadConn` 包装成只 `Accept` 一次的 `net.Listener` 交给 `http.Server.Serve`,让标准库处理请求生命周期,而不是自写 HTTP 解析循环。
- **HTTP/1.1-only 是有意决策**:`NextProtos` 只留 `http/1.1`;advertise h2 后 WebSocket 需要 RFC 8441 extended CONNECT,stdlib 不支持。详细证据与 per-SNI ALPN 替代方案被拒的理由见 `docs/security.md` 的 "Rejected Directions"。
- **未配置 `site_routes` 时行为与改造前完全一致**:`fake_site` 字段保留,语义不变(本地目录或 `host:port`)。
`config/sowerd.go` 新增:
## 已推翻的实现决策
```go
type SiteRoute struct {
Domains []string // 精确域名,小写归一化
Upstream string // 完整 URL,如 http://127.0.0.1:8080 或 https://backend.example.com
}
type SowerdConfig struct {
// ...现有字段...
SiteRoutes []SiteRoute
FakeSite string // 保留,作为未命中时的最终兜底
}
```
`Validate` 约束:
- `Upstream` 必须是合法 URL,scheme 仅允许 `http`/`https`。
- `Domains` 非空,每项小写归一化,不含通配符 `*`,不重复。
- 同一域名不得出现在多条 route 中(重复即配置错误,启动失败)。
- `FakeSite` 语义不变:未命中 route 时使用;仍要求是已存在目录或 `host:port`。
示例(TOML):
```toml
fake_site = "/var/www"
[[site_routes]]
domains = ["a.example.com", "b.example.com"]
upstream = "http://127.0.0.1:8080"
[[site_routes]]
domains = ["c.example.com"]
upstream = "https://backend.example.com"
```
### 匹配规则
- 构建 `map[string]*url.URL`:SNI → upstream。
- `handleConn` 中两个 transport 探测失败后,取 `(*tls.Conn).ConnectionState().ServerName`,小写归一化后查 map。
- 命中:用 `httputil.ReverseProxy` 转发到 upstream URL。
- 未命中:走现有 `fakeSite` 兜底(本地目录时 relay 到 `127.0.0.1:80` 的 fileserver)。
### 证书
- autocert 模式(未配置 `cert.cert`):`autocert.Manager.GetCertificate` 已按 SNI 自动申请,多域名无需改动。
- 自定义 cert 模式:单一证书只覆盖配置的 SAN,多域名路由需自行提供覆盖所有域名的多 SAN 证书;否则非证书覆盖域名的 TLS 握手会失败。文档需声明此约束。
### 443 fallback 流程改动
当前 fallback 用 `relay.RelayTo(rereadConn, fakeSite)` 裸 TCP 转发。新流程:
1. transport 探测失败后,`rereadConn.Stop().Reread()` 重置字节流到起点。
2. 取 SNI。
3. 若 SNI 命中 route:
- 构造 `httputil.NewSingleHostReverseProxy(targetURL)`。
- 用单连接 listener 模式(把 `rereadConn` 包装成一次性 `net.Listener`)交给 `http.Server{Handler: reverseProxy}.Serve(listener)`,让标准库解析 HTTP 请求并反向代理。
4. 若未命中:保持现有 `relay.RelayTo(rereadConn, fakeSite)` 行为不变。
单连接 listener 模式:实现一个只 `Accept` 一次 `rereadConn`、之后阻塞直到连接关闭的 `net.Listener`,供 `http.Server.Serve` 消费。
### HTTP/2 限制
当前 `NextProtos = ["http/1.1", "h2"]`。fallback 的 reverse proxy 路径在 MVP 仅支持 HTTP/1.1(`http.Server` 默认不启用 h2,启用需额外 `http2.ConfigureServer`)。MVP 决策:将 `NextProtos` 改为 `["http/1.1"]`,简化 fallback 行为,避免协商到 h2 后 reverse proxy 不支持的错配。
影响:fallback/伪装站点仅 HTTP/1.1;`sower` transport 不走 ALPN,不受影响。
## 兼容性
- 未配置 `site_routes` 时,行为与当前完全一致(单一 `fake_site` 兜底)。
- `fake_site` 字段保留,语义不变:可以是本地目录,也可以是 `host:port`。
## 实现决策
- reverse proxy 会把 outbound `Host` 显式改写为 upstream Host,路径和查询参数保留。
- reverse proxy 会拒绝 HTTP upgrade 请求,避免 fallback 连接被 hijack 后生命周期不可控。
- reverse proxy 设置客户端 header、upstream dial、TLS handshake 和 upstream response header 超时。
## 实现清单
1. `config/sowerd.go`:新增 `SiteRoute`、`SiteRoutes`,`Validate`,示例配置字符串。
2. `config/sowerd_test.go`:覆盖 Validate 各分支。
3. `cmd/sowerd`:新增 site router(map + 查询)与单连接 listener。
4. `cmd/sowerd/main.go`:`handleConn` fallback 分支接入 router + reverse proxy;`NextProtos` 改为 `http/1.1`。
5. `cmd/sowerd/main_test.go`:SNI 命中/未命中/无 routes 兼容的集成测试(用本地 dummy upstream listener)。
6. `ARCHITECTURE.md`:定稿后回写 sowerd data flow 与 design decisions。
7. `docs/README.md`:索引更新。
- ~~reverse proxy 会拒绝 HTTP upgrade 请求~~ — **已推翻**:现行实现不做任何拒绝,`httputil.ReverseProxy` 在 101 时经 hijack 直通,`TestReverseProxyConnWebSocket` 与 `TestReverseProxyConnPathRoutedWebSocket` 均通过,见 `ARCHITECTURE.md`。
- reverse proxy 会把 outbound `Host` 显式改写为 upstream Host,路径和查询参数保留;并设置客户端 header、upstream dial、TLS handshake、upstream response header 超时。
+22
View File
@@ -103,6 +103,28 @@ Decisions recorded here so they are not re-litigated without new evidence:
(golang/go#71128). The per-SNI ALPN workaround (h1-only for WS-bearing
routes) was declined: it makes "new WS routes must opt out of h2" a silent
failure mode on an anti-censorship edge.
- **Wildcard `site_routes` domains** (`*.example.com`) — rejected in
`config/sowerd.go` (`validateSiteRoutes`) and must stay rejected. SNI
dispatch is an exact-match map (`siteRouter.lookup`); a wildcard either
collapses every subdomain onto one upstream — losing the per-domain
routing it was meant to add — or needs priority/ambiguity rules on the
edge's hottest path. `autocert.HostWhitelist` is exact-match too, so
wildcard issuance would additionally force a hand-written `HostPolicy`.
- **Additional listen addresses and configurable 80/443 ports** — one
`serve_ip` and hardcoded 80/443 are deliberate: an edge listening on a
non-standard port is itself an active-probing signal. Multiple public
addresses are served by one instance per address, not by widening this
listener. `serve_ip` may be a concrete address to bind a single interface.
- **Per-site certificate binding** — the edge's certificate set is
independent of the `site_routes` table (autocert issues for the union of
routed domains plus `cert.domains`; static mode serves one pair for every
SNI). Keeping it independent means a prober cannot enumerate configured
sites from the certificate. Multi-SAN certificates already cover the
legitimate multi-domain case.
- **Domain routing on plain HTTP (:80)** — the :80 listener only completes
ACME challenges and 308-redirects public traffic to HTTPS; the `fake_site`
directory is served there on loopback only (`isLocalRemoteAddr`). Routing
:80 by domain would hand out a cleartext bypass of the TLS edge.
## Residual Accepted Signals