diff --git a/README.md b/README.md index b1f597f..4cacf81 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ myworktree is a thin management layer that: - Optional built-in HTTPS (`--tls-cert/--tls-key`) and token auth for non-loopback - Stored backlog redaction for common secrets (e.g. `sk-...`) - MCP tool endpoints (`/api/mcp/tools`, `/api/mcp/call`) +- Portal Dashboard with shared entry port, auto-discovery of running instances across repos +- Global auth token (HttpOnly Cookie, CSRF protection, tailscale serve integration) ## Requirements - macOS 12+ (other platforms are not validated yet) @@ -137,6 +139,13 @@ mw When startup succeeds, `mw` opens the web page automatically at the serving URL by default. `myworktree` prints the URL without opening a browser unless you pass `-open=true`. +When Portal is enabled, the startup output includes: +``` +Portal dashboard at: + http://0.0.0.0:12345/ +Tailscale: https://my-machine.tail-scale.ts.net/ +``` + myworktree uses the **current working directory** to detect the target repo (git root) and derives an isolated per-project data dir from it, so you can manage other projects by running the same binary in a different repo directory. By default, newly created worktrees are placed next to your repo: @@ -163,6 +172,17 @@ myworktree instance start --worktree --cmd "echo hello && ls" myworktree instance start --worktree # starts an interactive shell instance myworktree instance list myworktree instance stop + +# config (global auth token) +mw config # interactive guided setup (set/view/clear token) +mw config set-auth # set token directly +mw config get-auth # view token (masked) +mw config clear-auth # clear token + +# start with remote access & Portal (IPv6 is explicitly disabled) +mw start --listen 0.0.0.0:0 # LAN access, auto-inherits global token +mw start --listen 0.0.0.0:0 --portal-port 12346 # custom Portal port +mw start --listen 0.0.0.0:0 --portal-port 0 # disable Portal ``` Note: command starts are executed inside the instance shell, and you can continue sending input to the same running instance from the UI. @@ -211,10 +231,46 @@ The workflow verifies `gofmt`, runs `go test ./...`, and builds both binaries on Tagged releases (`v*`) run `.github/workflows/release.yml`, which produces darwin `amd64` / `arm64` archives plus SHA256 checksums. ## Remote access -- Default: binds to loopback only. -- If you listen on a non-loopback address, you must set `--auth`. -- For HTTPS, provide `--tls-cert` and `--tls-key`. -- `?token=` works for simple clients, but prefer `Authorization: Bearer ` to avoid leaving tokens in browser history or shell history. + +### Global Token + +Configure a global auth token once, and all instances automatically inherit it: + +```bash +mw config +# → Interactive guided setup: [1] set token [2] view token [3] clear token [q] quit +# Token stored in ~/.config/myworktree/auth.json (0600 permissions) +``` + +The token is stored as plaintext in `auth.json` (0600 permissions). Instance-level `--auth` override takes precedence over the global token. + +### Portal Dashboard + +`mw start --listen 0.0.0.0:0` starts a **Portal Dashboard** on port `12345` (configurable via `--portal-port`). The dashboard: + +- Lists all running instances across repos with auto-discovery +- Click an instance to jump to its Web UI through the Portal reverse proxy +- Uses **HttpOnly Cookie** (`mw_token`) for authentication — token never appears in URL or JS +- **CSRF protection** via double-submit cookie pattern on login/logout endpoints +- Cookie has 24-hour **sliding expiration** (refreshed on each auth-successful request) + +Set `--portal-port 0` to disable the Portal. + +### Tailscale HTTPS + +When Tailscale is installed, the Portal holder automatically configures `tailscale serve` to provide HTTPS access via `https://.ts.net`. This is fully automated — zero manual configuration. + +### Network Security + +| Access Path | Protocol | Encryption Layer | +|-------------|----------|-----------------| +| Instance direct (local/LAN IP) | `http://192.168.1.18:PORT` → instance | None (LAN only) | +| Instance direct (Tailscale IP) | `http://100.x.x.x:PORT` → instance | WireGuard tunnel | +| Dashboard + proxy (local/LAN) | `http://host:12345` → proxy `http://127.0.0.1:PORT` | None (LAN only) | +| Dashboard + proxy (Tailscale IP) | `http://100.x.x.x:12345` → proxy `http://127.0.0.1:PORT` | WireGuard tunnel | +| `tailscale serve` domain | `https://machine.ts.net` → proxy `http://127.0.0.1:PORT` | Let's Encrypt TLS + WireGuard | + +> **Note**: Tailscale's WireGuard tunnel provides network-layer encryption. Application-layer HTTPS is only used when accessing via `tailscale serve` domain (Let's Encrypt certificate). ## License MIT. See [LICENSE](./LICENSE). diff --git a/README.zh-CN.md b/README.zh-CN.md index a6ecff9..8e63b8a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -35,6 +35,8 @@ myworktree 只做管理,不碰项目具体内容: - 可选内置 HTTPS(`--tls-cert/--tls-key`),非 loopback 监听必须 `--auth` - 回放落盘日志脱敏(覆盖常见 secret 与 `sk-...`) - MCP 接口(`/api/mcp/tools`、`/api/mcp/call`) +- Portal 仪表板:共享入口端口,跨仓库自动发现运行实例 +- 全局认证 Token(HttpOnly Cookie、CSRF 防护、Tailscale Serve 自动集成) ## 运行环境 - macOS 12+ 其他平台未验证 @@ -137,6 +139,13 @@ mw 运行成功后,`mw` 默认会自动打开浏览器访问对应 URL。 `myworktree` 默认只打印 URL;如果也想自动打开浏览器,可传 `-open=true`。 +当 Portal 启用时,启动输出包含: +``` +Portal dashboard at: + http://0.0.0.0:12345/ +Tailscale: https://my-machine.tail-scale.ts.net/ +``` + myworktree 会用**当前工作目录**定位目标项目(git root),并基于该 git root 计算独立的数据目录,因此要管理其他项目时,只需要在另一个项目仓库目录下运行同一个 myworktree 二进制即可。 默认情况下,新建 worktree 会放在主仓库的同级目录下: @@ -163,6 +172,17 @@ myworktree instance start --worktree --cmd "echo hello && ls" myworktree instance start --worktree # 启动一个可交互 shell instance myworktree instance list myworktree instance stop + +# config(全局认证 Token) +mw config # 交互式引导(设置/查看/清除 Token) +mw config set-auth # 直接设置 Token +mw config get-auth # 查看 Token(掩码显示) +mw config clear-auth # 清除 Token + +# 启动并启用远程访问 + Portal +mw start --listen 0.0.0.0:0 # LAN 访问,自动继承全局 Token +mw start --listen 0.0.0.0:0 --portal-port 12346 # 自定义 Portal 端口 +mw start --listen 0.0.0.0:0 --portal-port 0 # 禁用 Portal ``` ## Tag 配置 @@ -209,10 +229,46 @@ GitHub Actions(`.github/workflows/go-ci.yml`)会在以下场景运行: 带 `v*` 标签的发布会触发 `.github/workflows/release.yml`,产出 darwin `amd64` / `arm64` 压缩包和 SHA256 校验文件。 ## 远程访问 -- 默认只监听本机回环地址。 -- 监听到非 loopback(如 `0.0.0.0` 或局域网 IP)时必须提供 `--auth`。 -- 需要 HTTPS 时提供 `--tls-cert` 与 `--tls-key`。 -- 简单客户端可用 `?token=`,但更推荐 `Authorization: Bearer `,避免 token 落入浏览器历史或 shell 历史。 + +### 全局 Token + +一次性配置全局认证 Token,所有实例自动继承: + +```bash +mw config +# → 交互式引导:[1] 设置 Token [2] 查看 Token [3] 清除 Token [q] 退出 +# Token 存储在 ~/.config/myworktree/auth.json(0600 权限) +``` + +Token 以明文存储在 `auth.json` 中(0600 权限)。实例级别 `--auth` 参数优先级高于全局 Token。 + +### Portal 仪表板 + +`mw start --listen 0.0.0.0:0` 会在端口 `12345` 启动 **Portal 仪表板**(可通过 `--portal-port` 自定义)。仪表板功能: + +- 自动发现并列出所有跨仓库运行中的实例 +- 点击实例通过 Portal 反向代理跳转到其 Web UI +- 使用 **HttpOnly Cookie**(`mw_token`)进行认证——Token 不出现在 URL 或 JS 中 +- 登录/登出端点采用 **CSRF 防护**(double-submit cookie 模式) +- Cookie 具备 24 小时**滑动过期**机制(每次认证成功的请求自动刷新有效期) + +设置 `--portal-port 0` 可禁用 Portal。 + +### Tailscale HTTPS + +当 Tailscale 已安装时,Portal 持有者自动配置 `tailscale serve`,提供 HTTPS 访问(`https://.ts.net`)。全程自动化,无需手动配置。 + +### 网络安全 + +| 访问路径 | 协议 | 加密层级 | +|----------|------|----------| +| 实例直连(本地/LAN IP) | `http://192.168.1.18:PORT` → 实例 | 无(仅 LAN 可及) | +| 实例直连(Tailscale IP) | `http://100.x.x.x:PORT` → 实例 | WireGuard 隧道加密 | +| 仪表板 + 代理(本地/LAN) | `http://host:12345` → 代理 `http://127.0.0.1:PORT` | 无(仅 LAN 可及) | +| 仪表板 + 代理(Tailscale IP) | `http://100.x.x.x:12345` → 代理 `http://127.0.0.1:PORT` | WireGuard 隧道加密 | +| `tailscale serve` 域名 | `https://machine.ts.net` → 代理 `http://127.0.0.1:PORT` | Let's Encrypt TLS + WireGuard | + +> **说明**:Tailscale 的 WireGuard 隧道已对网络层加密。仅通过 `tailscale serve` 域名访问时使用应用层 HTTPS(Let's Encrypt 证书)。 ## License MIT 协议,详见 [LICENSE](./LICENSE)。 diff --git a/docs/API.md b/docs/API.md index c877167..12c7f2b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -8,6 +8,8 @@ Base URL: printed when starting `myworktree` or `mw`, e.g. `http://127.0.0.1:500 Auth: - If `--auth ` is set, send `Authorization: Bearer `. - Alternatively, pass `?token=` for simple clients. +- For Portal dashboard access, the `mw_token` HttpOnly Cookie is used as the third token source (automatically sent by browser after `/api/auth` login). +- Token priority: `Authorization` header → `?token=` query → `mw_token` Cookie. - Prefer the `Authorization` header when possible so tokens do not end up in browser history or shell history. Common response header: @@ -473,7 +475,140 @@ Supported tool names: - `branch_list`, `tag_list` - `instance_list`, `instance_start`, `instance_stop`, `instance_input`, `instance_delete`, `instance_log_tail` -## 7) LLM 配置 +## 7) Portal Dashboard + +The Portal dashboard provides a shared entry point for discovering and accessing all running instances across repos. + +Base URL: `http://:/` (default portal port: 12345). + +**Auth model**: Portal uses `mw_token` HttpOnly Cookie for authentication. The token is obtained via the CSRF-protected `/api/auth` endpoint. Once authenticated, the Cookie is automatically sent by the browser on all subsequent requests. Cookie has 24-hour sliding expiration (refreshed on each successful auth request). + +**CSRF protection**: `/api/auth` and `/api/logout` endpoints use double-submit cookie pattern. Client must fetch a CSRF token from `/api/csrf-token`, then include it in the request body. CSRF tokens are single-use with a 5-minute TTL. + +### Dashboard page +`GET /` + +Returns the embedded Portal dashboard HTML page (no authentication required). + +Response headers: +- `Content-Security-Policy: default-src 'self'; script-src 'sha256-' ...; style-src 'self' 'sha256-' ...` +- `X-Content-Type-Options: nosniff` + +### Get CSRF token +`GET /api/csrf-token` + +Returns a new single-use CSRF token and sets `mw_csrf` Cookie. + +Rate limit: 1 request per second per IP. + +Response: +```json +{ "csrf_token": "<64-char-hex>" } +``` + +Sets Cookie: `mw_csrf=; Path=/; SameSite=Strict` (non-HttpOnly — JS must read it for CSRF double-submit). + +### Authenticate (login) +`POST /api/auth` + +Authenticates with the global auth token. Requires valid CSRF token. + +Body: +```json +{ "token": "", "csrf_token": "" } +``` + +Rate limit: 20 attempts per minute per IP. + +Success (200): Sets `mw_token` HttpOnly Cookie (`Max-Age=86400, SameSite=Lax`) and returns: +```json +{ "status": "ok" } +``` + +Errors: +- `400`: Auth token not configured on server (`{"error":"auth token not configured on server"}`) +- `401`: Invalid token +- `403`: CSRF token invalid/expired/used +- `429`: Rate limit exceeded + +### List instances +`GET /api/list` + +**Authentication required** (Cookie `mw_token` or Bearer token). + +Returns JSON with all running instances and Portal status. Each successful request refreshes the `mw_token` Cookie's expiration (sliding). + +Response: +```json +{ + "is_portal": true, + "portal_port": 12345, + "processes": [ + { + "instance_id": "12345-1710000000-a1b2c3", + "pid": 12345, + "port": 50053, + "host": "0.0.0.0", + "repo_name": "myproject", + "repo_hash": "a1b2c3d4e5f6", + "started_at": "2024-03-10T12:00:00Z", + "alive": true + } + ] +} +``` + +- `is_portal`: whether the current process holds the Portal port +- `portal_port`: Portal port number +- `alive`: determined by PID liveness and TCP port reachability + +### Portal status +`GET /api/portal-status` + +No authentication required. Returns whether the current instance holds the Portal port. + +Response: +```json +{ "is_portal": true } +``` + +### Logout +`POST /api/logout` + +**CSRF required**. Clears the `mw_token` Cookie. + +Body: +```json +{ "csrf_token": "" } +``` + +Response (200): +```json +{ "status": "ok" } +``` + +Always returns 200 (idempotent — successful even if not logged in). + +Errors: +- `403`: CSRF token invalid/expired/used or missing + +### Reverse proxy (access instance) +`ANY /s//*` + +**Authentication required** (Cookie `mw_token` or Bearer token). Each successful request refreshes the `mw_token` Cookie's expiration (sliding). + +Proxies the request to the corresponding instance at `http://127.0.0.1:`. Since the proxy connects via loopback, the instance's auth middleware automatically bypasses token validation. + +Security: +- `repo-hash` format validation: only `[a-f0-9]+` (lowercase hex) accepted; path traversal characters (`..`, `/`, `\`) rejected with 400 +- WebSocket upgrade is automatically handled by the reverse proxy (Go's `httputil.ReverseProxy` natively supports WebSocket hijacking) + +Errors: +- `400`: Invalid `repo-hash` format +- `401`: Not authenticated +- `502`: Target instance offline + +## 8) LLM 配置 ### 获取当前配置 `GET /api/llm/config` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 771c60c..ff6aade 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,6 +5,7 @@ myworktree is a lightweight single-user manager for: - **git worktrees** (isolated working directories) - **instances** (long-running shell/CLI processes started by myworktree) - **web UI + HTTP API** to manage them and replay recent output +- **Portal dashboard** (shared entry port with auto-discovery of running instances across repos, reverse proxy, and tailscale serve integration) It does **not** analyze project code or prevent concurrent write conflicts inside a worktree. @@ -19,6 +20,8 @@ It does **not** analyze project code or prevent concurrent write conflicts insid - `internal/mcp/` — MCP adapter surface (tool names + app-level tool dispatch), keeping core decoupled. - `internal/monitor/` — resource stats collector (CPU delta via gopsutil/process.Times, memory via RSS) - `internal/llm/` — LLM API client(OpenAI / Anthropic / OpenAI Compatible),可选,LLM Settings 通过 Web UI 对话框配置 +- `internal/config/` — global auth configuration (read/write `auth.json`) +- `internal/portal/` — Portal dashboard (port claiming, instance registry, CSRF state management, HTTP endpoints, reverse proxy; tailscale serve automation code is defined but **currently unused** due to tailscale CLI bug) - `internal/ui/` — embedded static UI. ## 3. Data & persistence @@ -38,6 +41,13 @@ It does **not** analyze project code or prevent concurrent write conflicts insid - 包含 LLM 配置(protocol、api_key、api_address、model 等) - 不存于项目目录下,避免污染 git 仓库 +### 3.1.3 Global auth & Portal registry +- `~/.config/myworktree/auth.json` — global auth token (0600 permissions, plaintext storage, `internal/config/` package) +- `~/.config/myworktree//server.json` — per-instance config (`listen_port`, `instance_id`; `instance_id` is a `pid-timestamp-rand` format unique identifier used for cross-referencing with Portal registry) +- `~/.config/myworktree/portal/` — shared Portal registry directory: + - `.json` — per-instance registration (instance_id, pid, port, host, repo_name, repo_hash, started_at; **no auth_token**) + - `portal.json` — current Portal holder (instance_id, port, updated_at) + ### 3.2 State model - Worktree: id, name, path, branch, baseRef, createdAt - Instance: id, worktreeId, tagId, command, cwd, env (sanitized), pid, status, logPath, timestamps @@ -329,12 +339,42 @@ If issues arise with the current filtering approach: - `docs/TERMINAL_IO_ANALYSIS.md` - Terminal I/O architecture and filtering - `docs/TERMINAL_TEST_CASES.md` - Test cases for terminal behavior -## 6. Security model (single-user) -- Default listen: loopback only. -- Non-loopback requires `--auth`. -- Optional built-in HTTPS via `--tls-cert/--tls-key`. -- Origin/Host check + basic rate limit on unauthorized attempts. -- Redaction on stored backlog (e.g. `sk-...`). +## 6. Security model (single-user, dual-layer) + +myworktree implements a **dual-layer authentication architecture**: + +**Layer 1 — Portal (public-facing)**: +- `mw_token` HttpOnly Cookie-based authentication (JS cannot read token, prevents XSS theft) +- Double-submit cookie CSRF protection on `/api/auth` and `/api/logout` endpoints +- CSRF token: single-use, 5-minute TTL, IP rate-limited (1 req/s) +- Cookie: 24-hour sliding expiration, `SameSite=Lax`, `Secure` flag on HTTPS +- `POST /api/auth` rate-limited per IP (20 attempts/min) +- `GET /` dashboard page served with strict CSP headers (hash-based inline script/style whitelist) + +**Layer 2 — Instance (loopback-bypassed via proxy)**: +- Default listen: loopback only, **IPv6 explicitly disabled** +- Non-loopback requires `--auth` +- Loopback requests skip all token/origin validation (enables Portal reverse proxy) +- Origin/Host check + basic rate limit on unauthorized non-loopback attempts +- Optional built-in HTTPS via `--tls-cert/--tls-key` +- Redaction on stored backlog (e.g. `sk-...`) + +**Proxy authentication bypass**: Portal reverse proxy forwards requests to instances via `127.0.0.1` (loopback), so instances automatically skip auth — users never need to manually pass tokens to individual instances. + +**Tailscale**: WireGuard tunnel provides network-layer encryption. ~~Portal holder automatically manages `tailscale serve` for HTTPS domain access (`https://.ts.net`) with Let's Encrypt certificates.~~ **Currently disabled** — tailscale CLI `serve` command on macOS returns success but does not actually configure the proxy. The `tailscaleServeLoop` goroutine and related `cleanupStaleTailscaleServe()` call are removed from the production code path. Users can still securely access Portal via Tailscale IP (`http://100.x.x.x:12345`) over the WireGuard tunnel. + +### CLI Flags & Configuration + +| Flag / Command | Description | +|---------------|-------------| +| `--portal-port ` | Portal claim target port (default: 12345; 0 = disable Portal) | +| `--auth ` | Per-instance auth token (overrides global token) | +| `mw config` | Interactive guided setup for global auth token | +| `mw config set-auth` | Set global token (hidden echo + confirmation) | +| `mw config get-auth` | View token (masked: first 4 + `****` + last 4 chars) | +| `mw config clear-auth` | Clear global token (no confirmation) | + +**Auth token auto-fill**: When `--auth` is empty, `startCmd` automatically loads from `~/.config/myworktree/auth.json`. If the file is corrupted, a warning is logged but startup continues (non-loopback listen will then be rejected by `validateSecurity()`). ## 7. MCP extensibility - Core managers (worktree/instance) are transport-agnostic. diff --git a/docs/PRD.md b/docs/PRD.md index 6258498..737b833 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,6 +1,6 @@ # myworktree — PRD (v0.1) -> 定位:单人使用的 **git worktree + coding CLI instance** 管理框架;提供 Web UI 做管理与输出回放;默认本机安全运行,可选远程访问(内置 HTTPS + Token)。 +> 定位:单人使用的 **git worktree + coding CLI instance** 管理框架;提供 Web UI 做管理与输出回放;Portal 仪表板全局入口,支持跨仓库运行实例自动发现;默认本机安全运行,可选远程访问(全局 Token、Portal 反向代理、Tailscale HTTPS)。 ## 1. 背景 在同一项目中并行多个 AI coding 任务时,常见痛点: @@ -41,6 +41,9 @@ - 默认只监听 `127.0.0.1`。 - 监听非 loopback(例如 `0.0.0.0` 或局域网 IP)时:必须提供 `--auth`。 - 可选内置 HTTPS:`--tls-cert/--tls-key`。 +- **Portal 端口绑定**:Portal 仪表板可绑定到 `0.0.0.0`,用户通过 LAN IP 或 Tailscale 域名访问。非 loopback 访问 Portal 时须通过 `mw_token` Cookie 认证。 +- **全局 Token(HttpOnly Cookie + CSRF)**:全局 Token 存储在 `~/.config/myworktree/auth.json`(0600 权限),通过 `mw config` 交互式配置。Portal 仪表板使用 HttpOnly Cookie(`mw_token`)传输 Token——JS 不可读取,防止 XSS 窃取。登录/登出端点采用 double-submit cookie 模式做 CSRF 防护。 +- **Tailscale HTTPS**:~~当 Tailscale 可用时,Portal 持有者自动配置 `tailscale serve` 提供 `https://.ts.net` 域名访问(Let's Encrypt 证书)。~~ **(暂未启用)** 在 macOS 下测试发现 tailscale 1.98 CLI 的 `serve` 命令存在 bug:`tailscale serve --bg ` 返回成功但实际未配置代理、`tailscale serve status` 始终报告 No serve config。相关代码(`tailscaleServeLoop`、`ensureTailscaleServe` 等)已封存不调用,待 tailscale 修复后恢复。Tailscale WireGuard 隧道本身提供网络层加密,仍可通过 `http://100.x.x.x:PORT` 安全访问。 - 涉及宿主机图形界面的快捷动作(例如从侧栏直接打开 Terminal / Finder)只在浏览器通过 `127.0.0.1` / `localhost` 访问时展示;远程访问时隐藏,避免误导用户在远端会话里触发本机 GUI 行为。 - 对应后端接口也强制仅接受 loopback 客户端请求,不能只依赖前端隐藏来形成安全边界。 - 日志/回放脱敏: @@ -59,7 +62,12 @@ - 超时降级:5 秒握手超时后自动降级到 SSE 方案。 - 运行中的实例在前端按实例维护各自的终端会话;切换标签时隐藏非活动终端,而不是强制断开其 PTY 连接。 - 终端配置:Web TTY 的缓冲区(scrollback)、主题、字体等参数由前端灵活配置,以适应不同的调试和使用场景。 -- 规划增强:无(PTY + Web TTY 已完成)。 +- 规划增强:**Portal Dashboard MVP**(已实现): + - 全局 Token 配置(`mw config` 交互式引导,`~/.config/myworktree/auth.json`,`0o600` 权限) + - Portal 仪表板(共享入口端口,自动发现所有仓库的运行实例,HttpOnly Cookie 认证,CSRF 防护) + - 反向代理(通过 Portal 统一入口访问各实例,解决跨域 Cookie 问题,支持 WebSocket) + - ~~Tailscale Serve 自动管理(自动配置 `tailscale serve` 提供 `https://.ts.net` 域名访问)~~ **(暂未启用,见 §6 安全说明)** + - 双层认证架构(Portal 层 Cookie + CSRF,实例层 loopback 绕过) - 浏览器关闭保护:前端在 `beforeunload` 事件时,无论是否存在运行中实例,均触发浏览器原生确认对话框,防止误操作关闭页面。 - **Main workspace 分支查询**:`GET /api/main` 返回 `{name, branch}`。branch 字段实时查询(`git rev-parse --abbrev-ref HEAD`),在 detached HEAD 场景(如 CI 浅克隆)下返回空字符串而非错误。 @@ -68,6 +76,15 @@ - 可启动/列出/停止 instance,且前端关闭后 instance 仍继续运行。 - UI 重连可看到所有已管理对象,并能回放 instance 近期输出。 - 本机访问 Web UI 时,可从侧栏一键打开所选主工作区/worktree 的 Terminal 与 Finder;远程访问时不展示这两个快捷入口。 +- **Portal Dashboard MVP**: + - `mw config` 交互式引导可完成全局 Token 的配置、查看(掩码)、清除 + - `mw start --listen 0.0.0.0:0` 自动启动 Portal 仪表板,多实例中仅一个持有 Portal 端口 + - Portal 仪表板可通过 LAN IP 和 Tailscale 域名访问,显示所有运行实例并可点击跳转 + - Cookie 认证流程:获取 CSRF token → 提交 auth → 获得 HttpOnly Cookie → 访问实例列表/代理 + - 实例通过 Portal 反向代理访问时,loopback 请求自动绕过实例端 auth 中间件 + - 反向代理支持 WebSocket 升级转发 + - ~~Tailscale 可用时自动配置 `tailscale serve`,提供 HTTPS 域名访问~~ **(暂未启用)** + - Portal 持有者崩溃后,其他实例在 10~15 秒内完成故障转移接管 ## 9. LLM 智能分支命名 在 Create Worktree 时,可选使用 LLM 将任务描述转换为简洁、规范的分支名。 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..684fcd8 --- /dev/null +++ b/docs/plans/feature-improve-remote-access/portal-dashboard-plan.md @@ -0,0 +1,644 @@ +# 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`)。IPv6 地址(包括 `::1`)不支持,视为非 loopback。若为 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/gen.go b/internal/portal/gen.go new file mode 100644 index 0000000..b0cca9e --- /dev/null +++ b/internal/portal/gen.go @@ -0,0 +1,63 @@ +//go:build ignore + +// CSP hash generator for portal/dashboard.html +// +// This script extracts all 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) + } +} diff --git a/internal/portal/portal.go b/internal/portal/portal.go new file mode 100644 index 0000000..6cadf7f --- /dev/null +++ b/internal/portal/portal.go @@ -0,0 +1,987 @@ +package portal + +import ( + "context" + cryptorand "crypto/rand" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/rand" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +//go:generate go run gen.go + +//go:embed dashboard.html +var DashboardHTML []byte + +// === Tailscale serve integration (currently disabled) === +// +// tailscale serve on macOS + tailscale 1.98 CLI returns success but does not +// actually configure the proxy. tailscale serve status always reports "No serve +// config". The tailscaleServeLoop goroutine and the cleanupStaleTailscaleServe() +// call in claimerLoop are NOT wired into Start/Stop/claimerLoop prod code paths. +// +// All tailscale serve functions below are kept for reference and tests, but are +// not invoked in production. To re-enable when tailscale fixes the issue: +// 1. Restore go p.tailscaleServeLoop() in Start() and bump wg counter +1 +// 2. Restore p.cleanupStaleTailscaleServe() after writePortalStatus() in claimerLoop +// 3. Restore p.stopTailscaleServe() in Stop() + +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 + Host string + AuthToken string + RegistryDir string + DataDir string + RepoName string + RepoHash string + WorktreePath string +} + +type Portal struct { + cfg Config + instanceID string + mu sync.Mutex + srv *http.Server + ln net.Listener + 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 + // tailscaleServeOK tracks whether tailscale serve is believed to be + // correctly configured. Once true, the 30s periodic check skips + // re-evaluation to avoid log spam when status returns empty. + tailscaleServeOK bool + + csrfState *csrfState +} + +type registration struct { + InstanceID string `json:"instance_id"` + PID int `json:"pid"` + Port int `json:"port"` + Host string `json:"host"` + RepoName string `json:"repo_name"` + RepoHash string `json:"repo_hash"` + StartedAt string `json:"started_at"` + Path string `json:"path"` +} + +type portalStatus struct { + InstanceID string `json:"instance_id"` + Port int `json:"port"` + UpdatedAt string `json:"updated_at"` +} + +func New(cfg Config) *Portal { + return &Portal{ + cfg: cfg, + instanceID: generateInstanceID(), + done: make(chan struct{}), + csrfState: newCSRFState(), + } +} + +func generateInstanceID() string { + pid := os.Getpid() + timestamp := time.Now().UnixNano() + randBytes := make([]byte, 4) + cryptorand.Read(randBytes) + return fmt.Sprintf("%d-%d-%s", pid, timestamp, hex.EncodeToString(randBytes)) +} + +func (p *Portal) Start() error { + p.writeRegistration() + // tailscaleServeLoop not started here — see comment block at top of file + p.wg.Add(3) + go p.claimerLoop() + go p.csrfState.cleanupLoop(p.done, &p.wg) + go p.cleanupRegistrationLoop() + return nil +} + +func (p *Portal) Stop() { + p.closeOnce.Do(func() { + close(p.done) + p.mu.Lock() + wasHolder := p.ln != nil + if p.srv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + 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() + // stopTailscaleServe() not called here — disabled, see top of file + p.deleteRegistration(wasHolder) + p.wg.Wait() + }) +} + +func (p *Portal) isPortalHolder() bool { + p.mu.Lock() + defer p.mu.Unlock() + return p.ln != nil +} + +func (p *Portal) claimerLoop() { + defer p.wg.Done() + + initialDelay := time.Duration(rand.Intn(5000)) * time.Millisecond + + select { + case <-time.After(initialDelay): + case <-p.done: + return + } + + failCount := 0 + + for { + select { + case <-p.done: + return + default: + } + + addr := fmt.Sprintf("%s:%d", p.cfg.Host, p.cfg.PortalPort) + ln, err := net.Listen("tcp", addr) + if err != nil { + if failCount == 9 { + log.Printf("[portal] claimer: portal port %d unavailable after 10 attempts, consider --portal-port or --portal-port 0", p.cfg.PortalPort) + } + failCount++ + backoff := 10*time.Second + time.Duration(rand.Intn(5000))*time.Millisecond + select { + case <-time.After(backoff): + continue + case <-p.done: + return + } + } + + failCount = 0 + + p.mu.Lock() + p.ln = ln + srv := p.newServer() + p.srv = srv + p.mu.Unlock() + + p.writePortalStatus() + // cleanupStaleTailscaleServe() not called here — disabled, see top of file + + err = srv.Serve(ln) + if err != nil && err != http.ErrServerClosed { + log.Printf("[portal] HTTP serve exited unexpectedly: %v", err) + } + + p.mu.Lock() + p.ln = nil + p.srv = nil + p.mu.Unlock() + + select { + case <-time.After(10*time.Second + time.Duration(rand.Intn(5000))*time.Millisecond): + continue + case <-p.done: + return + } + } +} + +func (p *Portal) newServer() *http.Server { + mux := http.NewServeMux() + mux.HandleFunc("/", p.handleDashboard) + mux.HandleFunc("/api/csrf-token", p.handleCSRFToken) + mux.HandleFunc("/api/auth", p.handleAuth) + mux.HandleFunc("/api/list", p.handleList) + mux.HandleFunc("/api/portal-status", p.handlePortalStatus) + mux.HandleFunc("/api/logout", p.handleLogout) + + return &http.Server{ + Handler: mux, + } +} + +func (p *Portal) cleanupRegistrationLoop() { + defer p.wg.Done() + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.cleanupStaleRegistrations() + case <-p.done: + return + } + } +} + +func (p *Portal) tailscaleServeLoop() { + defer p.wg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.mu.Lock() + isHolder := p.ln != nil + serveOK := p.tailscaleServeOK + p.mu.Unlock() + if !isHolder || serveOK { + continue + } + p.ensureTailscaleServe() + case <-p.done: + return + } + } +} + +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 { + p.mu.Lock() + p.tailscaleServeOK = true + p.mu.Unlock() + return + } + + if currentPort > 0 { + log.Printf("[portal] detaching stale tailscale serve at :443 → 127.0.0.1:%d", currentPort) + p.mu.Lock() + p.tailscaleServeOK = false + p.mu.Unlock() + 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 { + p.mu.Lock() + p.tailscaleServeOK = true + p.mu.Unlock() + 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", strconv.Itoa(p.cfg.PortalPort)) +} + +func (p *Portal) stopTailscaleServe() error { + p.mu.Lock() + p.tailscaleServeOK = false + p.mu.Unlock() + + 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() { + if p.cfg.RegistryDir == "" { + return + } + + entries, err := os.ReadDir(p.cfg.RegistryDir) + if err != nil { + return + } + + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "portal.json" { + continue + } + if filepath.Ext(entry.Name()) != ".json" { + continue + } + + data, err := os.ReadFile(filepath.Join(p.cfg.RegistryDir, entry.Name())) + if err != nil { + continue + } + var reg registration + if json.Unmarshal(data, ®) != nil { + continue + } + + if reg.InstanceID == p.instanceID { + continue + } + + if !isRegistrationAlive(reg) { + os.Remove(filepath.Join(p.cfg.RegistryDir, entry.Name())) + } + } +} + +func isRegistrationAlive(reg registration) bool { + if reg.PID <= 0 { + return false + } + proc, err := os.FindProcess(reg.PID) + if err != nil { + return false + } + if proc.Signal(syscall.Signal(0)) != nil { + return false + } + if reg.Port <= 0 { + return true + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", reg.Port), 1*time.Second) + if err != nil { + return false + } + conn.Close() + return true +} + +func (p *Portal) writeRegistration() { + if p.cfg.RegistryDir == "" || p.cfg.PortalPort == 0 { + return + } + + os.MkdirAll(p.cfg.RegistryDir, 0o755) + + reg := registration{ + InstanceID: p.instanceID, + PID: os.Getpid(), + Port: p.cfg.InstancePort, + Host: p.cfg.Host, + RepoName: p.cfg.RepoName, + RepoHash: p.cfg.RepoHash, + StartedAt: time.Now().Format(time.RFC3339), + Path: p.cfg.WorktreePath, + } + + data, _ := json.Marshal(reg) + tmp := filepath.Join(p.cfg.RegistryDir, p.instanceID+".json.tmp") + os.WriteFile(tmp, data, 0o600) + os.Rename(tmp, filepath.Join(p.cfg.RegistryDir, p.instanceID+".json")) +} + +func (p *Portal) deleteRegistration(wasHolder bool) { + if p.cfg.RegistryDir == "" { + return + } + + os.Remove(filepath.Join(p.cfg.RegistryDir, p.instanceID+".json")) + + if wasHolder { + os.Remove(filepath.Join(p.cfg.RegistryDir, "portal.json")) + } +} + +func (p *Portal) writePortalStatus() { + if p.cfg.RegistryDir == "" { + return + } + + status := portalStatus{ + InstanceID: p.instanceID, + Port: p.cfg.PortalPort, + UpdatedAt: time.Now().Format(time.RFC3339), + } + + data, _ := json.Marshal(status) + portalPath := filepath.Join(p.cfg.RegistryDir, "portal.json") + tmp := portalPath + ".tmp" + os.WriteFile(tmp, data, 0o600) + os.Rename(tmp, portalPath) +} + +func (p *Portal) handleDashboard(w http.ResponseWriter, r *http.Request) { + 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 'self'" + 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) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ip := getIP(r) + if !p.csrfState.allowCSRFRequest(ip) { + http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) + return + } + + token := p.csrfState.generate() + if token == "" { + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + cookie := &http.Cookie{ + Name: "mw_csrf", + Value: token, + Path: "/", + SameSite: http.SameSiteStrictMode, + HttpOnly: false, + } + 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{"csrf_token": token}) +} + +func (p *Portal) handleAuth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if p.cfg.AuthToken == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "auth token not configured on server"}) + 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"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + csrfCookie, err := r.Cookie("mw_csrf") + if err != nil { + http.Error(w, "CSRF cookie missing", http.StatusForbidden) + return + } + + if !p.csrfState.verifyAndConsume(csrfCookie.Value, req.CSRFToken) { + http.Error(w, "CSRF verification failed", http.StatusForbidden) + return + } + + if req.Token != p.cfg.AuthToken { + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + cookie := &http.Cookie{ + Name: "mw_token", + Value: p.cfg.AuthToken, + Path: "/", + MaxAge: 86400, + 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"}) +} + +func (p *Portal) handleList(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if !p.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + p.refreshAuthCookie(w, r) + + processes := []map[string]interface{}{} + if p.cfg.RegistryDir != "" { + entries, _ := os.ReadDir(p.cfg.RegistryDir) + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "portal.json" { + continue + } + if filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join(p.cfg.RegistryDir, entry.Name())) + if err != nil { + continue + } + var reg registration + if json.Unmarshal(data, ®) != nil { + continue + } + alive := isProcessAlive(reg.PID, reg.Port) + processes = append(processes, map[string]interface{}{ + "instance_id": reg.InstanceID, + "pid": reg.PID, + "port": reg.Port, + "repo_name": reg.RepoName, + "repo_hash": reg.RepoHash, + "started_at": reg.StartedAt, + "path": reg.Path, + "alive": alive, + }) + } + } + + 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, + "processes": processes, + }) +} + +func (p *Portal) handlePortalStatus(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + p.mu.Lock() + isHolder := p.ln != nil + p.mu.Unlock() + + json.NewEncoder(w).Encode(map[string]bool{"is_portal": isHolder}) +} + +func (p *Portal) handleLogout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + CSRFToken string `json:"csrf_token"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + csrfCookie, err := r.Cookie("mw_csrf") + if err != nil || !p.csrfState.verifyAndConsume(csrfCookie.Value, req.CSRFToken) { + http.Error(w, "CSRF verification failed", http.StatusForbidden) + return + } + + cookie := &http.Cookie{ + Name: "mw_token", + Value: "", + Path: "/", + MaxAge: 0, + 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"}) +} + +func (p *Portal) checkAuth(r *http.Request) bool { + token := extractToken(r) + if token == "" { + return false + } + return token == p.cfg.AuthToken +} + +func (p *Portal) refreshAuthCookie(w http.ResponseWriter, r *http.Request) { + cookie := &http.Cookie{ + Name: "mw_token", + Value: p.cfg.AuthToken, + Path: "/", + MaxAge: 86400, + SameSite: http.SameSiteLaxMode, + HttpOnly: true, + } + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + cookie.Secure = true + } + http.SetCookie(w, cookie) +} + +func extractToken(r *http.Request) string { + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return auth[7:] + } + if token := r.URL.Query().Get("token"); token != "" { + return token + } + if cookie, err := r.Cookie("mw_token"); err == nil { + return cookie.Value + } + return "" +} + +func getIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if idx := strings.Index(xff, ","); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + if idx := strings.Index(xff, " "); idx != -1 { + return xff[:idx] + } + return xff + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func isProcessAlive(pid int, port int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + if proc.Signal(syscall.Signal(0)) != nil { + return false + } + if port > 0 { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 1*time.Second) + if err == nil { + conn.Close() + return true + } + // TCP failed — port may be wrong (upgrade scenario) or not yet bound. + // Fall back to PID-only check: if process is alive, we're good. + } + return true +} + +type csrfState struct { + 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), + rateLimits: make(map[string]time.Time), + authLimits: make(map[string][]time.Time), + } +} + +func (s *csrfState) generate() string { + s.mu.Lock() + if len(s.used) >= 10000 { + s.mu.Unlock() + return "" + } + s.mu.Unlock() + + b := make([]byte, 32) + cryptorand.Read(b) + token := hex.EncodeToString(b) + + s.mu.Lock() + s.pending[token] = time.Now() + s.mu.Unlock() + return token +} + +func (s *csrfState) verifyAndConsume(cookieValue, bodyValue string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if cookieValue != bodyValue { + return false + } + + if createdAt, exists := s.pending[cookieValue]; exists { + if time.Since(createdAt) > 5*time.Minute { + delete(s.pending, cookieValue) + return false + } + delete(s.pending, cookieValue) + } else if usedTime, exists := s.used[cookieValue]; exists { + if time.Since(usedTime) <= 5*time.Minute { + return false + } + delete(s.used, cookieValue) + } else { + return false + } + + s.used[cookieValue] = time.Now() + return true +} + +func (s *csrfState) allowCSRFRequest(ip string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.rateLimits) >= 10000 { + return false + } + + last, exists := s.rateLimits[ip] + if exists && time.Since(last) < time.Second { + return false + } + s.rateLimits[ip] = time.Now() + return true +} + +func (s *csrfState) cleanupLoop(done chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.cleanup() + case <-done: + return + } + } +} + +func (s *csrfState) cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := time.Now().Add(-5 * time.Minute) + for k, v := range s.pending { + if v.Before(cutoff) { + delete(s.pending, k) + } + } + for k, v := range s.used { + if v.Before(cutoff) { + delete(s.used, k) + } + } + for k, v := range s.rateLimits { + if v.Before(cutoff) { + 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 +} + +var tsDNSNameCmd = func(ctx context.Context) (string, error) { + cmd := exec.CommandContext(ctx, "tailscale", "status", "--json") + out, err := cmd.Output() + if err != nil { + return "", err + } + var status struct { + Self struct { + DNSName string `json:"DNSName"` + } `json:"Self"` + } + if err := json.Unmarshal(out, &status); err != nil { + return "", err + } + if status.Self.DNSName == "" { + return "", fmt.Errorf("no DNS name") + } + return strings.TrimSuffix(status.Self.DNSName, "."), nil +} + +func TailscaleDNSName() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + name, err := tsDNSNameCmd(ctx) + if err != nil { + return "" + } + return name +} diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go new file mode 100644 index 0000000..41ca3dc --- /dev/null +++ b/internal/portal/portal_test.go @@ -0,0 +1,1263 @@ +package portal + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestGenerateInstanceID(t *testing.T) { + id1 := generateInstanceID() + id2 := generateInstanceID() + + if id1 == "" { + t.Fatal("generateInstanceID returned empty string") + } + if id1 == id2 { + t.Fatal("generateInstanceID returned same ID twice") + } + + for i, c := range id1 { + if c == '-' { + continue + } + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Fatalf("instanceID contains invalid character %c at position %d", c, i) + } + } +} + +func TestRegistrationFileWriteRead(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + PortalPort: 12345, + InstancePort: 54321, + Host: "localhost", + AuthToken: "test-token", + RegistryDir: tmpDir, + RepoName: "test-repo", + RepoHash: "abc123", + WorktreePath: "/path/to/test-worktree", + } + + p := New(cfg) + p.writeRegistration() + + regFile := filepath.Join(tmpDir, p.instanceID+".json") + data, err := os.ReadFile(regFile) + if err != nil { + t.Fatalf("failed to read registration file: %v", err) + } + + var reg registration + if err := json.Unmarshal(data, ®); err != nil { + t.Fatalf("registration file is not valid JSON: %v", err) + } + + if reg.InstanceID != p.instanceID { + t.Fatalf("expected instanceID %q, got %q", p.instanceID, reg.InstanceID) + } + if reg.PID != os.Getpid() { + t.Fatalf("expected PID %d, got %d", os.Getpid(), reg.PID) + } + if reg.Port != 54321 { + t.Fatalf("expected port %d, got %d", 54321, reg.Port) + } + if reg.RepoName != "test-repo" { + t.Fatalf("expected RepoName %q, got %q", "test-repo", reg.RepoName) + } + if reg.RepoHash != "abc123" { + t.Fatalf("expected RepoHash %q, got %q", "abc123", reg.RepoHash) + } + if reg.StartedAt == "" { + t.Fatal("expected StartedAt to be set") + } + if reg.Path != "/path/to/test-worktree" { + t.Fatalf("expected Path %q, got %q", "/path/to/test-worktree", reg.Path) + } +} + +func TestIsPortalHolder(t *testing.T) { + cfg := Config{PortalPort: 12345} + p := New(cfg) + + if p.isPortalHolder() { + t.Fatal("new Portal should not be a portal holder") + } +} + +func TestIsProcessAlive_PIDNotExist(t *testing.T) { + alive := isProcessAlive(999999, 0) + if alive { + t.Fatal("process with non-existent PID should not be alive") + } +} + +func TestIsProcessAlive_PIDExistsNoPort(t *testing.T) { + alive := isProcessAlive(os.Getpid(), 0) + if !alive { + t.Fatal("current process should be alive even without port") + } +} + +func TestIsProcessAlive_InvalidPID(t *testing.T) { + alive := isProcessAlive(-1, 0) + if alive { + t.Fatal("negative PID should not be considered alive") + } +} + +func TestCSRFGenerate(t *testing.T) { + state := newCSRFState() + token := state.generate() + + if len(token) != 64 { + t.Fatalf("expected 64 char hex token, got %d", len(token)) + } + + token2 := state.generate() + if token == token2 { + t.Fatal("two generated tokens should be different") + } +} + +func TestCSRFVerifyAndConsume_Success(t *testing.T) { + state := newCSRFState() + token := state.generate() + + if !state.verifyAndConsume(token, token) { + t.Fatal("verifyAndConsume should succeed with matching token") + } + + if state.verifyAndConsume(token, token) { + t.Fatal("second verification should fail (token already used)") + } +} + +func TestCSRFVerifyAndConsume_Mismatch(t *testing.T) { + state := newCSRFState() + token := state.generate() + + if state.verifyAndConsume(token, "different") { + t.Fatal("verifyAndConsume should fail with mismatched token") + } +} + +func TestCSRFVerifyAndConsume_Reuse(t *testing.T) { + state := newCSRFState() + token := state.generate() + + if !state.verifyAndConsume(token, token) { + t.Fatal("first verification should succeed") + } + if state.verifyAndConsume(token, token) { + t.Fatal("second verification should fail (token already used)") + } +} + +func TestCSRFVerifyAndConsume_Expired(t *testing.T) { + state := newCSRFState() + state.used["old-token"] = time.Now().Add(-6 * time.Minute) + + if !state.verifyAndConsume("old-token", "old-token") { + t.Fatal("expired token should be accepted (spec: not in used OR in used but > 5min TTL)") + } +} + +func TestCSRFAllowCSRFRequest_FirstRequest(t *testing.T) { + state := newCSRFState() + + if !state.allowCSRFRequest("192.168.1.1") { + t.Fatal("first request from IP should be allowed") + } +} + +func TestCSRFAllowCSRFRequest_RateLimit(t *testing.T) { + state := newCSRFState() + ip := "192.168.1.2" + + if !state.allowCSRFRequest(ip) { + t.Fatal("first request should be allowed") + } + if state.allowCSRFRequest(ip) { + t.Fatal("second request within 1 second should be rate limited") + } +} + +func TestCSRFAllowCSRFRequest_DifferentIPs(t *testing.T) { + state := newCSRFState() + + if !state.allowCSRFRequest("192.168.1.1") { + t.Fatal("first IP should be allowed") + } + if !state.allowCSRFRequest("192.168.1.2") { + t.Fatal("different IP should be allowed") + } +} + +func TestCSRFCleanup(t *testing.T) { + state := newCSRFState() + + state.used["old-token"] = time.Now().Add(-6 * time.Minute) + state.rateLimits["old-ip"] = time.Now().Add(-6 * time.Minute) + + state.cleanup() + + if _, exists := state.used["old-token"]; exists { + t.Fatal("old token should have been cleaned up") + } + if _, exists := state.rateLimits["old-ip"]; exists { + t.Fatal("old IP rate limit should have been cleaned up") + } +} + +func TestPortalStructFields(t *testing.T) { + cfg := Config{ + PortalPort: 12345, + Host: "localhost", + AuthToken: "test-token", + RegistryDir: "/tmp/portal", + RepoName: "test-repo", + RepoHash: "abc123", + } + + p := New(cfg) + + if p.cfg.PortalPort != 12345 { + t.Fatalf("expected PortalPort 12345, got %d", p.cfg.PortalPort) + } + if p.instanceID == "" { + t.Fatal("instanceID should not be empty") + } + if p.done == nil { + t.Fatal("done channel should be initialized") + } + if p.csrfState == nil { + t.Fatal("csrfState should be initialized") + } +} + +func TestPortalCloseOnce(t *testing.T) { + cfg := Config{PortalPort: 12345} + p := New(cfg) + + p.Stop() + p.Stop() + p.Stop() + p.Stop() + p.Stop() +} + +func TestGetIP(t *testing.T) { + tests := []struct { + remoteAddr string + xff string + expected string + }{ + {"192.168.1.1:12345", "", "192.168.1.1"}, + {"192.168.1.1:12345", "10.0.0.1", "10.0.0.1"}, + {"192.168.1.1:12345", "10.0.0.1, 10.0.0.2", "10.0.0.1"}, + } + + for _, tt := range tests { + req := &http.Request{ + RemoteAddr: tt.remoteAddr, + Header: http.Header{}, + } + if tt.xff != "" { + req.Header.Set("X-Forwarded-For", tt.xff) + } + + ip := getIP(req) + if ip != tt.expected { + t.Errorf("getIP(remoteAddr=%q, XFF=%q) = %q, expected %q", + tt.remoteAddr, tt.xff, ip, tt.expected) + } + } +} + +func TestRegistration_NoAuthTokenInFile(t *testing.T) { + tmpDir := t.TempDir() + + cfg := Config{ + PortalPort: 12345, + AuthToken: "secret-token-should-not-be-in-file", + RegistryDir: tmpDir, + RepoName: "test-repo", + RepoHash: "abc123", + } + + p := New(cfg) + p.writeRegistration() + + regFile := filepath.Join(tmpDir, p.instanceID+".json") + data, _ := os.ReadFile(regFile) + + var reg map[string]interface{} + json.Unmarshal(data, ®) + + if _, exists := reg["auth_token"]; exists { + t.Fatal("registration file should not contain auth_token") + } +} + +func TestCSRFState_MaxCapacity(t *testing.T) { + state := &csrfState{ + used: make(map[string]time.Time), + rateLimits: make(map[string]time.Time), + } + + for i := 0; i < 9999; i++ { + state.rateLimits[string(rune('0'+i%10))+string(rune('0'+(i/10)%10))] = time.Now() + } + + if !state.allowCSRFRequest("new-ip") { + t.Fatal("should allow request when map is at capacity (10000 limit is check before update)") + } +} + +func TestPortalMuConcurrentAccess(t *testing.T) { + cfg := Config{PortalPort: 12345} + p := New(cfg) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + _ = p.isPortalHolder() + } + }() + } + + wg.Wait() +} + +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 12345" { + t.Fatalf("expected second action 'serve --bg 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 12345" { + t.Fatalf("expected 'serve --bg 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() +} + +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