diff --git a/docs/plans/admin-console.md b/docs/plans/admin-console.md index be4b6a6..7b93bbf 100644 --- a/docs/plans/admin-console.md +++ b/docs/plans/admin-console.md @@ -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 页面是坏的"的二进制。 diff --git a/docs/plans/admin-persistence-config.md b/docs/plans/admin-persistence-config.md index 96ec7c0..9b5dddc 100644 --- a/docs/plans/admin-persistence-config.md +++ b/docs/plans/admin-persistence-config.md @@ -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` 设计决策)。 diff --git a/docs/plans/site-routing.md b/docs/plans/site-routing.md index 34aefe0..e122275 100644 --- a/docs/plans/site-routing.md +++ b/docs/plans/site-routing.md @@ -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 超时。 diff --git a/docs/security.md b/docs/security.md index 8fbf80d..f3d8de3 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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