From 4d2deac0d7dfd978808cadbb89d0568204d16b54 Mon Sep 17 00:00:00 2001 From: Letian Lin Date: Fri, 22 May 2026 13:29:46 +0800 Subject: [PATCH 01/25] docs: add Portal Dashboard implementation plan - Architecture: claimer pattern (TCP port as distributed lock), reverse proxy + loopback bypass - Security: CSP with hash-based script/style whitelist, CSRF double-submit cookie, HttpOnly token cookie, rate limiting - Features: global auth token config, dashboard HTML, tailscale serve auto-management - Config: auth.json (atomic write, Load() distinguishes file-not-found vs JSON corruption) - Auth: POST /api/auth returns 400 when token not configured; /api/logout requires CSRF token - Cookie: mw_token 24h sliding expiration via Set-Cookie on each authenticated request - Edge cases: Portal SPOF 10-15s failover window, Serve unexpected exit with Warn log --- .../portal-dashboard-plan.md | 643 ++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100644 docs/plans/feature-improve-remote-access/portal-dashboard-plan.md diff --git a/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md b/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md new file mode 100644 index 0000000..1b4dbd6 --- /dev/null +++ b/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md @@ -0,0 +1,643 @@ +# Portal Dashboard - 实施方案 + +## 状态 + +**阶段**: 规划中(等待实施) + +--- + +## 背景与动机 + +当前 myworktree 服务默认只绑定 `127.0.0.1:0`,只能从本机访问。用户希望: + +1. 通过局域网 IP(如 `192.168.1.18`)访问服务 +2. 通过 Tailscale 安全地将服务暴露到外网 +3. 无需手动记录端口,即可管理多个不同仓库的运行实例 + +--- + +## 设计目标 + +1. **局域网访问**:绑定 `0.0.0.0`,所有网口均可访问 +2. **安全性**:非 loopback 地址必须携带 token(非 loopback 无 token 则拒绝启动) +3. **全局 Token**:配置一次,所有实例自动继承,实例级别可按需覆盖 +4. **Portal 仪表板**:共享的入口端口,列出所有运行中的实例并可点击跳转 +5. **Token 安全**:Token 存储在 HttpOnly Cookie 中,JS 和 URL 均不可见 +6. **零配置**:仪表板自动发现运行中的实例,无需人工追踪 +7. **Tailscale HTTPS**:自动为 Portal 配置 `tailscale serve`,零手动管理 + +--- + +## 架构 + +### 目录结构 + +``` +~/.config/myworktree/ +├── auth.json ← 全局 auth token(独立于 LLM 的 config.json) +├── / +│ └── server.json ← { listen_port, instance_id } +├── / +│ └── server.json +└── portal/ ← 共享注册目录 + ├── .json ← 各实例写入自己的注册信息 + └── portal.json ← 当前 portal 持有者(instance_id + port) +``` + +### 文件变更清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `internal/config/global.go` | **新增** | 读写 `~/.config/myworktree/auth.json`(auth token 独立于 LLM 的 `config.json`) | +| `internal/config/global_test.go` | **新增** | 测试 | +| `internal/portal/portal.go` | **新增** | 核心:抢占者、注册、Portal HTTP 服务、反向代理、Tailscale serve 管理 | +| `internal/portal/dashboard.html` | **新增** | 仪表板 HTML(go:embed 内嵌) | +| `internal/portal/portal_test.go` | **新增** | 测试 | +| `internal/app/app.go` | **修改** | Config 扩展、server.json 字段、portal 生命周期集成 | +| `internal/cli/cli.go` | **修改** | `--portal-port` flag、全局 token 自动填充、交互式 `config` 子命令 | + +--- + +## 详细设计 + +### 1. 全局配置 + +**文件**: `~/.config/myworktree/auth.json`(独立于 LLM 的 `config.json`,避免字段冲突) + +```json +{ + "auth_token": "your-global-secret-token" +} +``` + +**存储策略**: + +- `auth_token` 字段存储明文(供反向代理转发、Cookie 设置、`/api/auth` 登录校验,以及实例 `withAuth` 中间件做字符串比较) +- **仅明文存储**——不引入 bcrypt 哈希。由于 `auth_token` 明文同时用于 `withAuth` 中间件字符串比较、反向代理转发和 Cookie 设置,引入哈希并不能消除明文存储需求;`auth_token_hash` 的安全价值微乎其微(攻击者若能读取文件即可同时获得明文)。接受此风险,缓解措施为严格的 `0o600` 文件权限 + +**Token 强度建议**:推荐使用 `openssl rand -hex 32` 生成 64 字符(256-bit)随机 token。用户也可使用 `pwgen -s 32 1` 或密码管理器生成。避免使用字典单词或短密码。 + +**文件**: `internal/config/global.go` + +定义 `GlobalConfig` 结构体,包含 `auth_token` 字符串字段(JSON tag)。提供两个导出函数:`Load()` 从 `~/.config/myworktree/auth.json` 读取并解析 JSON 返回配置指针和 error;`Save(cfg)` 将配置序列化为 JSON 写入 `auth.json`。 + +**`Load()` 错误处理策略**: +- 若文件不存在(`os.IsNotExist`),返回零值空配置 + `nil` error(不报错,这是正常情况:用户从未配置过 Token) +- 若 JSON 解析失败,返回零值空配置 + 非 nil error(包装原始解析错误,如 `fmt.Errorf("config: failed to parse auth.json: %w", err)`),让调用方决定如何处理错误 +- 在任何情况下都不 panic + +**调用方处理**(`startCmd` 中的自动填充逻辑):调用 `config.Load()` 时根据返回值区分: +- error 为 nil + `AuthToken` 非空 → 使用加载的 Token ✓ +- error 为 nil + `AuthToken` 为空 → Token 未配置,保留空值 ✓ +- error 非 nil → **auth.json 存在但已损坏**,打印 Warn 日志(`[config] auth.json is corrupted: %v`)后保留空值。注意:若 `--listen` 绑定非 loopback 且 auth 为空,后续 `validateSecurity()` 会拒绝启动并提示 `--auth is required`,此时用户可结合 Warn 日志定位到 auth.json 损坏的问题。若 `--listen` 绑定 loopback,则不做特殊处理(loopback 访问本身无需 token) + +**原子写入**:`Save()` 必须使用「写临时文件 → rename」模式(与 `internal/llm/config.go` 一致),避免进程崩溃时文件损坏。 + +**交互式 Config 命令**(`mw config` 无参数时进入引导流程): + +引导流程展示一个菜单,用户选择: +- 选项 1:设置全局 Token —— 提示用户输入 Token(输入时字符隐藏),再提示确认输入,两次一致后保存 +- 选项 2:查看当前 Token —— 以掩码形式显示(仅显示前 4 位和后 4 位,中间用星号替代) +- 选项 3:清除全局 Token —— 将 `auth_token` 设为空字符串并保存 +- 选项 q:退出 + +子命令: +- `mw config set-auth`:进入交互式设置流程(输入 + 确认) +- `mw config get-auth`:直接输出当前 Token(掩码形式) +- `mw config clear-auth`:直接清除(无需二次确认) + +**子命令路由设计**:`mw config`(无参数)进入上述交互式引导。`mw config set-auth`、`mw config get-auth`、`mw config clear-auth` 在 `Run()` 中新增 `case "config":` 分支处理,该分支再根据 `args[2]` 分发到对应的处理函数。 + +**自动填充逻辑**(在 `startCmd` 中):解析完 `--auth` flag 后,若其值为空字符串,则从全局配置 `config.Load()` 读取。处理逻辑: +- `Load()` 返回 error 为 nil 且 `AuthToken` 非空 → 赋值给 auth 变量 +- `Load()` 返回 error 为 nil 且 `AuthToken` 为空 → 保留 auth 为空(Token 未配置,正常情况) +- `Load()` 返回 error 非 nil → 打印 Warn 日志 `[config] auth.json is corrupted: `,保留 auth 为空。此时若 `--listen` 绑定非 loopback 地址,`validateSecurity()` 会拒绝启动并提示 `--auth is required`,用户可结合 Warn 日志定位到 auth.json 损坏问题 + +--- + +### 2. Auth 中间件改造(Loopback 始终放行) + +**文件**: `internal/app/app.go` + +修改 `withAuth`,对 loopback 请求跳过 token 校验,无论 token 来源是全局配置还是 `--auth` 参数。 + +`withAuth` 中间件改造后的执行流程(按顺序): + +1. **AuthToken 为空**:若配置中未设置 token,直接放行所有请求(保留现有行为)。 +2. **Loopback 检查**:调用 `isLoopbackRequest(r)` 判断请求来源是否为回环地址(`127.0.0.1`、`localhost`、`::1`)。若为 loopback,直接放行——跳过 Origin 同源校验和 token 校验。这是实现反向代理认证绕过的基础。 +3. **Origin 同源校验**(仅非 loopback):调用 `sameOriginHost(r)` 比较请求头 `Origin` 与 `Host`。若 Origin 为空(浏览器未发送)则放行;若 Origin 的 Host 部分与请求 Host 不匹配则返回 403。注意:此校验依赖浏览器诚实地发送 Origin 头,非浏览器客户端(curl 等)可伪造,因此仅作为纵深防御层而非独立安全边界。 +4. **Token 提取**:按以下优先级从请求中提取 token:(a) `Authorization: Bearer ` 请求头;(b) URL 查询参数 `?token=`;(c) `mw_token` Cookie(新增的 Cookie 来源)。取第一个非空值。 +5. **Token 比对 + 速率限制**:将提取的 token 与配置中的 `AuthToken` 做字符串明文比较。若不匹配,记录该 IP 的失败次数(每 IP 每分钟最多 20 次),超出则返回 429,未超出则返回 401。若匹配成功,清除该 IP 的失败计数并放行请求。 + +**Token 来源**(调整后的 `withAuth` 校验顺序): + +1. `Authorization: Bearer ` 请求头 +2. `?token=` URL 查询参数 +3. `mw_token` Cookie(新增) + +**行为矩阵**: + +| 来源 IP | AuthToken 来源 | 结果 | +|---------|---------------|------| +| 127.0.0.1 / localhost | 任意来源 | **放行**(无需 token) | +| 非 loopback | 全局配置 | **需要 token** | +| 非 loopback | `--auth` 参数 | **需要 token** | + +--- + +### 3. Portal 包(`internal/portal/portal.go`) + +#### Portal 配置 + +`portal.Config` 结构体包含以下字段: +- `PortalPort`:抢占的目标端口(整数,默认 12345;设为 0 表示禁用 Portal,不启动抢占、不注册) +- `Host`:监听地址(来自主服务的 listen host) +- `AuthToken`:全局或进程级的认证 token +- `RegistryDir`:注册目录路径(`~/.config/myworktree/portal/`) +- `DataDir`:当前实例数据目录路径(`~/.config/myworktree//`) +- `RepoName`:仓库显示名称 +- `RepoHash`:仓库 hash,用于构造反向代理路径 + +`portal.Portal` 结构体包含以下关键字段: +- `cfg`:上述 `Config` 配置 +- `instanceID`:格式为 `pid-timestamp-rand` 的唯一实例标识符,在 `Start()` 时生成,生命周期内不变 +- `mu`:`sync.Mutex`,保护以下 `srv` 和 `ln` 字段的并发读写(claimerLoop 写入,Stop 读取,避免 data race) +- `srv`:`*http.Server`,Portal HTTP 服务实例(仅当前持有者非 nil) +- `ln`:`net.Listener`,portal 端口监听器(仅当前持有者非 nil) +- `done`:`chan struct{}`,关闭信号,通知所有协程退出 +- `wg`:`sync.WaitGroup`,等待所有协程完全退出 +- `closeOnce`:`sync.Once`,确保 `Stop()` 只执行一次 + +#### 生命周期 + +**`Start()` 方法**: +1. 生成 `instanceID`(格式 `pid-timestamp-rand`),全生命周期不变 +2. 增加 `WaitGroup` 计数(用于 `claimerLoop` 和 `tailscaleServeLoop` 两个 goroutine) +3. 将实例注册信息写入 `portal/.json` +4. 启动 `claimerLoop` goroutine +5. 启动 `tailscaleServeLoop` goroutine +6. 返回 + +**`Stop()` 方法**(通过 `sync.Once` 保证只执行一次): +1. 关闭 `done` channel,通知所有 goroutine 退出 +2. 加 `mu` 锁检查 `srv` 是否为 nil,若非 nil 则调用 `Shutdown(ctx)` 优雅关闭(5 秒超时排空活跃连接),完成后将 `srv` 和 `ln` 置 nil +3. 调用 `stopTailscaleServe()` 清理 tailscale serve +4. 删除注册文件(`portal/.json`,以及若本进程是持有者则删除 `portal/portal.json`) +5. 调用 `wg.Wait()` 等待所有 goroutine 完全退出 + +**`claimerLoop` goroutine**: +1. 初始随机延迟 0~5 秒(`rand.Intn(5000)` 毫秒),避免多实例同时启动时的惊群效应 +2. 进入死循环:尝试 `net.Listen` 抢占 portal 端口。成功则获取 `mu` 锁设置 `ln` 和 `srv`(创建 HTTP Server 并配置路由),写入 `portal.json` 声明自己为持有者,然后阻塞在 `srv.Serve(ln)` 直到 `Shutdown()` 被调用或意外错误。`Shutdown` 完成后获取 `mu` 锁将 `srv` 和 `ln` 置 nil。 +3. 若 `net.Listen` 失败(端口已被占用),等待 10~15 秒随机间隔(`10s + rand.Intn(5000)ms`)后重试。随机抖动避免多个失败者同步唤醒同时重试。 +4. 每次循环开始时检查 `done` channel,若已关闭则立即退出。 +5. **Serve 意外退出的影响**:若 `srv.Serve()` 因 `http.ErrServerClosed` 以外的错误返回(如监听器异常关闭、系统资源耗尽),`ln` 将被关闭,下一个循环迭代中将重新绑定端口。在本次迭代的 `Serve()` 退出到下次迭代 `net.Listen` 成功之间的窗口期内(含 10~15 秒退避),Portal 不可用——反向代理返回 502、仪表板不可达、所有通过 Portal 的请求中断。`tailscaleServeLoop` 不受影响(30 秒定时检查感知到 Portal 离线后不会错误清理 tailscale serve)。**缓解措施**:`Serve()` 返回意外错误时,立即输出 Warn 日志(`[portal] HTTP serve exited unexpectedly: %v`),让用户感知到 Portal 中断,同时依靠 10~15 秒自动恢复窗口 + +> **已知边界情况——instance_id 与 server.json 的非原子性**:`portal.Start()` 生成 `instanceID` 并立即写入 `portal/.json` 注册文件,但 `server.json` 中的 `instance_id` 由 `app.go` 独立、异步写入(写入时机为 `net.Listen` 成功后)。若进程在两者之间崩溃,注册文件存在但 `server.json` 中无对应 `instance_id`。此不一致在以下场景中被自动修复: +> - Portal 活性校验(PID + TCP + instance_id 匹配)可检测并过滤该条注册文件 +> - 下一轮清理周期(5 分钟)会删除无法验证存活的无效注册文件 +> - 进程下次启动时会重新生成 instanceID 并写入 `server.json` + +#### 注册文件结构 + +**`~/.config/myworktree/portal/.json`**(各实例写入,instance-id 格式为 `pid-timestamp-rand`,稳定唯一): + +包含字段:`instance_id`(实例唯一标识)、`pid`(进程 ID)、`port`(实例监听端口)、`host`(监听地址)、`repo_name`(仓库名)、`repo_hash`(仓库 hash)、`started_at`(启动时间 ISO 8601 格式)。注册文件中**不包含 auth_token**,token 仅保存在进程内存和 `server.json` 中。 + +当 `--portal-port` 设置为 0(禁用 Portal)时,实例不写入此注册文件,也不参与抢占。 + +**`~/.config/myworktree/portal/portal.json`**(当前 portal 持有者写入,**原子写入**:写临时文件 → rename,避免并发写损坏): + +包含字段:`instance_id`(当前持有者的实例标识)、`port`(Portal 端口)、`updated_at`(更新时间 ISO 8601 格式)。 + +#### 仪表板端点 + +| 端点 | 方法 | 认证 | 说明 | +|------|------|------|------| +| `GET /` | | 无 | 仪表板 HTML 页面。响应头包含 `Content-Security-Policy: default-src 'self'; script-src 'sha256-' 'sha256-' ...; style-src 'self' 'sha256-' 'sha256-' ...`。所有 `` 均为对应内联 ` + + \ No newline at end of file diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 514a9b1..0959a5d 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -2,6 +2,7 @@ package portal import ( "context" + _ "embed" cryptorand "crypto/rand" "encoding/hex" "encoding/json" @@ -14,12 +15,21 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "sync" "syscall" "time" ) +//go:embed dashboard.html +var DashboardHTML []byte + +var cspHashes = [][2]string{ + {"script", "sha256-placeholder"}, + {"style", "sha256-placeholder"}, +} + type Config struct { PortalPort int InstancePort int @@ -343,7 +353,35 @@ func (p *Portal) writePortalStatus() { } func (p *Portal) handleDashboard(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Dashboard")) + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + var scriptHashes []string + var styleHashes []string + for _, h := range cspHashes { + if h[0] == "script" { + scriptHashes = append(scriptHashes, h[1]) + } else if h[0] == "style" { + styleHashes = append(styleHashes, h[1]) + } + } + + csp := "default-src 'self'; script-src" + for _, h := range scriptHashes { + csp += " " + h + } + csp += "; style-src" + for _, h := range styleHashes { + csp += " " + h + } + w.Header().Set("Content-Security-Policy", csp) + + w.Write(DashboardHTML) } func (p *Portal) handleCSRFToken(w http.ResponseWriter, r *http.Request) { @@ -394,6 +432,12 @@ func (p *Portal) handleAuth(w http.ResponseWriter, r *http.Request) { return } + ip := getIP(r) + if !p.csrfState.allowAuthAttempt(ip) { + http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) + return + } + var req struct { Token string `json:"token"` CSRFToken string `json:"csrf_token"` @@ -476,6 +520,10 @@ func (p *Portal) handleList(w http.ResponseWriter, r *http.Request) { } } + sort.Slice(processes, func(i, j int) bool { + return processes[i]["repo_name"].(string) < processes[j]["repo_name"].(string) + }) + json.NewEncoder(w).Encode(map[string]interface{}{ "is_portal": p.isPortalHolder(), "portal_port": p.cfg.PortalPort, @@ -522,6 +570,9 @@ func (p *Portal) handleLogout(w http.ResponseWriter, r *http.Request) { SameSite: http.SameSiteLaxMode, HttpOnly: true, } + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + cookie.Secure = true + } http.SetCookie(w, cookie) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) @@ -669,17 +720,19 @@ func isProcessAlive(pid int, port int) bool { } type csrfState struct { - mu sync.Mutex - pending map[string]time.Time - used map[string]time.Time + mu sync.Mutex + pending map[string]time.Time + used map[string]time.Time rateLimits map[string]time.Time + authLimits map[string][]time.Time } func newCSRFState() *csrfState { return &csrfState{ pending: make(map[string]time.Time), - used: make(map[string]time.Time), + used: make(map[string]time.Time), rateLimits: make(map[string]time.Time), + authLimits: make(map[string][]time.Time), } } @@ -778,4 +831,40 @@ func (s *csrfState) cleanup() { delete(s.rateLimits, k) } } + for k, times := range s.authLimits { + var remaining []time.Time + for _, t := range times { + if t.After(cutoff) { + remaining = append(remaining, t) + } + } + if len(remaining) == 0 { + delete(s.authLimits, k) + } else { + s.authLimits[k] = remaining + } + } +} + +func (s *csrfState) allowAuthAttempt(ip string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.authLimits) >= 10000 { + return false + } + + cutoff := time.Now().Add(-1 * time.Minute) + var newTimes []time.Time + for _, t := range s.authLimits[ip] { + if t.After(cutoff) { + newTimes = append(newTimes, t) + } + } + if len(newTimes) >= 20 { + return false + } + newTimes = append(newTimes, time.Now()) + s.authLimits[ip] = newTimes + return true } \ No newline at end of file From 7663d8ac653e2b6435d46ac74f384e89accbf28f Mon Sep 17 00:00:00 2001 From: Letian Lin Date: Fri, 22 May 2026 20:21:35 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat(portal):=20Task=207=20=E2=80=94=20re?= =?UTF-8?q?verse=20proxy=20with=20bidirectional=20WebSocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strip /s// prefix before forwarding to target instance - Validate repo-hash format (lowercase hex only) to prevent path traversal - Set proxy.ErrorLog to logWriter for 502 errors with repo_hash context - Add integration tests: auth required (401), invalid hash (400), not found (502), sliding cookie, WebSocket bidirectional message exchange - WebSocket test: waitForPort retry loop instead of time.Sleep - wsWriteFrame: fix mask key offset and extended payload length field --- internal/portal/portal.go | 24 ++- internal/portal/portal_test.go | 328 +++++++++++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 1 deletion(-) diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 0959a5d..5329b12 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -599,10 +599,32 @@ func (p *Portal) handleProxy(w http.ResponseWriter, r *http.Request) { } proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", port)}) - r.URL.Host = fmt.Sprintf("127.0.0.1:%d", port) + originalPath := r.URL.Path + proxy.Director = func(req *http.Request) { + req.URL.Scheme = "http" + req.URL.Host = fmt.Sprintf("127.0.0.1:%d", port) + strippedPath := strings.TrimPrefix(originalPath, "/s/"+repoHash) + if !strings.HasPrefix(strippedPath, "/") { + strippedPath = "/" + strippedPath + } + req.URL.Path = strippedPath + req.URL.RawPath = "" + } + proxy.ErrorLog = log.New(&logWriter{repoHash: repoHash, port: port}, "", 0) + proxy.ServeHTTP(w, r) } +type logWriter struct { + repoHash string + port int +} + +func (l *logWriter) Write(p []byte) (int, error) { + log.Printf("[portal] reverse proxy: dial 127.0.0.1:%d (repo_hash=%q) failed: %s", l.port, l.repoHash, strings.TrimSpace(string(p))) + return len(p), nil +} + func (p *Portal) checkAuth(r *http.Request) bool { token := extractToken(r) if token == "" { diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index dbedcf6..a3d679b 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -1,10 +1,15 @@ package portal import ( + "context" "encoding/json" + "fmt" + "net" "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -403,4 +408,327 @@ func TestPortalMuConcurrentAccess(t *testing.T) { } wg.Wait() +} + +func TestHandleProxy_AuthRequired(t *testing.T) { + tmpDir := t.TempDir() + cfg := Config{ + PortalPort: 12345, + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoHash: "abc123", + } + p := New(cfg) + + req := httptest.NewRequest("GET", "/s/abc123/", nil) + rec := httptest.NewRecorder() + p.handleProxy(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } +} + +func TestHandleProxy_InvalidRepoHash(t *testing.T) { + tmpDir := t.TempDir() + cfg := Config{ + PortalPort: 12345, + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoHash: "abc123", + } + p := New(cfg) + + tests := []struct { + path string + expectStatus int + }{ + {"/s/../etc/passwd", http.StatusBadRequest}, + {"/s/g..g/", http.StatusBadRequest}, + {"/s/ABCDEF/", http.StatusBadRequest}, + } + + for _, tt := range tests { + req := httptest.NewRequest("GET", tt.path, nil) + req.AddCookie(&http.Cookie{Name: "mw_token", Value: "test-token"}) + rec := httptest.NewRecorder() + p.handleProxy(rec, req) + + if rec.Code != tt.expectStatus { + t.Errorf("path %q: expected %d, got %d", tt.path, tt.expectStatus, rec.Code) + } + } +} + +func TestHandleProxy_InstanceNotFound(t *testing.T) { + tmpDir := t.TempDir() + cfg := Config{ + PortalPort: 12345, + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoHash: "abc123", + } + p := New(cfg) + + req := httptest.NewRequest("GET", "/s/abc123/", nil) + req.AddCookie(&http.Cookie{Name: "mw_token", Value: "test-token"}) + rec := httptest.NewRecorder() + p.handleProxy(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", rec.Code) + } +} + +func TestHandleProxy_SlidingCookie(t *testing.T) { + tmpDir := t.TempDir() + cfg := Config{ + PortalPort: 12345, + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoHash: "slide123", + } + p := New(cfg) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skip("skipping test: port not available") + } + defer ln.Close() + instancePort := ln.Addr().(*net.TCPAddr).Port + + reg := registration{ + InstanceID: p.instanceID, + PID: os.Getpid(), + Port: instancePort, + RepoHash: "slide123", + } + data, _ := json.Marshal(reg) + os.WriteFile(filepath.Join(tmpDir, p.instanceID+".json"), data, 0o600) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + conn.Write(buf[:n]) + conn.Close() + } + }() + + req := httptest.NewRequest("GET", "/s/slide123/api/list", nil) + req.AddCookie(&http.Cookie{Name: "mw_token", Value: "test-token"}) + rec := httptest.NewRecorder() + p.handleProxy(rec, req) + + cookies := rec.Result().Cookies() + var found bool + for _, c := range cookies { + if c.Name == "mw_token" && c.MaxAge > 0 { + found = true + break + } + } + if !found { + t.Fatal("expected sliding auth cookie to be set") + } +} + +func waitForPort(host string, port int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 50*time.Millisecond) + if err == nil { + conn.Close() + return true + } + time.Sleep(10 * time.Millisecond) + } + return false +} + +func wsHandshake(conn net.Conn, path string, key string) error { + req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nCookie: mw_token=test-token\r\n\r\n", path, key) + _, err := conn.Write([]byte(req)) + if err != nil { + return err + } + buf := make([]byte, 1024) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := conn.Read(buf) + if err != nil { + return err + } + resp := string(buf[:n]) + if !strings.Contains(resp, "101") { + return fmt.Errorf("handshake failed: %s", resp[:min(200, len(resp))]) + } + return nil +} + +func wsReadFrame(conn net.Conn) (opcode int, payload []byte, err error) { + header := make([]byte, 2) + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, err = conn.Read(header) + if err != nil { + return 0, nil, err + } + opcode = int(header[0]) & 0x0f + masked := (header[1] & 0x80) != 0 + payloadLen := int(header[1]) & 0x7f + if payloadLen == 126 { + ext := make([]byte, 2) + conn.Read(ext) + payloadLen = int(ext[0])<<8 | int(ext[1]) + } else if payloadLen == 127 { + ext := make([]byte, 8) + conn.Read(ext) + payloadLen = 0 + for _, b := range ext { + payloadLen = payloadLen<<8 + int(b) + } + } + maskKey := make([]byte, 4) + if masked { + conn.Read(maskKey) + } + payload = make([]byte, payloadLen) + conn.Read(payload) + if masked { + for i := range payload { + payload[i] ^= maskKey[i%4] + } + } + return opcode, payload, nil +} + +func wsWriteFrame(conn net.Conn, opcode int, payload []byte) error { + maskKey := []byte{0x12, 0x34, 0x56, 0x78} + var frame []byte + if len(payload) < 126 { + frame = make([]byte, 6+len(payload)) + frame[0] = byte(0x80 | opcode) + frame[1] = byte(0x80 | len(payload)) + copy(frame[2:], maskKey) + for i, b := range payload { + frame[6+i] = b ^ maskKey[i%4] + } + } else if len(payload) < 65536 { + frame = make([]byte, 8+len(payload)) + frame[0] = byte(0x80 | opcode) + frame[1] = 0xfe + frame[2] = byte(len(payload) >> 8) + frame[3] = byte(len(payload) & 0xff) + copy(frame[4:], maskKey) + for i, b := range payload { + frame[8+i] = b ^ maskKey[i%4] + } + } else { + frame = make([]byte, 14+len(payload)) + frame[0] = byte(0x80 | opcode) + frame[1] = 0xff + copy(frame[10:], maskKey) + for i, b := range payload { + frame[14+i] = b ^ maskKey[i%4] + } + } + _, err := conn.Write(frame) + return err +} + +func TestHandleProxy_WebSocket(t *testing.T) { + tmpDir := t.TempDir() + + backendLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skip("skipping test: port not available") + } + backendPort := backendLn.Addr().(*net.TCPAddr).Port + + go func() { + for { + conn, err := backendLn.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + reqStr := string(buf[:n]) + if !strings.Contains(reqStr, "Upgrade: websocket") { + conn.Write([]byte("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK")) + return + } + conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")) + opcode, payload, _ := wsReadFrame(conn) + if opcode == 0x01 && len(payload) > 0 { + wsWriteFrame(conn, opcode, payload) + } + }() + } + }() + defer backendLn.Close() + + reg := registration{ + InstanceID: "test-instance", + PID: os.Getpid(), + Port: backendPort, + RepoHash: "abc123", + } + data, _ := json.Marshal(reg) + os.WriteFile(filepath.Join(tmpDir, "test-instance.json"), data, 0o600) + + cfg := Config{ + PortalPort: 0, + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoHash: "abc123", + } + p := New(cfg) + + proxyLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skip("skipping test: port not available") + } + proxyPort := proxyLn.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.HandleFunc("/s/", p.handleProxy) + srv := &http.Server{Handler: mux} + go srv.Serve(proxyLn) + defer srv.Shutdown(context.Background()) + + if !waitForPort("127.0.0.1", proxyPort, 500*time.Millisecond) { + t.Skip("proxy server not ready") + } + + conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", proxyPort)) + if err != nil { + t.Skip("skipping test: proxy port not available") + } + defer conn.Close() + + if err := wsHandshake(conn, "/s/abc123/echo", "dGhlIHNhbXBsZSBub25jZQ=="); err != nil { + t.Fatal("handshake failed:", err) + } + + testMsg := []byte("Hello WebSocket") + if err := wsWriteFrame(conn, 0x01, testMsg); err != nil { + t.Fatal("write frame failed:", err) + } + + opcode, echoPayload, err := wsReadFrame(conn) + if err != nil { + t.Fatal("read frame failed:", err) + } + if opcode != 0x01 { + t.Fatalf("expected text frame (opcode=1), got opcode=%d", opcode) + } + if string(echoPayload) != string(testMsg) { + t.Fatalf("expected echo %q, got %q", string(testMsg), string(echoPayload)) + } } \ No newline at end of file From b5a13003aa50c2472b25f7f0c18aa5e9bbe92491 Mon Sep 17 00:00:00 2001 From: Letian Lin Date: Fri, 22 May 2026 22:26:52 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(portal):=20Task=208=20=E2=80=94=20Ta?= =?UTF-8?q?ilscale=20Serve=20=E8=87=AA=E5=8A=A8=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal-dashboard-plan.md | 2 +- .../portal-dashboard-tasks.md | 4 +- internal/portal/portal.go | 153 +++++++- internal/portal/portal_test.go | 326 ++++++++++++++++++ 4 files changed, 470 insertions(+), 15 deletions(-) diff --git a/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md b/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md index 40b3c63..9ad2226 100644 --- a/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md +++ b/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md @@ -368,7 +368,7 @@ - 若指向其他端口 → 认为配置已过期(上轮 Portal 崩溃残留或端口变更),调用 `tailscale serve stop` 清理后重新启动 - 若无 `:443` 配置或 `tailscale serve status` 以非零退出码退出 → 视为未运行,启动新的 tailscale serve -**检测失败时的安全默认**:若 `--json` 不支持(旧版 tailscale)或解析失败,不自动启动 tailscale serve(避免与现有手动配置冲突),仅记录 Warn 日志。 +**检测失败时的安全默认**:若 JSON 解析失败,不自动启动 tailscale serve(避免与现有手动配置冲突),仅记录 Warn 日志。 **启动命令**: diff --git a/docs/plans/feature-improve-remote-access/portal-dashboard-tasks.md b/docs/plans/feature-improve-remote-access/portal-dashboard-tasks.md index b14c63c..376c9c0 100644 --- a/docs/plans/feature-improve-remote-access/portal-dashboard-tasks.md +++ b/docs/plans/feature-improve-remote-access/portal-dashboard-tasks.md @@ -299,7 +299,7 @@ Task 3 ──→ Task 4 ─────────────┤ | 8.5 | 启动命令:`tailscale serve --bg http://127.0.0.1:`,10 秒超时 | §4 行 375 | | 8.6 | 启动时孤儿清理:`claimerLoop` 抢占端口成功后立即调用 `tailscale serve status --json`,检测到 `:443` 指向错误端口 → Warn 日志 + `stop` + 重新启动 | §4 行 358–361 | | 8.7 | 实现 `stopTailscaleServe()`:执行 `tailscale serve stop` | §4 行 379 | -| 8.8 | 边界处理:(a) Tailscale 未安装 → Warn 跳过 (b) `--json` 不支持 → Warn 跳过 (c) 用户手动配置 → 检测到已配置同端口则跳过 (d) `status` 退出码非零 → 视为未配置 | §4 行 371、行 383–389 | +| 8.8 | 边界处理:(a) Tailscale 未安装 → Warn 跳过 (c) 用户手动配置 → 检测到已配置同端口则跳过 (d) `status` 退出码非零 → 视为未配置 | §4 行 371、行 383–389 | | 8.9 | **集成测试**:mock 外部命令输出,验证四种分支:已正确配置/指向错误端口/无配置/json 解析失败;验证启动时孤儿清理场景 | §测试策略 行 586 | **验收标准**: @@ -307,7 +307,7 @@ Task 3 ──→ Task 4 ─────────────┤ - 非持有者不执行 `tailscale serve` 命令 - `tailscale serve` 残留指向其他端口 → 接管时清理 + 重新配置 - Tailscale 未安装时 `mw start` 正常启动(仪表板可用,仅无 HTTPS 域名) -- 旧版 tailscale(无 `--json`)不自动启动 serve,仅 Warn 日志 +- 【已移除】仅支持 tailscale ≥ 1.56.0(`--json` 标志引入版本),不再兼容旧版 --- diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 5329b12..be8b3ad 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -6,6 +6,7 @@ import ( cryptorand "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "math/rand" @@ -14,8 +15,10 @@ import ( "net/http/httputil" "net/url" "os" + "os/exec" "path/filepath" "sort" + "strconv" "strings" "sync" "syscall" @@ -30,6 +33,16 @@ var cspHashes = [][2]string{ {"style", "sha256-placeholder"}, } +var tsStatus = func(ctx context.Context) ([]byte, error) { + cmd := exec.CommandContext(ctx, "tailscale", "serve", "status", "--json") + return cmd.Output() +} + +var tsServeAction = func(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "tailscale", args...) + return cmd.Run() +} + type Config struct { PortalPort int InstancePort int @@ -50,6 +63,10 @@ type Portal struct { done chan struct{} wg sync.WaitGroup closeOnce sync.Once + // stoppedTailscaleServe is a one-way latch set when tailscale management + // is permanently unavailable (binary not installed, JSON parse failures). + // Once true, tailscale serve management is disabled for the lifetime of this instance. + stoppedTailscaleServe bool csrfState *csrfState } @@ -88,12 +105,12 @@ func generateInstanceID() string { } func (p *Portal) Start() error { + p.writeRegistration() p.wg.Add(4) go p.claimerLoop() go p.csrfState.cleanupLoop(p.done, &p.wg) go p.cleanupRegistrationLoop() go p.tailscaleServeLoop() - p.writeRegistration() return nil } @@ -105,12 +122,21 @@ func (p *Portal) Stop() { if p.srv != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - p.srv.Shutdown(ctx) + if err := p.srv.Shutdown(ctx); err != nil { + log.Printf("[portal] warning: srv.Shutdown failed: %v", err) + } p.srv = nil p.ln = nil } p.mu.Unlock() - p.stopTailscaleServe() + p.mu.Lock() + skip := p.stoppedTailscaleServe + p.mu.Unlock() + if !skip { + if err := p.stopTailscaleServe(); err != nil { + log.Printf("[portal] warning: stopTailscaleServe failed: %v", err) + } + } p.deleteRegistration(wasHolder) p.wg.Wait() }) @@ -125,7 +151,6 @@ func (p *Portal) isPortalHolder() bool { func (p *Portal) claimerLoop() { defer p.wg.Done() - rand.Seed(time.Now().UnixNano()) initialDelay := time.Duration(rand.Intn(5000)) * time.Millisecond select { @@ -167,6 +192,7 @@ func (p *Portal) claimerLoop() { p.mu.Unlock() p.writePortalStatus() + p.cleanupStaleTailscaleServe() err = p.srv.Serve(ln) if err != nil && err != http.ErrServerClosed { @@ -178,13 +204,11 @@ func (p *Portal) claimerLoop() { p.srv = nil p.mu.Unlock() - if p.ln == nil { - select { - case <-time.After(10*time.Second + time.Duration(rand.Intn(5000))*time.Millisecond): - continue - case <-p.done: - return - } + select { + case <-time.After(10*time.Second + time.Duration(rand.Intn(5000))*time.Millisecond): + continue + case <-p.done: + return } } } @@ -231,13 +255,118 @@ func (p *Portal) tailscaleServeLoop() { if !isHolder { continue } + p.ensureTailscaleServe() case <-p.done: return } } } -func (p *Portal) stopTailscaleServe() { +func (p *Portal) ensureTailscaleServe() { + status, err := p.getTailscaleServeStatus() + if err != nil { + return + } + + p.repairTailscaleServe(status.currentPort) +} + +func (p *Portal) repairTailscaleServe(currentPort int) { + if currentPort == p.cfg.PortalPort { + return + } + + if currentPort > 0 { + log.Printf("[portal] detaching stale tailscale serve at :443 → 127.0.0.1:%d", currentPort) + if err := p.stopTailscaleServe(); err != nil { + log.Printf("[portal] failed to stop stale tailscale serve: %v", err) + return + } + } + + if err := p.startTailscaleServe(); err != nil { + log.Printf("[portal] failed to start tailscale serve: %v", err) + } else { + log.Printf("[portal] tailscale serve started successfully on :443 → 127.0.0.1:%d", p.cfg.PortalPort) + } +} + +type tailscaleStatus struct { + currentPort int +} + +func (p *Portal) getTailscaleServeStatus() (*tailscaleStatus, error) { + p.mu.Lock() + if p.stoppedTailscaleServe { + p.mu.Unlock() + return nil, fmt.Errorf("tailscale serve management disabled") + } + p.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + output, err := tsStatus(ctx) + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + log.Printf("[portal] tailscale not installed, skipping tailscale serve management: %v", err) + p.mu.Lock() + p.stoppedTailscaleServe = true + p.mu.Unlock() + } else if _, ok := err.(*exec.ExitError); ok { + log.Printf("[portal] tailscale serve status returned non-zero (likely not configured): %v", err) + return &tailscaleStatus{currentPort: 0}, nil + } else { + log.Printf("[portal] tailscale serve status check failed: %v", err) + } + return nil, fmt.Errorf("tailscale serve status check failed: %w", err) + } + + var status struct { + TCP map[string]string `json:"TCP"` + } + if err := json.Unmarshal(output, &status); err != nil { + log.Printf("[portal] failed to parse tailscale serve status: %v", err) + p.mu.Lock() + p.stoppedTailscaleServe = true + p.mu.Unlock() + return nil, fmt.Errorf("failed to parse tailscale serve status: %w", err) + } + + if target, ok := status.TCP[":443"]; ok { + parts := strings.Split(target, ":") + if len(parts) >= 2 { + port, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + log.Printf("[portal] failed to parse tailscale serve port: %v", err) + p.mu.Lock() + p.stoppedTailscaleServe = true + p.mu.Unlock() + return nil, fmt.Errorf("failed to parse tailscale serve port: %w", err) + } + return &tailscaleStatus{currentPort: port}, nil + } + } + + return &tailscaleStatus{currentPort: 0}, nil +} + +func (p *Portal) startTailscaleServe() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return tsServeAction(ctx, "serve", "--bg", fmt.Sprintf("http://127.0.0.1:%d", p.cfg.PortalPort)) +} + +func (p *Portal) stopTailscaleServe() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return tsServeAction(ctx, "serve", "stop") +} + +func (p *Portal) cleanupStaleTailscaleServe() { + p.ensureTailscaleServe() } func (p *Portal) cleanupStaleRegistrations() { diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index a3d679b..5dd0a58 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -3,11 +3,13 @@ package portal import ( "context" "encoding/json" + "errors" "fmt" "net" "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "strings" "sync" @@ -731,4 +733,328 @@ func TestHandleProxy_WebSocket(t *testing.T) { if string(echoPayload) != string(testMsg) { t.Fatalf("expected echo %q, got %q", string(testMsg), string(echoPayload)) } +} + +func fakeExitError() error { + cmd := exec.Command("false") + return cmd.Run() +} + +func TestGetTailscaleServeStatus_AlreadyConfigured(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"TCP":{":443":"http://127.0.0.1:12345"}}`), nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + status, err := p.getTailscaleServeStatus() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.currentPort != 12345 { + t.Fatalf("expected currentPort 12345, got %d", status.currentPort) + } +} + +func TestGetTailscaleServeStatus_WrongPort(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"TCP":{":443":"http://127.0.0.1:9999"}}`), nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + status, err := p.getTailscaleServeStatus() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.currentPort != 9999 { + t.Fatalf("expected currentPort 9999, got %d", status.currentPort) + } +} + +func TestGetTailscaleServeStatus_NotConfigured(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"TCP":{}}`), nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + status, err := p.getTailscaleServeStatus() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.currentPort != 0 { + t.Fatalf("expected currentPort 0 (not configured), got %d", status.currentPort) + } +} + +func TestGetTailscaleServeStatus_NoTCPKey(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"Other":"value"}`), nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + status, err := p.getTailscaleServeStatus() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if status.currentPort != 0 { + t.Fatalf("expected currentPort 0, got %d", status.currentPort) + } +} + +func TestGetTailscaleServeStatus_JSONParseFailure(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`not json`), nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + _, err := p.getTailscaleServeStatus() + if err == nil { + t.Fatal("expected error for unparseable JSON") + } + if !p.stoppedTailscaleServe { + t.Fatal("expected stoppedTailscaleServe to be true after JSON parse failure") + } + + _, err = p.getTailscaleServeStatus() + if err == nil { + t.Fatal("expected error when stoppedTailscaleServe is true") + } +} + +func TestGetTailscaleServeStatus_TailscaleNotFound(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return nil, &exec.Error{Name: "tailscale", Err: exec.ErrNotFound} + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + _, err := p.getTailscaleServeStatus() + if err == nil { + t.Fatal("expected error for tailscale not found") + } + if !p.stoppedTailscaleServe { + t.Fatal("expected stoppedTailscaleServe to be true after tailscale not found") + } + + _, err = p.getTailscaleServeStatus() + if err == nil { + t.Fatal("expected error when stoppedTailscaleServe is true") + } +} + +func TestGetTailscaleServeStatus_ExitError(t *testing.T) { + origStatus := tsStatus + defer func() { tsStatus = origStatus }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return nil, fakeExitError() + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + status, err := p.getTailscaleServeStatus() + if err != nil { + t.Fatalf("exit error should return status with currentPort 0, got error: %v", err) + } + if status.currentPort != 0 { + t.Fatalf("expected currentPort 0 for non-zero exit, got %d", status.currentPort) + } + if p.stoppedTailscaleServe { + t.Fatal("stoppedTailscaleServe should NOT be set for non-zero exit code") + } +} + +func TestRepairTailscaleServe_AlreadyCorrect(t *testing.T) { + origAction := tsServeAction + defer func() { tsServeAction = origAction }() + + var actionCalled bool + tsServeAction = func(ctx context.Context, args ...string) error { + actionCalled = true + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + p.repairTailscaleServe(12345) + + if actionCalled { + t.Fatal("expected no action when currentPort matches portal port") + } +} + +func TestRepairTailscaleServe_PointsToWrongPort(t *testing.T) { + origAction := tsServeAction + defer func() { tsServeAction = origAction }() + + var actions []string + tsServeAction = func(ctx context.Context, args ...string) error { + actions = append(actions, strings.Join(args, " ")) + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + p.repairTailscaleServe(9999) + + if len(actions) != 2 { + t.Fatalf("expected 2 actions (stop + start), got %d: %v", len(actions), actions) + } + if actions[0] != "serve stop" { + t.Fatalf("expected first action 'serve stop', got %q", actions[0]) + } + if actions[1] != "serve --bg http://127.0.0.1:12345" { + t.Fatalf("expected second action 'serve --bg http://127.0.0.1:12345', got %q", actions[1]) + } +} + +func TestRepairTailscaleServe_NotConfigured(t *testing.T) { + origAction := tsServeAction + defer func() { tsServeAction = origAction }() + + var actions []string + tsServeAction = func(ctx context.Context, args ...string) error { + actions = append(actions, strings.Join(args, " ")) + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + p.repairTailscaleServe(0) + + if len(actions) != 1 { + t.Fatalf("expected 1 action (start only), got %d: %v", len(actions), actions) + } + if actions[0] != "serve --bg http://127.0.0.1:12345" { + t.Fatalf("expected 'serve --bg http://127.0.0.1:12345', got %q", actions[0]) + } +} + +func TestRepairTailscaleServe_StopFailsDoesNotStart(t *testing.T) { + origAction := tsServeAction + defer func() { tsServeAction = origAction }() + + var actions []string + tsServeAction = func(ctx context.Context, args ...string) error { + actions = append(actions, strings.Join(args, " ")) + if strings.Join(args, " ") == "serve stop" { + return errors.New("stop failed") + } + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + p.repairTailscaleServe(9999) + + if len(actions) != 1 { + t.Fatalf("expected only 1 action (stop, which fails), got %d: %v", len(actions), actions) + } + if actions[0] != "serve stop" { + t.Fatalf("expected 'serve stop', got %q", actions[0]) + } +} + +func TestEnsureTailscaleServe_AllowsRepairWhenCalled(t *testing.T) { + origStatus := tsStatus + origAction := tsServeAction + defer func() { + tsStatus = origStatus + tsServeAction = origAction + }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"TCP":{":443":"http://127.0.0.1:9999"}}`), nil + } + + var actions []string + tsServeAction = func(ctx context.Context, args ...string) error { + actions = append(actions, strings.Join(args, " ")) + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + + p.mu.Lock() + p.ln = &net.TCPListener{} + p.mu.Unlock() + + p.ensureTailscaleServe() + + if len(actions) != 2 { + t.Fatalf("expected 2 actions (stop + start), got %d", len(actions)) + } + if actions[0] != "serve stop" { + t.Fatalf("expected first action 'serve stop', got %q", actions[0]) + } +} + +func TestCleanupStaleTailscaleServe_AlreadyCorrect(t *testing.T) { + origStatus := tsStatus + origAction := tsServeAction + defer func() { + tsStatus = origStatus + tsServeAction = origAction + }() + + tsStatus = func(ctx context.Context) ([]byte, error) { + return []byte(`{"TCP":{":443":"http://127.0.0.1:12345"}}`), nil + } + + var actionCalled bool + tsServeAction = func(ctx context.Context, args ...string) error { + actionCalled = true + return nil + } + + cfg := Config{PortalPort: 12345} + p := New(cfg) + p.cleanupStaleTailscaleServe() + + if actionCalled { + t.Fatal("expected no action when already correctly configured") + } +} + +func TestTailscaleServeLoop_IgnoresNonHolder(t *testing.T) { + cfg := Config{PortalPort: 12345} + p := New(cfg) + + p.wg.Add(1) + go func() { + p.tailscaleServeLoop() + }() + time.Sleep(10 * time.Millisecond) + close(p.done) + p.wg.Wait() } \ No newline at end of file From 093d05003022a60df2ac6b52ed4ac744b1b39128 Mon Sep 17 00:00:00 2001 From: Letian Lin Date: Fri, 22 May 2026 22:53:19 +0800 Subject: [PATCH 15/25] portal: implement Task 9 - Go generate CSP hash auto-computation - Add gen.go (//go:build ignore) that reads dashboard.html and generates csp_gen.go with SHA256+base64 hashes for inline `) + styleRe := regexp.MustCompile(`(?s)`) + + scripts := scriptRe.FindAllStringSubmatch(content, -1) + styles := styleRe.FindAllStringSubmatch(content, -1) + + var out strings.Builder + out.WriteString("package portal\n\n") + out.WriteString("// Code generated by go generate; DO NOT EDIT.\n\n") + out.WriteString("var CSPHashes = [][2]string{\n") + for _, m := range scripts { + h := sha256.Sum256([]byte(m[1])) + out.WriteString(fmt.Sprintf("\t{\"script\", \"'sha256-%s'\"},\n", base64.StdEncoding.EncodeToString(h[:]))) + } + for _, m := range styles { + h := sha256.Sum256([]byte(m[1])) + out.WriteString(fmt.Sprintf("\t{\"style\", \"'sha256-%s'\"},\n", base64.StdEncoding.EncodeToString(h[:]))) + } + out.WriteString("}\n") + + err = os.WriteFile("csp_gen.go", []byte(out.String()), 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to write csp_gen.go: %v\n", err) + os.Exit(1) + } +} \ No newline at end of file diff --git a/internal/portal/portal.go b/internal/portal/portal.go index be8b3ad..78be1e9 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -25,14 +25,11 @@ import ( "time" ) +//go:generate go run gen.go + //go:embed dashboard.html var DashboardHTML []byte -var cspHashes = [][2]string{ - {"script", "sha256-placeholder"}, - {"style", "sha256-placeholder"}, -} - var tsStatus = func(ctx context.Context) ([]byte, error) { cmd := exec.CommandContext(ctx, "tailscale", "serve", "status", "--json") return cmd.Output() @@ -492,7 +489,7 @@ func (p *Portal) handleDashboard(w http.ResponseWriter, r *http.Request) { var scriptHashes []string var styleHashes []string - for _, h := range cspHashes { + for _, h := range CSPHashes { if h[0] == "script" { scriptHashes = append(scriptHashes, h[1]) } else if h[0] == "style" { @@ -504,7 +501,7 @@ func (p *Portal) handleDashboard(w http.ResponseWriter, r *http.Request) { for _, h := range scriptHashes { csp += " " + h } - csp += "; style-src" + csp += "; style-src 'self'" for _, h := range styleHashes { csp += " " + h } diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index 5dd0a58..d25c4c0 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -2,6 +2,8 @@ package portal import ( "context" + "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -11,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -1057,4 +1060,111 @@ func TestTailscaleServeLoop_IgnoresNonHolder(t *testing.T) { time.Sleep(10 * time.Millisecond) close(p.done) p.wg.Wait() +} + +func TestCSPHashes_MatchesDashboardHTML(t *testing.T) { + content := string(DashboardHTML) + + scriptRe := regexp.MustCompile(`(?s)`) + styleRe := regexp.MustCompile(`(?s)`) + + scriptMatches := scriptRe.FindAllStringSubmatch(content, -1) + styleMatches := styleRe.FindAllStringSubmatch(content, -1) + + if len(scriptMatches)+len(styleMatches) == 0 { + t.Fatal("no