From c3829128b59bb97796bd1a27492dd35a832a4415 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 13:36:57 +0800 Subject: [PATCH 01/10] docs: add Tailscale Funnel sidecar design Capture the approved design for optional public Web Service access via a Go tsnet sidecar (codeg-tsnet), with independent state dir, desktop OAuth, server auth-key, and Windows-friendly packaging. --- ...7-14-libtailscale-funnel-sidecar-design.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-libtailscale-funnel-sidecar-design.md diff --git a/docs/superpowers/specs/2026-07-14-libtailscale-funnel-sidecar-design.md b/docs/superpowers/specs/2026-07-14-libtailscale-funnel-sidecar-design.md new file mode 100644 index 000000000..40085842d --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-libtailscale-funnel-sidecar-design.md @@ -0,0 +1,363 @@ +# Codeg Tailscale Funnel Sidecar Design + +**Date:** 2026-07-14 +**Status:** Approved for implementation planning +**Scope:** Desktop embedded Web Service + standalone `codeg-server` +**Approach:** Go `tsnet` sidecar (`codeg-tsnet`), not in-process libtailscale C bindings + +## Problem + +Codeg can expose a local Web Service (desktop embedded HTTP server or standalone `codeg-server`) on LAN/loopback with token auth. Users also need optional **public remote access** without: + +- taking over the system Tailscale daemon +- sharing the system Tailscale node identity/state +- replacing Codeg's existing application token auth + +## Goals + +1. Optional public HTTPS access via **Tailscale Funnel**. +2. Application always uses its **own userspace node** and **independent state directory**. +3. Never manage or replace system Tailscale. +4. Desktop auth: browser OAuth/login guidance. +5. Server/Docker auth: auth key / headless credentials. +6. Enablement is an optional switch inside Web Service settings (not always-on). +7. Ship on the existing release matrix, including Windows, via sidecar packaging similar to `codeg-mcp`. +8. Funnel failure must not break local Web Service. + +## Non-goals + +- Using system `tailscaled` or `tailscale up` against the host node. +- Making Funnel the only access path. +- Replacing Codeg bearer token auth with Tailscale identity auth. +- Exposing raw tailnet `listen/accept` as the primary app serving path. +- Linking Go/CGO libtailscale into the main Rust binary. + +## Why sidecar instead of in-process libtailscale + +In-process libtailscale embeds `tsnet` through a C archive. That path is attractive on Unix, but: + +- connection plumbing relies on Unix-style FD passing (`socketpair`, rights) +- upstream Windows port work is incomplete +- CGO + static archive complicates the Rust/Tauri build matrix + +A dedicated Go sidecar: + +- uses pure Go `tsnet` (first-class on Windows/macOS/Linux) +- keeps Funnel control in-process to the sidecar only +- reuses Codeg's existing sidecar packaging pattern (`codeg-mcp`) +- isolates crashes: sidecar death disables Funnel, not Axum + +## Architecture + +```text +Settings UI / env + | + v +codeg / codeg-server (Rust) + - Axum Web Service on 0.0.0.0: + - TailscaleController (control plane only) + | + | spawn + localhost control API + v +codeg-tsnet (Go sidecar) + - tsnet.Server userspace node + - state: /tailscale + - OAuth / auth key + - Funnel -> http://127.0.0.1: + | + v +Public HTTPS Funnel URL +``` + +### Components + +#### 1. `codeg-tsnet` (new Go binary) + +Responsibilities: + +- create/configure userspace node +- independent state dir + hostname +- login (browser URL or auth key) +- enable/disable Funnel to localhost web port +- report structured status +- graceful shutdown + +Non-responsibilities: + +- no business API +- no static asset serving +- no replacement for Axum + +#### 2. `TailscaleController` (Rust) + +Lives under `src-tauri` web/runtime code. + +Responsibilities: + +- locate sidecar binary +- start/stop lifecycle tied to Web Service funnel switch +- poll/map status for Tauri commands and HTTP handlers +- degrade cleanly when unsupported/missing/crashed + +Binary resolution order (aligned with `codeg-mcp`): + +1. `CODEG_TSNET_BIN` +2. sibling of current executable +3. Tauri externalBin / resource path + +#### 3. Web Service integration + +- Config flag: `funnelEnabled` +- Start order: bind web port successfully -> start sidecar -> enable Funnel +- Stop order: disable Funnel / stop sidecar -> stop web server (desktop path) +- `codeg-server` remains externally managed; Funnel can stop without stopping the whole process + +#### 4. Frontend (Web Service settings) + +Add a Funnel section to the existing Web Service page: + +- enable switch +- state badge +- open login action +- funnel URL + copy/open +- error text +- note that Codeg token is still required +- note that Codeg uses a private node/state dir and does not touch system Tailscale + +## State and identity + +| Item | Value | +|---|---| +| State directory | `/tailscale` (desktop: effective app data dir) | +| Override | `CODEG_TS_STATE_DIR` | +| Hostname | `codeg-` | +| Override | `CODEG_TS_HOSTNAME` | +| Node type | userspace `tsnet`, Codeg-owned | +| System Tailscale | untouched | + +The short id should be stable per installation/data dir so restarts reattach to the same node identity stored in the state directory. + +## Control protocol + +Sidecar starts with: + +```bash +codeg-tsnet \ + --control-addr 127.0.0.1:0 \ + --state-dir \ + --hostname codeg-xxxx \ + [--auth-key ...] \ + [--control-token ...] +``` + +Bootstrap on stdout (single JSON line): + +```json +{"controlAddr":"127.0.0.1:54321","pid":12345} +``` + +Localhost HTTP JSON API: + +| Endpoint | Purpose | +|---|---| +| `GET /status` | state, IPs, loginUrl, funnelUrl, lastError | +| `POST /up` | connect; optional `{ "authKey": "..." }` | +| `POST /funnel` | `{ "enabled": true, "localhostPort": 3080 }` | +| `POST /logout` | optional session clear | +| `POST /shutdown` | graceful exit | + +Security: + +- control server binds `127.0.0.1` only +- optional/random control token required on requests +- auth keys never logged +- control port never published via Funnel + +## State machine + +```text +stopped + -> starting + -> needs_login + -> connecting + -> online + -> funnel_enabling + -> funnel_ready + -> error + -> stopping + -> stopped +``` + +`needs_login` includes `loginUrl` for desktop browser guidance. +Controller polls `/status` every 1-2s while login/funnel is in progress. + +## Auth flows + +### Desktop (browser OAuth) + +1. User enables Funnel in Web Service settings. +2. Ensure local Web Service is running on `port`. +3. Start `codeg-tsnet` with Codeg state dir/hostname. +4. `POST /up` without auth key. +5. If `needs_login`, open `loginUrl` in browser and show waiting UI. +6. On `online`, `POST /funnel` with `localhostPort=port`. +7. Show `funnelUrl`. +8. On disable/stop: disable Funnel and shutdown sidecar. + +### Server / Docker (auth key) + +1. `CODEG_TS_FUNNEL=1` (or persisted equivalent) and web server running. +2. Require `CODEG_TS_AUTHKEY`; if missing, fail Funnel with explicit error (no interactive browser wait). +3. Start sidecar / `POST /up` with auth key. +4. Enable Funnel to local web port. +5. Emit Funnel URL on stderr/logs and status API. + +### Environment variables + +| Variable | Meaning | +|---|---| +| `CODEG_TS_FUNNEL` | enable Funnel for server mode | +| `CODEG_TS_AUTHKEY` | headless auth key | +| `CODEG_TS_STATE_DIR` | override state directory | +| `CODEG_TS_HOSTNAME` | override node hostname | +| `CODEG_TSNET_BIN` | override sidecar path | + +## API / config surface + +### Config extension + +```ts +WebServiceConfig { + token?: string | null + port?: number | null + autoStart: boolean + funnelEnabled: boolean +} +``` + +### Status extension + +```ts +TailscaleFunnelStatus { + supported: boolean + enabled: boolean + state: + | "stopped" + | "starting" + | "needs_login" + | "connecting" + | "online" + | "funnel_enabling" + | "funnel_ready" + | "error" + | "stopping" + loginUrl?: string + funnelUrl?: string + hostname?: string + ipv4?: string + lastError?: string +} +``` + +Expose via: + +- Tauri commands for desktop settings +- HTTP handlers for web mode / server UI +- either extend web-server status or add dedicated funnel status endpoints + +Suggested stable error keys: + +- `tailscale.sidecar_missing` +- `tailscale.start_failed` +- `tailscale.login_timeout` +- `tailscale.funnel_denied` +- `tailscale.funnel_failed` +- `tailscale.authkey_required` +- `tailscale.unsupported` + +## Failure handling + +| Scenario | Behavior | +|---|---| +| Sidecar binary missing | Funnel unavailable; local web continues | +| Login timeout | error state; retryable; web continues | +| Funnel not permitted on account/policy | error with actionable key | +| Sidecar crash | mark error; optional single restart; web continues | +| Port change while Funnel enabled | rebind Funnel to new port or recycle sidecar cleanly | +| Server external mode stop web | reject stopping whole server; only Funnel stop is valid | + +## Packaging and CI + +Follow `codeg-mcp` patterns. + +### Artifacts + +- Desktop: `codeg-tsnet-` via Tauri `externalBin` +- Server tarball/zip: place next to `codeg-server` and `codeg-mcp` +- Docker image: include `codeg-tsnet` discoverable by sibling lookup + +### GitHub Actions changes + +- install Go in desktop/server build jobs +- build `codeg-tsnet` for each release matrix target: + - macOS x64/arm64 + - Linux x64/arm64 + - Windows x64 (and desktop Windows arm64 if matrix includes it) +- smoke `--help` / version on native runners +- missing/failed Funnel support must not fail basic app startup tests +- release packaging must include the sidecar where the platform build succeeds + +Windows is a first-class packaging target with sidecar. Runtime Funnel on Windows is an intended supported path; if a host-specific runtime failure occurs, surface a clear error and keep local web working. + +## Security + +1. Funnel makes the service publicly reachable; **Codeg token remains mandatory**. +2. State dir permissions restricted to the user/process owner. +3. Auth keys and control tokens never appear in info logs or UI secrets panels as re-readable secrets beyond intentional local config storage. +4. Control API localhost-only. +5. Do not write Funnel public endpoints that bypass app auth. + +## Testing strategy + +1. **Unit:** state machine, config parsing, binary lookup, error key mapping. +2. **Controller integration:** mock sidecar HTTP for login/online/funnel/crash paths. +3. **Sidecar contract tests:** schema and status transitions with a fake/minimal control server or tsnet test doubles where practical. +4. **UI tests:** switch, needs_login, funnel URL, error rendering. +5. **Manual acceptance:** real Tailscale account on macOS/Linux; auth-key path for server; Windows verification as available. + +Automated CI should not require a real Tailscale account. + +## Acceptance criteria + +1. Enabling Funnel on desktop guides browser login when needed and yields a public HTTPS URL. +2. Access via Funnel still requires Codeg token. +3. Node state lives only under Codeg data dir; system Tailscale remains untouched. +4. Disabling Funnel / stopping Web Service stops the sidecar. +5. `codeg-server` can enable Funnel headlessly with auth key. +6. Release artifacts for Linux/macOS/Windows include `codeg-tsnet` when built. +7. Sidecar absence degrades Funnel only. +8. PR description is bilingual (Chinese + English). + +## Implementation phases + +1. `codeg-tsnet` MVP + control protocol + status schema. +2. Rust `TailscaleController` + Web Service config/status hooks. +3. Settings UI + i18n. +4. Sidecar packaging in prepare-sidecars / server / Docker / release workflow. +5. Server env path, docs, and acceptance verification. + +## Open implementation choices (non-blocking) + +These may be settled in the implementation plan without changing the architecture: + +- exact control auth header name +- whether funnel fields are embedded in web-server status or separate endpoints +- whether sidecar is always ephemeral-restarted or long-lived across web restarts when funnel stays enabled +- exact Funnel URL formatting from tsnet cert domain / serve config + +## References + +- Existing Web Service: `src-tauri/src/web/mod.rs`, settings UI under `src/components/settings/web-service-settings.tsx` +- Existing sidecar pattern: `codeg-mcp` + `pnpm tauri:prepare-sidecars` +- Upstream capability basis: Tailscale `tsnet` + Funnel serve config +- Rejected alternative: in-process libtailscale C binding as primary integration From d2bdc3314d148aa546f4957a83d8dfbe5c5f6fda Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 13:44:27 +0800 Subject: [PATCH 02/10] docs: add Tailscale Funnel sidecar implementation plan Break the approved Funnel sidecar design into task-sized implementation steps covering codeg-tsnet, Rust controller, UI, packaging, and PR. --- .../2026-07-14-libtailscale-funnel-sidecar.md | 931 ++++++++++++++++++ 1 file changed, 931 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-libtailscale-funnel-sidecar.md diff --git a/docs/superpowers/plans/2026-07-14-libtailscale-funnel-sidecar.md b/docs/superpowers/plans/2026-07-14-libtailscale-funnel-sidecar.md new file mode 100644 index 000000000..dbdf17c81 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-libtailscale-funnel-sidecar.md @@ -0,0 +1,931 @@ +# Tailscale Funnel Sidecar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Codeg Web Service optional public HTTPS access via a private Tailscale Funnel userspace node, packaged as a Go `codeg-tsnet` sidecar for desktop and `codeg-server` (including Windows). + +**Architecture:** Rust owns Axum and app token auth. A Go sidecar owns `tsnet` identity, OAuth/auth-key login, and Funnel reverse-proxy to `http://127.0.0.1:`. Control plane is localhost HTTP JSON with bootstrap line on stdout. Funnel failures degrade only Funnel; local web keeps running. + +**Tech Stack:** Go + `tailscale.com/tsnet`, Rust/Axum/Tauri, React/next-intl settings UI, Node `prepare-sidecars.mjs`, GitHub Actions release matrix, Docker. + +## Global Constraints + +- Never manage, replace, or write into system Tailscale state. +- Always use Codeg-owned state dir: `/tailscale` (override `CODEG_TS_STATE_DIR`). +- Hostname default: `codeg-` (override `CODEG_TS_HOSTNAME`). +- Desktop auth: browser OAuth/login URL. Server/Docker auth: `CODEG_TS_AUTHKEY` required when Funnel is enabled. +- Existing Codeg bearer token remains mandatory over Funnel. +- Sidecar binary name: `codeg-tsnet` / `codeg-tsnet.exe`. +- Binary resolution: `CODEG_TSNET_BIN` -> exe sibling -> Tauri externalBin/resource path. +- Packaging must follow `codeg-mcp` patterns and ship on macOS/Linux/Windows. +- Funnel missing/failed must not break local Web Service startup. +- Control API binds `127.0.0.1` only; auth header `X-Codeg-Tsnet-Token`. +- PR description must be bilingual (Chinese + English). + +--- + +## File Structure + +### Create +- `codeg-tsnet/go.mod` +- `codeg-tsnet/main.go` +- `codeg-tsnet/control.go` +- `codeg-tsnet/status.go` +- `codeg-tsnet/funnel.go` +- `codeg-tsnet/main_test.go` +- `codeg-tsnet/control_test.go` +- `src-tauri/src/web/tailscale/mod.rs` +- `src-tauri/src/web/tailscale/binary.rs` +- `src-tauri/src/web/tailscale/controller.rs` +- `src-tauri/src/web/tailscale/protocol.rs` +- `src-tauri/src/web/tailscale/status.rs` +- `src-tauri/src/web/handlers/tailscale.rs` +- `src/components/settings/web-service-funnel-section.tsx` +- `src/components/settings/web-service-funnel-section.test.tsx` + +### Modify +- `src-tauri/src/web/mod.rs` -- config flag, start/stop hooks, status aggregation +- `src-tauri/src/web/handlers/mod.rs` -- export tailscale handlers +- `src-tauri/src/web/router.rs` -- register Funnel endpoints +- `src-tauri/src/lib.rs` -- register Tauri commands + auto-start Funnel after auto-start web +- `src-tauri/src/app_state.rs` -- hold `TailscaleController` handle +- `src-tauri/src/bin/codeg_server.rs` -- env-driven Funnel enable on server boot +- `src-tauri/scripts/prepare-sidecars.mjs` -- build/stage `codeg-tsnet` too +- `src-tauri/tauri.conf.json` -- `externalBin` add `binaries/codeg-tsnet` +- `.github/workflows/release.yml` -- Go setup, build/package/smoke `codeg-tsnet` +- `Dockerfile`, `Dockerfile.ci` -- include `codeg-tsnet` +- `install.sh`, `install.ps1` -- managed bin list includes `codeg-tsnet` +- `src/lib/api.ts` -- config/status/API wrappers +- `src/components/settings/web-service-settings.tsx` -- mount Funnel section +- `src/i18n/messages/en.json`, `zh-CN.json` (+ other locales with same keys) +- `README.md` / `docs/readme/README.zh-CN.md` -- Funnel env/docs section (minimal) + +### Responsibility Boundaries +- `codeg-tsnet/*`: Tailscale userspace node + Funnel only +- `web/tailscale/*`: spawn/control/status mapping only +- `web/mod.rs`: web lifecycle orchestration only +- UI section: Funnel UX only; does not reimplement token/port logic + +--- + +### Task 1: `codeg-tsnet` MVP + control protocol + +**Files:** +- Create: `codeg-tsnet/go.mod` +- Create: `codeg-tsnet/main.go` +- Create: `codeg-tsnet/control.go` +- Create: `codeg-tsnet/status.go` +- Create: `codeg-tsnet/funnel.go` +- Create: `codeg-tsnet/main_test.go` +- Create: `codeg-tsnet/control_test.go` + +**Interfaces:** +- Consumes: none +- Produces: + - CLI: + ```text + codeg-tsnet \ + --control-addr 127.0.0.1:0 \ + --state-dir \ + --hostname codeg-xxxx \ + [--auth-key ...] \ + --control-token + ``` + - stdout bootstrap (exactly one JSON line, then logs to stderr only): + ```json + {"controlAddr":"127.0.0.1:54321","pid":12345} + ``` + - HTTP API (header `X-Codeg-Tsnet-Token: ` required on every request): + - `GET /status` -> `StatusResponse` + - `POST /up` body optional `{ "authKey": "..." }` + - `POST /funnel` body `{ "enabled": true, "localhostPort": 3080 }` + - `POST /logout` + - `POST /shutdown` + - `StatusResponse` JSON camelCase: + ```go + type StatusResponse struct { + State string `json:"state"` + LoginURL string `json:"loginUrl,omitempty"` + FunnelURL string `json:"funnelUrl,omitempty"` + Hostname string `json:"hostname,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + LastError string `json:"lastError,omitempty"` + ErrorKey string `json:"errorKey,omitempty"` + BackendState string `json:"backendState,omitempty"` + } + ``` + - `state` values: `stopped|starting|needs_login|connecting|online|funnel_enabling|funnel_ready|error|stopping` + +- [ ] **Step 1: Scaffold Go module** + +```bash +mkdir -p codeg-tsnet +cd codeg-tsnet +go mod init github.com/xintaofei/codeg/codeg-tsnet +go get tailscale.com@v1.82.5 +``` + +Pin a known-good Tailscale module version in `go.mod`. Start with `v1.82.5`; if the implementer toolchain rejects it, bump only the module pin, not the control protocol. + +- [ ] **Step 2: Write failing unit tests for protocol parsing and auth middleware** + +```go +// codeg-tsnet/control_test.go +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusJSONShape(t *testing.T) { + s := StatusResponse{State: "needs_login", LoginURL: "https://login.tailscale.com/a/x"} + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if m["state"] != "needs_login" { + t.Fatalf("state=%v", m["state"]) + } + if m["loginUrl"] != "https://login.tailscale.com/a/x" { + t.Fatalf("loginUrl=%v", m["loginUrl"]) + } +} + +func TestAuthMiddlewareRejectsMissingToken(t *testing.T) { + h := withToken("secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + })) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/status", nil) + h.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("code=%d", rr.Code) + } +} +``` + +- [ ] **Step 3: Run tests, expect compile/fail** + +Run: `cd codeg-tsnet && go test ./...` +Expected: FAIL (missing types/handlers) + +- [ ] **Step 4: Implement minimal sidecar** + +Implement in pure Go (no CGO): + +```go +// main.go responsibilities +// - parse flags +// - create tsnet.Server{Dir, Hostname, AuthKey} +// - start control HTTP on 127.0.0.1:0 +// - print bootstrap JSON to stdout once +// - wait for /shutdown or signal +``` + +```go +// funnel.go responsibilities +// - when enabled: use tsnet Serve/Funnel APIs to reverse-proxy to +// http://127.0.0.1: +// - when disabled: clear serve/funnel config +// - derive public HTTPS funnel URL from serve config / cert domains +``` + +Important implementation notes: +- Use `tsnet.Server` userspace only; do not shell out to system `tailscale`. +- Prefer official Funnel/serve configuration APIs available in the pinned Tailscale module (LocalClient ServeConfig / Funnel helpers). If the module surface differs slightly, keep the control protocol fixed and adapt only the internal call. +- On desktop `/up` without auth key, expose `loginUrl` when backend needs interactive login. +- Never log auth keys or control tokens. +- All application logs go to stderr after bootstrap line. + +- [ ] **Step 5: Re-run unit tests** + +Run: `cd codeg-tsnet && go test ./...` +Expected: PASS + +- [ ] **Step 6: Manual local smoke (binary lifecycle; no real Tailscale account required)** + +```bash +mkdir -p /tmp/codeg-ts-state +go build -o codeg-tsnet . +./codeg-tsnet --control-addr 127.0.0.1:0 --state-dir /tmp/codeg-ts-state --hostname codeg-test --control-token testtoken +# capture bootstrap JSON from stdout +curl -H "X-Codeg-Tsnet-Token: testtoken" http://127.0.0.1:/status +curl -X POST -H "X-Codeg-Tsnet-Token: testtoken" http://127.0.0.1:/shutdown +``` + +Expected: bootstrap JSON prints; `/status` returns JSON; process exits on `/shutdown`. + +- [ ] **Step 7: Commit** + +```bash +git add codeg-tsnet +git commit -m "feat(tsnet): add codeg-tsnet control-plane sidecar MVP" +``` + +--- + +### Task 2: Rust protocol types + binary locator + +**Files:** +- Create: `src-tauri/src/web/tailscale/mod.rs` +- Create: `src-tauri/src/web/tailscale/protocol.rs` +- Create: `src-tauri/src/web/tailscale/binary.rs` +- Create: `src-tauri/src/web/tailscale/status.rs` +- Modify: `src-tauri/src/web/mod.rs` (add `pub mod tailscale;`) + +**Interfaces:** +- Consumes: Task 1 control protocol +- Produces: + ```rust + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct SidecarBootstrap { + pub control_addr: String, + pub pid: u32, + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct SidecarStatus { + pub state: String, + pub login_url: Option, + pub funnel_url: Option, + pub hostname: Option, + pub ipv4: Option, + pub last_error: Option, + pub error_key: Option, + pub backend_state: Option, + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + pub struct TailscaleFunnelStatus { + pub supported: bool, + pub enabled: bool, + pub state: String, + pub login_url: Option, + pub funnel_url: Option, + pub hostname: Option, + pub ipv4: Option, + pub last_error: Option, + } + + pub fn locate_codeg_tsnet_binary() -> Option; + pub fn default_state_dir(data_dir: &Path) -> PathBuf; // data_dir.join("tailscale") + pub fn default_hostname(data_dir: &Path) -> String; // codeg-<8 hex of hash(data_dir)> + ``` + +Error keys (stable): +- `tailscale.sidecar_missing` +- `tailscale.start_failed` +- `tailscale.login_timeout` +- `tailscale.funnel_denied` +- `tailscale.funnel_failed` +- `tailscale.authkey_required` +- `tailscale.unsupported` + +- [ ] **Step 1: Write failing Rust unit tests** + +```rust +#[test] +fn parses_bootstrap_json() { + let raw = r#"{"controlAddr":"127.0.0.1:9","pid":42}"#; + let b: SidecarBootstrap = serde_json::from_str(raw).unwrap(); + assert_eq!(b.control_addr, "127.0.0.1:9"); + assert_eq!(b.pid, 42); +} + +#[test] +fn hostname_is_stable_for_same_data_dir() { + let p = std::path::PathBuf::from("/tmp/codeg-data-a"); + assert_eq!(default_hostname(&p), default_hostname(&p)); + assert!(default_hostname(&p).starts_with("codeg-")); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cd src-tauri && cargo test --no-default-features web::tailscale -- --nocapture` +Expected: FAIL (module missing) + +- [ ] **Step 3: Implement protocol + locator** + +Mirror `locate_codeg_mcp_binary()` from `src-tauri/src/acp/connection.rs`: + +```rust +pub fn locate_codeg_tsnet_binary() -> Option { + let filename = if cfg!(windows) { "codeg-tsnet.exe" } else { "codeg-tsnet" }; + if let Some(raw) = std::env::var_os("CODEG_TSNET_BIN") { + let candidate = PathBuf::from(raw); + if is_executable_file(&candidate) { return Some(candidate); } + } + if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())) { + let candidate = dir.join(filename); + if is_executable_file(&candidate) { return Some(candidate); } + } + which::which(filename).ok().filter(|p| is_executable_file(p)) +} +``` + +Reuse executable checks similar to MCP locator. Do not invent PATH heuristics beyond that. + +- [ ] **Step 4: Re-run tests** + +Run: `cd src-tauri && cargo test --no-default-features web::tailscale` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src-tauri/src/web/tailscale src-tauri/src/web/mod.rs +git commit -m "feat(web): add Tailscale sidecar protocol types and binary locator" +``` + +--- + +### Task 3: `TailscaleController` lifecycle + +**Files:** +- Create: `src-tauri/src/web/tailscale/controller.rs` +- Modify: `src-tauri/src/web/tailscale/mod.rs` +- Modify: `src-tauri/src/app_state.rs` +- Test: unit tests with mock mapping and missing-binary paths + +**Interfaces:** +- Consumes: `locate_codeg_tsnet_binary`, protocol types +- Produces: + ```rust + pub struct TailscaleController { /* mutex inner */ } + + impl TailscaleController { + pub fn new() -> Self; + pub async fn status(&self) -> TailscaleFunnelStatus; + pub async fn enable_funnel(&self, opts: EnableFunnelOpts) -> Result; + pub async fn disable_funnel(&self) -> Result<(), AppCommandError>; + pub async fn open_login_hint(&self) -> Result, AppCommandError>; + pub async fn shutdown(&self); + } + + pub struct EnableFunnelOpts { + pub data_dir: PathBuf, + pub localhost_port: u16, + pub auth_key: Option, + pub require_auth_key: bool, // true for server/headless + pub state_dir_override: Option, + pub hostname_override: Option, + } + ``` + +Behavior contract: +1. If binary missing -> `supported=false`, error key `tailscale.sidecar_missing`, local web unaffected. +2. Spawn via `crate::process::tokio_command` (Windows `CREATE_NO_WINDOW`). +3. Capture first stdout line as bootstrap JSON (timeout 5s). +4. All control requests include `X-Codeg-Tsnet-Token`. +5. Flow: start -> `POST /up` -> poll `/status` every 1s -> on `online` `POST /funnel` -> poll until `funnel_ready` or error. +6. Desktop: if `needs_login`, surface `loginUrl` and keep polling up to 10 minutes. +7. Server: if `require_auth_key` and no key -> immediate `tailscale.authkey_required`. +8. On disable/stop: `POST /funnel {enabled:false}` best-effort, then `POST /shutdown`, then kill if needed. +9. Sidecar crash -> state `error`; no infinite restart loop. +10. Never touch system Tailscale paths. + +- [ ] **Step 1: Write controller tests** + +Minimum required tests: +- missing binary -> unsupported/error key +- status mapping from sidecar JSON +- authkey required path + +```rust +#[tokio::test] +async fn missing_binary_is_unsupported() { + // ensure CODEG_TSNET_BIN points nowhere and no sibling binary + let c = TailscaleController::new(); + let st = c.status().await; + assert!(!st.supported); +} + +#[test] +fn maps_sidecar_status_to_funnel_status() { + let raw = SidecarStatus { + state: "funnel_ready".into(), + login_url: None, + funnel_url: Some("https://codeg-abc.ts.net".into()), + hostname: Some("codeg-abc".into()), + ipv4: Some("100.64.0.1".into()), + last_error: None, + error_key: None, + backend_state: Some("Running".into()), + }; + let st = map_status(true, true, raw); + assert_eq!(st.state, "funnel_ready"); + assert_eq!(st.funnel_url.as_deref(), Some("https://codeg-abc.ts.net")); +} +``` + +- [ ] **Step 2: Run tests, expect fail** + +Run: `cd src-tauri && cargo test --no-default-features tailscale` +Expected: FAIL + +- [ ] **Step 3: Implement controller** + +Key spawn snippet: + +```rust +let mut cmd = crate::process::tokio_command(&bin); +cmd.arg("--control-addr").arg("127.0.0.1:0") + .arg("--state-dir").arg(&state_dir) + .arg("--hostname").arg(&hostname) + .arg("--control-token").arg(&token) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); +if let Some(k) = &auth_key { cmd.arg("--auth-key").arg(k); } +let mut child = cmd.spawn().map_err(...)?; +let bootstrap = read_bootstrap_line(child.stdout.take(), Duration::from_secs(5)).await?; +``` + +HTTP client: existing `reqwest` dependency is fine for localhost JSON. + +- [ ] **Step 4: Wire into `AppState`** + +```rust +// app_state.rs +pub tailscale: Arc, +``` + +Construct with `Arc::new(TailscaleController::new())` in desktop setup and `codeg-server` bootstrap. + +- [ ] **Step 5: Tests pass + commit** + +```bash +cd src-tauri && cargo test --no-default-features web::tailscale +git add src-tauri/src/web/tailscale src-tauri/src/app_state.rs src-tauri/src/bin/codeg_server.rs src-tauri/src/lib.rs +git commit -m "feat(web): add TailscaleController sidecar lifecycle" +``` + +--- + +### Task 4: Web Service config/status/API integration + +**Files:** +- Modify: `src-tauri/src/web/mod.rs` +- Create: `src-tauri/src/web/handlers/tailscale.rs` +- Modify: `src-tauri/src/web/handlers/mod.rs` +- Modify: `src-tauri/src/web/router.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/src/bin/codeg_server.rs` + +**Interfaces:** +- Consumes: controller + existing web start/stop +- Produces: + +Config: +```rust +// WebServiceConfig gains: +pub funnel_enabled: bool, +``` +metadata key: `web_service_funnel_enabled` (`true`/`false`) + +Commands/HTTP: +- `get_tailscale_funnel_status` -> `TailscaleFunnelStatus` +- `set_tailscale_funnel_enabled { enabled: bool }` -> `TailscaleFunnelStatus` +- `open_tailscale_login` -> `{ loginUrl?: string }` + +Lifecycle rules: +- Desktop start web success + `funnelEnabled` -> `enable_funnel` +- Desktop stop web -> `disable_funnel` first (best-effort), then stop axum +- Desktop auto-start web path in `lib.rs` also starts Funnel when configured +- Server: after bind/mark external running, if `CODEG_TS_FUNNEL=1` (or persisted funnelEnabled), call enable with `require_auth_key=true` and `CODEG_TS_AUTHKEY` +- Server stop endpoint still cannot stop whole server; Funnel disable remains allowed via set-enabled false +- Port change while funnel enabled: disable then enable with new port + +- [ ] **Step 1: Extend config load/update** + +Add `funnel_enabled` to `WebServiceConfig` with default `false`. Persist with existing transaction in `update_web_service_config_core`. + +- [ ] **Step 2: Implement hooks** + +After successful web bind (desktop path): + +```rust +if load_web_service_config(...).await?.funnel_enabled { + let _ = app_state.tailscale.enable_funnel(EnableFunnelOpts { + data_dir: app_state.data_dir.clone(), + localhost_port: actual_port, + auth_key: std::env::var("CODEG_TS_AUTHKEY").ok(), + require_auth_key: false, + state_dir_override: std::env::var_os("CODEG_TS_STATE_DIR").map(PathBuf::from), + hostname_override: std::env::var("CODEG_TS_HOSTNAME").ok(), + }).await; +} +``` + +Prefer wrapper approach for stop to minimize signature churn: +- Tauri/HTTP `stop_web_server` call `state.tailscale.disable_funnel().await` first when not externally managed. +- External/server process shutdown path in `codeg_server.rs` also disables funnel before exit. + +- [ ] **Step 3: Register routes and Tauri commands** + +Router (auth-protected like other settings commands): + +```rust +"/get_tailscale_funnel_status" => post(handlers::tailscale::get_status) +"/set_tailscale_funnel_enabled" => post(handlers::tailscale::set_enabled) +"/open_tailscale_login" => post(handlers::tailscale::open_login) +``` + +Tauri: + +```rust +web::tailscale::get_tailscale_funnel_status, +web::tailscale::set_tailscale_funnel_enabled, +web::tailscale::open_tailscale_login, +``` + +- [ ] **Step 4: Server env path** + +In `codeg_server.rs` after server is marked running: + +```rust +let funnel_env = matches!(std::env::var("CODEG_TS_FUNNEL").as_deref(), Ok("1") | Ok("true") | Ok("yes")); +if funnel_env { + match state.tailscale.enable_funnel(EnableFunnelOpts { + data_dir: state.data_dir.clone(), + localhost_port: port, + auth_key: std::env::var("CODEG_TS_AUTHKEY").ok(), + require_auth_key: true, + state_dir_override: std::env::var_os("CODEG_TS_STATE_DIR").map(PathBuf::from), + hostname_override: std::env::var("CODEG_TS_HOSTNAME").ok(), + }).await { + Ok(st) => tracing::info!(?st.funnel_url, ?st.state, "[SERVER] Tailscale Funnel status"), + Err(err) => tracing::warn!(%err, "[SERVER][WARN] Tailscale Funnel not enabled"), + } +} +``` + +Do not print auth key. Funnel URL may be logged. + +- [ ] **Step 5: Verify compile** + +Run: +```bash +cd src-tauri +cargo check --no-default-features --bin codeg-server +cargo check --bin codeg +``` +Expected: success (or only pre-existing unrelated warnings) + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/src/web src-tauri/src/lib.rs src-tauri/src/app_state.rs src-tauri/src/bin/codeg_server.rs +git commit -m "feat(web): wire Tailscale Funnel into web service lifecycle and APIs" +``` + +--- + +### Task 5: Frontend settings UI + i18n + API client + +**Files:** +- Modify: `src/lib/api.ts` +- Create: `src/components/settings/web-service-funnel-section.tsx` +- Create: `src/components/settings/web-service-funnel-section.test.tsx` +- Modify: `src/components/settings/web-service-settings.tsx` +- Modify: `src/i18n/messages/en.json` +- Modify: `src/i18n/messages/zh-CN.json` +- Modify other locale files under `src/i18n/messages/` with the same keys (English text acceptable if full translation not available) + +**Interfaces:** +```ts +export interface TailscaleFunnelStatus { + supported: boolean + enabled: boolean + state: + | "stopped" + | "starting" + | "needs_login" + | "connecting" + | "online" + | "funnel_enabling" + | "funnel_ready" + | "error" + | "stopping" + loginUrl?: string + funnelUrl?: string + hostname?: string + ipv4?: string + lastError?: string +} + +export async function getTailscaleFunnelStatus(): Promise +export async function setTailscaleFunnelEnabled(enabled: boolean): Promise +export async function openTailscaleLogin(): Promise<{ loginUrl?: string | null }> +``` + +Extend: +```ts +export interface WebServiceConfig { + token: string | null + port: number | null + autoStart: boolean + funnelEnabled: boolean +} +``` + +UI requirements (Funnel section under Web Service): +- Switch: enable Funnel (disabled when web service stopped on desktop) +- Badge for state +- Button: open login when `needs_login` +- Funnel URL row with copy/open +- Error text mapped from known keys +- Notes: + - Codeg token still required + - Uses private node/state dir; does not touch system Tailscale +- Poll status every 1.5s while state is transitional (`starting|needs_login|connecting|online|funnel_enabling|stopping`) + +- [ ] **Step 1: Add API wrappers** + +Implement next to existing web-server API block in `src/lib/api.ts`. + +- [ ] **Step 2: Write component test** + +```tsx +it("shows login action when needs_login", async () => { + // mock getTailscaleFunnelStatus -> needs_login + loginUrl + // render section, assert login button visible +}) + +it("renders funnel url when ready", async () => { + // mock funnel_ready + funnelUrl +}) +``` + +- [ ] **Step 3: Implement section component and mount it** + +In `web-service-settings.tsx`: +- include `funnelEnabled` in persist payload +- render `` below start/stop controls + +Use existing patterns: `Switch`, `openUrl`, `copyTextToClipboard`, `useTranslations("WebServiceSettings")`. + +- [ ] **Step 4: i18n keys** + +Add under `WebServiceSettings` in en + zh-CN at minimum: + +```json +"funnelTitle": "Public Access (Tailscale Funnel)", +"funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", +"funnelEnable": "Enable Funnel", +"funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", +"funnelState": "Funnel status", +"funnelLogin": "Open Tailscale login", +"funnelUrl": "Public URL", +"funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", +"funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", +"funnelUnsupported": "Funnel sidecar is unavailable in this install", +"funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" +} +``` + +- [ ] **Step 5: Run frontend tests** + +Run: +```bash +pnpm test -- web-service-funnel-section.test.tsx +``` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/api.ts src/components/settings/web-service-funnel-section.tsx src/components/settings/web-service-funnel-section.test.tsx src/components/settings/web-service-settings.tsx src/i18n/messages +git commit -m "feat(ui): add Tailscale Funnel controls to Web Service settings" +``` + +--- + +### Task 6: Packaging, CI, installers, Docker + +**Files:** +- Modify: `src-tauri/scripts/prepare-sidecars.mjs` +- Modify: `src-tauri/tauri.conf.json` +- Modify: `.github/workflows/release.yml` +- Modify: `Dockerfile` +- Modify: `Dockerfile.ci` +- Modify: `install.sh` +- Modify: `install.ps1` + +**Interfaces / packaging contract:** +- Desktop externalBin: `["binaries/codeg-mcp", "binaries/codeg-tsnet"]` +- `prepare-sidecars.mjs` builds both sidecars for target triple +- Server artifacts include `codeg-tsnet` next to `codeg-server` and `codeg-mcp` +- Docker image includes `/usr/local/bin/codeg-tsnet` +- Installers manage `codeg-tsnet` as a third managed bin +- `CODEG_SKIP_SIDECAR=1` skips both sidecars (dev convenience) + +- [ ] **Step 1: Extend prepare-sidecars** + +Refactor script from single `BIN_NAME` to a list of sidecars: +1. `codeg-mcp` via existing cargo build +2. `codeg-tsnet` via `go build` with `GOOS`/`GOARCH` derived from rust triple + +Triple -> Go env mapping: +- `x86_64-apple-darwin` -> `GOOS=darwin GOARCH=amd64` +- `aarch64-apple-darwin` -> `GOOS=darwin GOARCH=arm64` +- `x86_64-unknown-linux-gnu` -> `GOOS=linux GOARCH=amd64` +- `aarch64-unknown-linux-gnu` -> `GOOS=linux GOARCH=arm64` +- `x86_64-pc-windows-msvc` -> `GOOS=windows GOARCH=amd64` +- `aarch64-pc-windows-msvc` -> `GOOS=windows GOARCH=arm64` + +Output staged as `src-tauri/binaries/codeg-tsnet-{.exe}`. + +- [ ] **Step 2: Update tauri.conf.json externalBin** + +```json +"externalBin": ["binaries/codeg-mcp", "binaries/codeg-tsnet"] +``` + +- [ ] **Step 3: Update release.yml** + +For desktop + server jobs: +1. Add Go setup: +```yaml +- uses: actions/setup-go@v5 + with: + go-version: "1.22.x" +``` +2. Build/stage `codeg-tsnet` for matrix target (via prepare-sidecars or explicit `go build`). +3. Verify file exists next to existing `codeg-mcp` checks. +4. Package into tar.gz/zip and Docker artifact copies. +5. Smoke: `codeg-tsnet --help` on native runners (Windows/macOS/Linux x64; skip cross arm exec). + +- [ ] **Step 4: Docker + installers** + +Dockerfile backend stage needs Go and must ship `/usr/local/bin/codeg-tsnet`. + +`install.sh` / `install.ps1`: +```bash +MANAGED_BINS=(codeg-server codeg-mcp codeg-tsnet) +``` +```powershell +$ManagedBins = @("codeg-server", "codeg-mcp", "codeg-tsnet") +``` + +Include existence smoke for `codeg-tsnet` like `codeg-mcp`. + +- [ ] **Step 5: Local packaging dry-run** + +```bash +pnpm tauri:prepare-sidecars +ls src-tauri/binaries +``` + +Expected: both `codeg-mcp-` and `codeg-tsnet-` present. + +- [ ] **Step 6: Commit** + +```bash +git add src-tauri/scripts/prepare-sidecars.mjs src-tauri/tauri.conf.json .github/workflows/release.yml Dockerfile Dockerfile.ci install.sh install.ps1 +git commit -m "build: package codeg-tsnet sidecar across desktop, server, and Docker" +``` + +--- + +### Task 7: Docs + acceptance verification + bilingual PR + +**Files:** +- Modify: `README.md` (env table section if present) +- Modify: `docs/readme/README.zh-CN.md` + +**Docs content to add:** +- Funnel is optional +- Desktop: enable in Web Service settings, browser login +- Server: + - `CODEG_TS_FUNNEL=1` + - `CODEG_TS_AUTHKEY=...` + - optional `CODEG_TS_STATE_DIR`, `CODEG_TS_HOSTNAME`, `CODEG_TSNET_BIN` +- State lives under `/tailscale` +- Does not use system Tailscale +- Codeg token still required + +- [ ] **Step 1: Write docs deltas** + +Keep concise; mirror existing `CODEG_MCP_BIN` documentation style. + +- [ ] **Step 2: Acceptance checklist (manual)** + +Desktop (macOS or Windows or Linux): +1. Start Web Service locally -> works without Funnel. +2. Enable Funnel without sidecar binary present -> error state, local web still up. +3. With sidecar present + Tailscale account: enable Funnel -> login URL -> after login show public URL. +4. Open public URL -> still prompted for Codeg token. +5. Disable Funnel / stop Web Service -> `codeg-tsnet` process exits. +6. Confirm no changes to system Tailscale prefs/state. + +Server: +1. `CODEG_TS_FUNNEL=1` without auth key -> Funnel error `authkey_required`, server still serves local/LAN. +2. With auth key -> Funnel URL logged/status API returns ready. +3. Artifact contains `codeg-tsnet` sibling binary. + +Automated: +```bash +cd codeg-tsnet && go test ./... +cd src-tauri && cargo test --no-default-features web::tailscale +pnpm test -- web-service-funnel-section.test.tsx +pnpm tauri:prepare-sidecars +``` + +- [ ] **Step 3: Open bilingual PR from fork** + +Remote push target: `fork` (`ijry/codeg-plus`), base `xintaofei/codeg` `main`. + +PR title: +```text +feat: optional Tailscale Funnel public access via codeg-tsnet sidecar +``` + +PR body structure: + +```markdown +## 中文 +### 背景 +... +### 方案 +- Go sidecar `codeg-tsnet`(tsnet userspace) +- 独立状态目录,不接管系统 Tailscale +- 桌面浏览器登录 / 服务端 auth key +- Windows 随 sidecar 一并支持 +### 验证 +... + +## English +### Background +... +### Approach +... +### Validation +... +``` + +- [ ] **Step 4: Final commit if docs remain** + +```bash +git add README.md docs/readme/README.zh-CN.md +git commit -m "docs: document optional Tailscale Funnel sidecar" +``` + +Then push branch and create PR: +```bash +git push -u fork HEAD +gh pr create --repo xintaofei/codeg --base main --title "..." --body "..." +``` + +--- + +## Spec Coverage Self-Review + +| Spec requirement | Task | +|---|---| +| Optional Funnel public HTTPS | 3,4,5 | +| Own userspace node + independent state dir | 1,2,3 | +| Never touch system Tailscale | 1,3,7 | +| Desktop browser OAuth guidance | 1,3,5 | +| Server auth key headless | 3,4,7 | +| Settings switch `funnelEnabled` | 4,5 | +| Sidecar packaging like `codeg-mcp` | 6 | +| Windows first-class via sidecar | 1,6 | +| Funnel failure does not break local web | 3,4 | +| Codeg token still required | 5,7 | +| Control protocol + status machine | 1,2,3 | +| Release matrix / Docker / installers | 6 | +| Bilingual PR | 7 | + +## Placeholder / Consistency Check + +- Control auth header fixed: `X-Codeg-Tsnet-Token` +- Funnel status is dedicated endpoints (not overloaded into `WebServerInfo`) +- Sidecar is started on demand when Funnel enabled; stopped on disable/web stop +- Funnel URL comes from sidecar status field `funnelUrl` +- No TBD left for architecture decisions; only Tailscale module pin may adjust if `v1.82.5` fails to build, without changing protocol + +## Execution Notes + +- Work from repo root `codeg/`. +- Do not push to `origin` (`xintaofei/codeg`); use `fork`. +- Design doc already committed on docs branch; implementation should use a feature branch from latest `main` (or continue and include docs commit) before PR. +- Prefer small commits at each task boundary above. From 4f114f5064885cd3e5ab2ff6edd49bba138ba105 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 13:55:09 +0800 Subject: [PATCH 03/10] feat(tsnet): add codeg-tsnet control-plane sidecar MVP Introduce a pure Go tsnet sidecar with loopback control API, independent state directory, browser/auth-key login hooks, and Funnel reverse-proxy lifecycle for public HTTPS access without touching system Tailscale. --- codeg-tsnet/.gitignore | 5 + codeg-tsnet/control.go | 126 ++++++++++ codeg-tsnet/control_test.go | 74 ++++++ codeg-tsnet/funnel.go | 472 ++++++++++++++++++++++++++++++++++++ codeg-tsnet/go.mod | 83 +++++++ codeg-tsnet/go.sum | 238 ++++++++++++++++++ codeg-tsnet/main.go | 101 ++++++++ codeg-tsnet/main_test.go | 24 ++ codeg-tsnet/status.go | 25 ++ 9 files changed, 1148 insertions(+) create mode 100644 codeg-tsnet/.gitignore create mode 100644 codeg-tsnet/control.go create mode 100644 codeg-tsnet/control_test.go create mode 100644 codeg-tsnet/funnel.go create mode 100644 codeg-tsnet/go.mod create mode 100644 codeg-tsnet/go.sum create mode 100644 codeg-tsnet/main.go create mode 100644 codeg-tsnet/main_test.go create mode 100644 codeg-tsnet/status.go diff --git a/codeg-tsnet/.gitignore b/codeg-tsnet/.gitignore new file mode 100644 index 000000000..ee1b9871c --- /dev/null +++ b/codeg-tsnet/.gitignore @@ -0,0 +1,5 @@ +/codeg-tsnet +/codeg-tsnet.exe +*.test +*.out + diff --git a/codeg-tsnet/control.go b/codeg-tsnet/control.go new file mode 100644 index 000000000..0d46c1cd8 --- /dev/null +++ b/codeg-tsnet/control.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "time" +) + +type bootstrapInfo struct { + ControlAddr string `json:"controlAddr"` + PID int `json:"pid"` +} + +type upRequest struct { + AuthKey string `json:"authKey,omitempty"` +} + +type funnelRequest struct { + Enabled bool `json:"enabled"` + LocalhostPort int `json:"localhostPort"` +} + +func withToken(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + got := r.Header.Get("X-Codeg-Tsnet-Token") + if got == "" || got != token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +func readJSONBody(r *http.Request, dst any) error { + defer r.Body.Close() + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +} + +type controlServer struct { + mgr *nodeManager + shutdown func() +} + +func (c *controlServer) routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /status", c.handleStatus) + mux.HandleFunc("POST /up", c.handleUp) + mux.HandleFunc("POST /funnel", c.handleFunnel) + mux.HandleFunc("POST /logout", c.handleLogout) + mux.HandleFunc("POST /shutdown", c.handleShutdown) + return mux +} + +func (c *controlServer) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, c.mgr.snapshot()) +} + +func (c *controlServer) handleUp(w http.ResponseWriter, r *http.Request) { + var req upRequest + if err := readJSONBody(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := c.mgr.up(req.AuthKey); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + writeJSON(w, http.StatusOK, c.mgr.snapshot()) +} + +func (c *controlServer) handleFunnel(w http.ResponseWriter, r *http.Request) { + var req funnelRequest + if err := readJSONBody(r, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var err error + if req.Enabled { + err = c.mgr.enableFunnel(req.LocalhostPort) + } else { + err = c.mgr.disableFunnel() + } + if err != nil { + // Status already updated with errorKey; still return JSON status for controller. + writeJSON(w, http.StatusOK, c.mgr.snapshot()) + return + } + writeJSON(w, http.StatusOK, c.mgr.snapshot()) +} + +func (c *controlServer) handleLogout(w http.ResponseWriter, r *http.Request) { + if err := c.mgr.logout(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, c.mgr.snapshot()) +} + +func (c *controlServer) handleShutdown(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"ok": "true"}) + go func() { + time.Sleep(50 * time.Millisecond) + if c.shutdown != nil { + c.shutdown() + } + }() +} diff --git a/codeg-tsnet/control_test.go b/codeg-tsnet/control_test.go new file mode 100644 index 000000000..29341aea5 --- /dev/null +++ b/codeg-tsnet/control_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestStatusJSONShape(t *testing.T) { + s := StatusResponse{State: "needs_login", LoginURL: "https://login.tailscale.com/a/x"} + b, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + if m["state"] != "needs_login" { + t.Fatalf("state=%v", m["state"]) + } + if m["loginUrl"] != "https://login.tailscale.com/a/x" { + t.Fatalf("loginUrl=%v", m["loginUrl"]) + } +} + +func TestAuthMiddlewareRejectsMissingToken(t *testing.T) { + h := withToken("secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + })) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/status", nil) + h.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("code=%d", rr.Code) + } +} + +func TestAuthMiddlewareAcceptsValidToken(t *testing.T) { + h := withToken("secret", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(204) + })) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/status", nil) + req.Header.Set("X-Codeg-Tsnet-Token", "secret") + h.ServeHTTP(rr, req) + if rr.Code != http.StatusNoContent { + t.Fatalf("code=%d", rr.Code) + } +} + +func TestStatusHandlerReturnsStopped(t *testing.T) { + mgr := newNodeManager("codeg-test", t.TempDir(), "") + ctrl := &controlServer{mgr: mgr} + h := withToken("tok", ctrl.routes()) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/status", nil) + req.Header.Set("X-Codeg-Tsnet-Token", "tok") + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String()) + } + var st StatusResponse + if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil { + t.Fatal(err) + } + if st.State != stateStopped { + t.Fatalf("state=%s", st.State) + } + if st.Hostname != "codeg-test" { + t.Fatalf("hostname=%s", st.Hostname) + } +} diff --git a/codeg-tsnet/funnel.go b/codeg-tsnet/funnel.go new file mode 100644 index 000000000..db0d09a83 --- /dev/null +++ b/codeg-tsnet/funnel.go @@ -0,0 +1,472 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + "time" + + "tailscale.com/client/local" + "tailscale.com/ipn" + "tailscale.com/tsnet" +) + +type nodeManager struct { + mu sync.Mutex + + hostname string + stateDir string + authKey string + + state string + loginURL string + funnelURL string + ipv4 string + lastError string + errorKey string + backendState string + localPort int + desiredFunnel bool + + server *tsnet.Server + funnelLn net.Listener + funnelSrv *http.Server + watchCancel context.CancelFunc + upCancel context.CancelFunc +} + +func newNodeManager(hostname, stateDir, authKey string) *nodeManager { + return &nodeManager{ + hostname: hostname, + stateDir: stateDir, + authKey: authKey, + state: stateStopped, + } +} + +func (m *nodeManager) snapshot() StatusResponse { + m.mu.Lock() + defer m.mu.Unlock() + return StatusResponse{ + State: m.state, + LoginURL: m.loginURL, + FunnelURL: m.funnelURL, + Hostname: m.hostname, + IPv4: m.ipv4, + LastError: m.lastError, + ErrorKey: m.errorKey, + BackendState: m.backendState, + } +} + +func (m *nodeManager) setError(key, msg string) { + m.mu.Lock() + defer m.mu.Unlock() + m.state = stateError + m.errorKey = key + m.lastError = msg +} + +func (m *nodeManager) setState(state string) { + m.mu.Lock() + defer m.mu.Unlock() + m.state = state +} + +func (m *nodeManager) ensureServerLocked() *tsnet.Server { + if m.server != nil { + return m.server + } + s := &tsnet.Server{ + Dir: m.stateDir, + Hostname: m.hostname, + AuthKey: m.authKey, + UserLogf: func(format string, args ...any) { + msg := fmt.Sprintf(format, args...) + // Never log secrets. Auth keys and control tokens must not appear. + lower := strings.ToLower(msg) + if strings.Contains(lower, "authkey") || strings.Contains(lower, "auth key") { + return + } + log.Printf("[tsnet] %s", msg) + }, + } + m.server = s + return s +} + +func (m *nodeManager) up(authKey string) error { + m.mu.Lock() + if m.state == stateStopping { + m.mu.Unlock() + return errors.New("node is stopping") + } + if authKey != "" { + m.authKey = authKey + if m.server != nil { + m.server.AuthKey = authKey + } + } + m.state = stateStarting + m.loginURL = "" + m.lastError = "" + m.errorKey = "" + m.backendState = "" + if m.upCancel != nil { + m.upCancel() + m.upCancel = nil + } + if m.watchCancel != nil { + m.watchCancel() + m.watchCancel = nil + } + s := m.ensureServerLocked() + ctx, cancel := context.WithCancel(context.Background()) + m.upCancel = cancel + m.mu.Unlock() + + go m.runUp(ctx, s) + return nil +} + +func (m *nodeManager) runUp(ctx context.Context, s *tsnet.Server) { + m.setState(stateConnecting) + + lc, err := s.LocalClient() + if err != nil { + m.setError("tailscale.start_failed", err.Error()) + return + } + + watchCtx, watchCancel := context.WithCancel(ctx) + m.mu.Lock() + m.watchCancel = watchCancel + m.mu.Unlock() + + go m.watchIPN(watchCtx, lc) + + // Kick interactive login when no auth key is configured so BrowseToURL / AuthURL appear. + if strings.TrimSpace(s.AuthKey) == "" { + if err := lc.StartLoginInteractive(ctx); err != nil { + // Non-fatal: node may already be logged in or will surface NeedsLogin via bus. + log.Printf("[tsnet] StartLoginInteractive: %v", err) + } + } + + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + st, err := lc.Status(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + continue + } + m.mu.Lock() + m.backendState = st.BackendState + if st.AuthURL != "" { + m.loginURL = st.AuthURL + if m.state != stateFunnelEnabling && m.state != stateFunnelReady && m.state != stateOnline { + m.state = stateNeedsLogin + } + } + if len(st.TailscaleIPs) > 0 { + m.ipv4 = st.TailscaleIPs[0].String() + } + backend := st.BackendState + m.mu.Unlock() + + switch backend { + case ipn.Running.String(): + m.mu.Lock() + m.state = stateOnline + m.loginURL = "" + if len(st.TailscaleIPs) > 0 { + m.ipv4 = st.TailscaleIPs[0].String() + } + desired := m.desiredFunnel + port := m.localPort + m.mu.Unlock() + if desired && port > 0 { + _ = m.enableFunnel(port) + } + return + case ipn.NeedsLogin.String(), ipn.NeedsMachineAuth.String(): + m.mu.Lock() + if st.AuthURL != "" { + m.loginURL = st.AuthURL + } + m.state = stateNeedsLogin + m.mu.Unlock() + case ipn.Starting.String(): + m.setState(stateConnecting) + } + } + } +} + +func (m *nodeManager) watchIPN(ctx context.Context, lc *local.Client) { + watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyNoPrivateKeys) + if err != nil { + log.Printf("[tsnet] WatchIPNBus: %v", err) + return + } + defer watcher.Close() + + for { + n, err := watcher.Next() + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("[tsnet] IPN bus closed: %v", err) + return + } + if n.ErrMessage != nil { + m.setError("tailscale.start_failed", *n.ErrMessage) + continue + } + m.mu.Lock() + if n.BrowseToURL != nil && *n.BrowseToURL != "" { + m.loginURL = *n.BrowseToURL + if m.state != stateFunnelEnabling && m.state != stateFunnelReady && m.state != stateOnline { + m.state = stateNeedsLogin + } + } + if n.State != nil { + m.backendState = n.State.String() + switch *n.State { + case ipn.NeedsLogin, ipn.NeedsMachineAuth: + m.state = stateNeedsLogin + case ipn.Starting: + if m.state != stateFunnelEnabling && m.state != stateFunnelReady { + m.state = stateConnecting + } + case ipn.Running: + if m.state != stateFunnelEnabling && m.state != stateFunnelReady { + m.state = stateOnline + } + m.loginURL = "" + } + } + m.mu.Unlock() + } +} + +func (m *nodeManager) enableFunnel(localhostPort int) error { + if localhostPort <= 0 || localhostPort > 65535 { + return fmt.Errorf("invalid localhostPort %d", localhostPort) + } + + m.mu.Lock() + m.localPort = localhostPort + m.desiredFunnel = true + m.state = stateFunnelEnabling + m.lastError = "" + m.errorKey = "" + s := m.ensureServerLocked() + oldLn := m.funnelLn + oldSrv := m.funnelSrv + m.funnelLn = nil + m.funnelSrv = nil + m.mu.Unlock() + + if oldSrv != nil { + _ = oldSrv.Close() + } + if oldLn != nil { + _ = oldLn.Close() + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + st, err := s.Up(ctx) + if err != nil { + lc, lcErr := s.LocalClient() + if lcErr == nil { + if cur, stErr := lc.Status(context.Background()); stErr == nil { + m.mu.Lock() + m.backendState = cur.BackendState + if cur.AuthURL != "" { + m.loginURL = cur.AuthURL + m.state = stateNeedsLogin + m.mu.Unlock() + return nil + } + m.mu.Unlock() + } + } + key := "tailscale.funnel_failed" + msg := err.Error() + lower := strings.ToLower(msg) + if strings.Contains(lower, "access") || strings.Contains(lower, "funnel") { + key = "tailscale.funnel_denied" + } + m.setError(key, msg) + return err + } + + m.mu.Lock() + if len(st.TailscaleIPs) > 0 { + m.ipv4 = st.TailscaleIPs[0].String() + } + m.state = stateFunnelEnabling + m.mu.Unlock() + + ln, err := s.ListenFunnel("tcp", ":443") + if err != nil { + key := "tailscale.funnel_failed" + msg := err.Error() + lower := strings.ToLower(msg) + if strings.Contains(lower, "access") || strings.Contains(lower, "not permitted") || strings.Contains(lower, "denied") { + key = "tailscale.funnel_denied" + } + m.setError(key, msg) + return err + } + + target, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", localhostPort)) + proxy := httputil.NewSingleHostReverseProxy(target) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + req.Host = target.Host + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { + http.Error(w, "upstream unavailable", http.StatusBadGateway) + } + + srv := &http.Server{Handler: proxy} + domains := s.CertDomains() + funnelURL := "" + if len(domains) > 0 { + funnelURL = "https://" + domains[0] + } + + m.mu.Lock() + m.funnelLn = ln + m.funnelSrv = srv + m.funnelURL = funnelURL + m.state = stateFunnelReady + m.lastError = "" + m.errorKey = "" + m.mu.Unlock() + + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) { + m.setError("tailscale.funnel_failed", err.Error()) + } + }() + return nil +} + +func (m *nodeManager) disableFunnel() error { + m.mu.Lock() + m.desiredFunnel = false + oldLn := m.funnelLn + oldSrv := m.funnelSrv + m.funnelLn = nil + m.funnelSrv = nil + m.funnelURL = "" + s := m.server + if m.state == stateFunnelReady || m.state == stateFunnelEnabling { + if m.backendState == ipn.Running.String() || m.ipv4 != "" { + m.state = stateOnline + } else if m.loginURL != "" { + m.state = stateNeedsLogin + } else if m.server != nil { + m.state = stateConnecting + } else { + m.state = stateStopped + } + } + m.mu.Unlock() + + if oldSrv != nil { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _ = oldSrv.Shutdown(ctx) + cancel() + _ = oldSrv.Close() + } + if oldLn != nil { + _ = oldLn.Close() + } + + if s != nil { + if lc, err := s.LocalClient(); err == nil { + _ = lc.SetServeConfig(context.Background(), new(ipn.ServeConfig)) + } + } + return nil +} + +func (m *nodeManager) logout() error { + _ = m.disableFunnel() + m.mu.Lock() + s := m.server + m.mu.Unlock() + if s == nil { + return nil + } + lc, err := s.LocalClient() + if err != nil { + return err + } + if err := lc.Logout(context.Background()); err != nil { + return err + } + m.mu.Lock() + m.loginURL = "" + m.ipv4 = "" + m.funnelURL = "" + m.backendState = "" + m.state = stateNeedsLogin + m.mu.Unlock() + return nil +} + +func (m *nodeManager) shutdown() { + m.mu.Lock() + m.state = stateStopping + if m.upCancel != nil { + m.upCancel() + m.upCancel = nil + } + if m.watchCancel != nil { + m.watchCancel() + m.watchCancel = nil + } + m.mu.Unlock() + + _ = m.disableFunnel() + + m.mu.Lock() + s := m.server + m.server = nil + m.mu.Unlock() + if s != nil { + _ = s.Close() + } + + m.mu.Lock() + m.state = stateStopped + m.loginURL = "" + m.funnelURL = "" + m.ipv4 = "" + m.backendState = "" + m.mu.Unlock() +} diff --git a/codeg-tsnet/go.mod b/codeg-tsnet/go.mod new file mode 100644 index 000000000..dbbdfe70a --- /dev/null +++ b/codeg-tsnet/go.mod @@ -0,0 +1,83 @@ +module github.com/xintaofei/codeg/codeg-tsnet + +go 1.26.3 + +require tailscale.com v1.82.5 + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect + github.com/aws/aws-sdk-go-v2 v1.36.0 // indirect + github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 // indirect + github.com/aws/smithy-go v1.22.2 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/gaissmai/bart v0.18.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/btree v1.1.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/illarion/gonotify/v3 v3.0.2 // indirect + github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/sdnotify v1.0.0 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/miekg/dns v1.1.58 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/prometheus-community/pro-bing v0.4.0 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect + github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect + github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect + github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect + github.com/vishvananda/netns v0.0.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/mod v0.23.0 // indirect + golang.org/x/net v0.36.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/time v0.10.0 // indirect + golang.org/x/tools v0.30.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 // indirect +) diff --git a/codeg-tsnet/go.sum b/codeg-tsnet/go.sum new file mode 100644 index 000000000..90e1d5aec --- /dev/null +++ b/codeg-tsnet/go.sum @@ -0,0 +1,238 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= +github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.36.0 h1:b1wM5CcE65Ujwn565qcwgtOTT1aT4ADOHHgglKjG7fk= +github.com/aws/aws-sdk-go-v2 v1.36.0/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31 h1:lWm9ucLSRFiI4dQQafLrEOmEDGry3Swrz0BIRdiHJqQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.31/go.mod h1:Huu6GG0YTfbPphQkDSo4dEGmQRTKb9k9G7RdtyQWxuI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31 h1:ACxDklUKKXb48+eg5ROZXi1vDgfMyfIA/WyvqHcHI0o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.31/go.mod h1:yadnfsDwqXeVaohbGc/RaD287PuyRw2wugkh5ZL2J6k= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12 h1:O+8vD2rGjfihBewr5bT+QUfYUHIxCVgG61LHoT59shM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.12/go.mod h1:usVdWJaosa66NMvmCrr08NcWDBRv4E6+YFG2pUdw1Lk= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13 h1:3LXNnmtH3TURctC23hnC0p/39Q5gre3FI7BNOiDcVWc= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.13/go.mod h1:7Yn+p66q/jt38qMoVfNvjbm3D89mGBnkwDcijgtih8w= +github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= +github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= +github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= +github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= +github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/gaissmai/bart v0.18.0 h1:jQLBT/RduJu0pv/tLwXE+xKPgtWJejbxuXAR+wLJafo= +github.com/gaissmai/bart v0.18.0/go.mod h1:JJzMAhNF5Rjo4SF4jWBrANuJfqY+FvsFhW7t1UZJ+XY= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874 h1:F8d1AJ6M9UQCavhwmO6ZsrYLfG8zVFWfEfMS2MXPkSY= +github.com/go-json-experiment/json v0.0.0-20250223041408-d3c622f1b874/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= +github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= +github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= +github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= +github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca h1:ecjHwH73Yvqf/oIdQ2vxAX+zc6caQsYdPzsxNW1J3G8= +github.com/tailscale/golang-x-crypto v0.0.0-20250218230618-9a281fd8faca/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= +github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= +github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw= +github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= +github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= +golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= +golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= +gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= +honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= +honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.82.5 h1:p5owmyPoPM1tFVHR3LjquFuLfpZLzafvhe5kjVavHtE= +tailscale.com v1.82.5/go.mod h1:iU6kohVzG+bP0/5XjqBAnW8/6nSG/Du++bO+x7VJZD0= diff --git a/codeg-tsnet/main.go b/codeg-tsnet/main.go new file mode 100644 index 000000000..270a65243 --- /dev/null +++ b/codeg-tsnet/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +func main() { + log.SetOutput(os.Stderr) + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("") + + controlAddr := flag.String("control-addr", "127.0.0.1:0", "control HTTP listen address (must be loopback)") + stateDir := flag.String("state-dir", "", "independent Tailscale state directory") + hostname := flag.String("hostname", "codeg", "Tailscale hostname for this userspace node") + authKey := flag.String("auth-key", "", "optional Tailscale auth key") + controlToken := flag.String("control-token", "", "required token for control API header X-Codeg-Tsnet-Token") + flag.Parse() + + if strings.TrimSpace(*stateDir) == "" { + fmt.Fprintln(os.Stderr, "codeg-tsnet: --state-dir is required") + os.Exit(2) + } + if strings.TrimSpace(*controlToken) == "" { + fmt.Fprintln(os.Stderr, "codeg-tsnet: --control-token is required") + os.Exit(2) + } + if err := os.MkdirAll(*stateDir, 0o700); err != nil { + fmt.Fprintf(os.Stderr, "codeg-tsnet: create state dir: %v\n", err) + os.Exit(1) + } + + host, portStr, err := net.SplitHostPort(*controlAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "codeg-tsnet: invalid --control-addr: %v\n", err) + os.Exit(2) + } + if host != "127.0.0.1" && host != "localhost" && host != "::1" { + fmt.Fprintln(os.Stderr, "codeg-tsnet: --control-addr must bind to loopback") + os.Exit(2) + } + if _, err := strconv.Atoi(portStr); err != nil && portStr != "0" { + // SplitHostPort already validated ports; keep defensive check. + } + + mgr := newNodeManager(*hostname, *stateDir, *authKey) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + ctrl := &controlServer{mgr: mgr} + httpSrv := &http.Server{ + Handler: withToken(*controlToken, ctrl.routes()), + } + ctrl.shutdown = func() { + stop() + shCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shCtx) + } + + ln, err := net.Listen("tcp", *controlAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "codeg-tsnet: listen control: %v\n", err) + os.Exit(1) + } + + boot := bootstrapInfo{ + ControlAddr: ln.Addr().String(), + PID: os.Getpid(), + } + if err := json.NewEncoder(os.Stdout).Encode(boot); err != nil { + fmt.Fprintf(os.Stderr, "codeg-tsnet: bootstrap write: %v\n", err) + os.Exit(1) + } + _ = os.Stdout.Sync() + + go func() { + if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("control server error: %v", err) + stop() + } + }() + + <-ctx.Done() + mgr.shutdown() + shCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shCtx) +} diff --git a/codeg-tsnet/main_test.go b/codeg-tsnet/main_test.go new file mode 100644 index 000000000..503fa7226 --- /dev/null +++ b/codeg-tsnet/main_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestBootstrapJSONShape(t *testing.T) { + b := bootstrapInfo{ControlAddr: "127.0.0.1:9", PID: 42} + raw, err := json.Marshal(b) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m["controlAddr"] != "127.0.0.1:9" { + t.Fatalf("controlAddr=%v", m["controlAddr"]) + } + if int(m["pid"].(float64)) != 42 { + t.Fatalf("pid=%v", m["pid"]) + } +} diff --git a/codeg-tsnet/status.go b/codeg-tsnet/status.go new file mode 100644 index 000000000..345145dd9 --- /dev/null +++ b/codeg-tsnet/status.go @@ -0,0 +1,25 @@ +package main + +// StatusResponse is the control-plane status payload returned by GET /status. +type StatusResponse struct { + State string `json:"state"` + LoginURL string `json:"loginUrl,omitempty"` + FunnelURL string `json:"funnelUrl,omitempty"` + Hostname string `json:"hostname,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + LastError string `json:"lastError,omitempty"` + ErrorKey string `json:"errorKey,omitempty"` + BackendState string `json:"backendState,omitempty"` +} + +const ( + stateStopped = "stopped" + stateStarting = "starting" + stateNeedsLogin = "needs_login" + stateConnecting = "connecting" + stateOnline = "online" + stateFunnelEnabling = "funnel_enabling" + stateFunnelReady = "funnel_ready" + stateError = "error" + stateStopping = "stopping" +) From fa491c4bebef975f653b2d2d1c07bf4ee10aa9e5 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:43:17 +0800 Subject: [PATCH 04/10] feat(web): add Tailscale sidecar protocol types and binary locator Introduce codeg-tsnet bootstrap/status protocol types, binary resolution, and Funnel status mapping under an independent state directory. --- src-tauri/src/web/mod.rs | 91 ++++ src-tauri/src/web/tailscale/binary.rs | 91 ++++ src-tauri/src/web/tailscale/controller.rs | 572 ++++++++++++++++++++++ src-tauri/src/web/tailscale/mod.rs | 207 ++++++++ src-tauri/src/web/tailscale/protocol.rs | 57 +++ src-tauri/src/web/tailscale/status.rs | 111 +++++ 6 files changed, 1129 insertions(+) create mode 100644 src-tauri/src/web/tailscale/binary.rs create mode 100644 src-tauri/src/web/tailscale/controller.rs create mode 100644 src-tauri/src/web/tailscale/mod.rs create mode 100644 src-tauri/src/web/tailscale/protocol.rs create mode 100644 src-tauri/src/web/tailscale/status.rs diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index d364dd3d7..dec71b60c 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod event_bridge; pub mod handlers; +pub mod tailscale; pub mod port_probe; pub mod router; pub mod shutdown; @@ -27,6 +28,7 @@ use crate::db::service::app_metadata_service; const WEB_SERVICE_TOKEN_KEY: &str = "web_service_token"; const WEB_SERVICE_PORT_KEY: &str = "web_service_port"; const WEB_SERVICE_AUTO_START_KEY: &str = "web_service_auto_start"; +const WEB_SERVICE_FUNNEL_ENABLED_KEY: &str = "web_service_funnel_enabled"; pub const DEFAULT_WEB_SERVICE_PORT: u16 = 3080; pub struct WebServerState { @@ -92,6 +94,14 @@ impl WebServerState { pub fn is_externally_managed(&self) -> bool { self.handle.lock().unwrap().is_none() && self.running.load(Ordering::Acquire) } + + /// Bound localhost port while the web service is running, else `0`. + pub fn current_port(&self) -> u16 { + if !self.running.load(Ordering::Acquire) { + return 0; + } + self.port.load(Ordering::Relaxed) + } } #[derive(Clone, Serialize)] @@ -233,6 +243,7 @@ pub struct WebServiceConfig { pub token: Option, pub port: Option, pub auto_start: bool, + pub funnel_enabled: bool, } pub async fn load_web_service_config( @@ -251,10 +262,16 @@ pub async fn load_web_service_config( .await .map_err(AppCommandError::from)?, ); + let funnel_enabled = parse_bool_metadata( + app_metadata_service::get_value(conn, WEB_SERVICE_FUNNEL_ENABLED_KEY) + .await + .map_err(AppCommandError::from)?, + ); Ok(WebServiceConfig { token: token.filter(|value| !value.trim().is_empty()), port, auto_start, + funnel_enabled, }) } @@ -265,6 +282,12 @@ pub async fn update_web_service_config_core( let token = config.token.unwrap_or_default().trim().to_string(); let port = config.port.unwrap_or(DEFAULT_WEB_SERVICE_PORT); let auto_start = if config.auto_start { "true" } else { "false" }.to_string(); + let funnel_enabled = if config.funnel_enabled { + "true" + } else { + "false" + } + .to_string(); let port_str = port.to_string(); conn.transaction::<_, (), AppCommandError>(move |txn| { @@ -278,6 +301,13 @@ pub async fn update_web_service_config_core( app_metadata_service::upsert_value(txn, WEB_SERVICE_AUTO_START_KEY, &auto_start) .await .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value( + txn, + WEB_SERVICE_FUNNEL_ENABLED_KEY, + &funnel_enabled, + ) + .await + .map_err(AppCommandError::from)?; Ok(()) }) }) @@ -620,6 +650,8 @@ pub(crate) async fn do_start_web_server_with_state( guard.disarm(); let addresses = addresses_for_bind(&advertised_host, actual_port); + // Best-effort Funnel enable; local web remains up on Funnel failure. + crate::web::tailscale::maybe_enable_funnel_after_web_start(&app_state, actual_port, false).await; Ok(WebServerInfo { port: actual_port, token, @@ -784,6 +816,12 @@ pub(crate) async fn do_start_web_server_tauri( &app.path().app_data_dir().unwrap_or_default(), ), web_server_state: WebServerState::new(), // placeholder; not used by handlers + // Reuse the shared controller so Funnel status/commands and the + // embedded router see the same sidecar process. + tailscale: app + .state::>() + .inner() + .clone(), chat_channel_manager: crate::app_state::default_chat_channel_manager(), workspace_transfer: app .try_state::>() @@ -880,6 +918,53 @@ pub(crate) async fn do_start_web_server_tauri( guard.disarm(); let addresses = addresses_for_bind(&advertised_host, actual_port); + // Best-effort Funnel enable after local bind succeeds. + { + let controller = app + .state::>() + .inner() + .clone(); + let db = app.state::(); + let data_dir = crate::paths::resolve_effective_data_dir( + &app.path().app_data_dir().unwrap_or_default(), + ); + let enabled = match load_web_service_config(&db.conn).await { + Ok(cfg) => cfg.funnel_enabled, + Err(err) => { + tracing::warn!(%err, "[tailscale] failed to load funnel config"); + false + } + }; + let env_enabled = matches!( + std::env::var("CODEG_TS_FUNNEL").as_deref(), + Ok("1") | Ok("true") | Ok("yes") + ); + if enabled || env_enabled { + match controller + .enable_funnel(crate::web::tailscale::EnableFunnelOpts { + data_dir, + localhost_port: actual_port, + auth_key: std::env::var("CODEG_TS_AUTHKEY") + .ok() + .filter(|s| !s.trim().is_empty()), + require_auth_key: false, + state_dir_override: std::env::var_os("CODEG_TS_STATE_DIR") + .map(std::path::PathBuf::from), + hostname_override: std::env::var("CODEG_TS_HOSTNAME") + .ok() + .filter(|s| !s.trim().is_empty()), + }) + .await + { + Ok(st) => tracing::info!( + state = %st.state, + funnel_url = ?st.funnel_url, + "[tailscale] Funnel status after web start" + ), + Err(err) => tracing::warn!(%err, "[tailscale] Funnel not enabled"), + } + } + } Ok(WebServerInfo { port: actual_port, token, @@ -902,8 +987,14 @@ pub async fn start_web_server( #[cfg(feature = "tauri-runtime")] #[tauri::command] pub async fn stop_web_server( + app: tauri::AppHandle, state: tauri::State<'_, WebServerState>, ) -> Result<(), AppCommandError> { + use tauri::Manager; + if let Some(controller) = app.try_state::>() + { + controller.shutdown().await; + } do_stop_web_server(&state).await; Ok(()) } diff --git a/src-tauri/src/web/tailscale/binary.rs b/src-tauri/src/web/tailscale/binary.rs new file mode 100644 index 000000000..2813c6e68 --- /dev/null +++ b/src-tauri/src/web/tailscale/binary.rs @@ -0,0 +1,91 @@ +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Resolve the `codeg-tsnet` sidecar binary. +/// +/// Order: +/// 1. `CODEG_TSNET_BIN` +/// 2. sibling of current executable +/// 3. PATH via `which` +pub fn locate_codeg_tsnet_binary() -> Option { + let filename = if cfg!(windows) { + "codeg-tsnet.exe" + } else { + "codeg-tsnet" + }; + + if let Some(raw) = std::env::var_os("CODEG_TSNET_BIN") { + let candidate = PathBuf::from(raw); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + + if let Some(dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(Path::to_path_buf)) + { + let candidate = dir.join(filename); + if is_executable_file(&candidate) { + return Some(candidate); + } + } + + which::which(filename) + .ok() + .filter(|p| is_executable_file(p)) +} + +pub fn default_state_dir(data_dir: &Path) -> PathBuf { + data_dir.join("tailscale") +} + +/// Stable hostname derived from the installation data dir. +pub fn default_hostname(data_dir: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(data_dir.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let short = digest + .iter() + .take(4) + .map(|b| format!("{b:02x}")) + .collect::(); + format!("codeg-{short}") +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o111 == 0 { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostname_is_stable_for_same_data_dir() { + let p = std::path::PathBuf::from("/tmp/codeg-data-a"); + assert_eq!(default_hostname(&p), default_hostname(&p)); + assert!(default_hostname(&p).starts_with("codeg-")); + assert_eq!(default_hostname(&p).len(), "codeg-".len() + 8); + } + + #[test] + fn state_dir_is_under_data_dir() { + let p = std::path::PathBuf::from("/tmp/codeg-data-a"); + assert_eq!(default_state_dir(&p), p.join("tailscale")); + } +} diff --git a/src-tauri/src/web/tailscale/controller.rs b/src-tauri/src/web/tailscale/controller.rs new file mode 100644 index 000000000..cfcce3c7a --- /dev/null +++ b/src-tauri/src/web/tailscale/controller.rs @@ -0,0 +1,572 @@ +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::{Child, ChildStderr, ChildStdout}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::app_error::AppCommandError; +use crate::web::tailscale::binary::{default_hostname, default_state_dir, locate_codeg_tsnet_binary}; +use crate::web::tailscale::protocol::{SidecarBootstrap, SidecarStatus}; +use crate::web::tailscale::status::{ + map_status, TailscaleFunnelStatus, ERR_AUTHKEY_REQUIRED, ERR_FUNNEL_FAILED, ERR_LOGIN_TIMEOUT, + ERR_SIDECAR_MISSING, ERR_START_FAILED, +}; + +const CONTROL_TOKEN_HEADER: &str = "X-Codeg-Tsnet-Token"; +const BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(5); +const LOGIN_TIMEOUT: Duration = Duration::from_secs(600); +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone)] +pub struct EnableFunnelOpts { + pub data_dir: PathBuf, + pub localhost_port: u16, + pub auth_key: Option, + pub require_auth_key: bool, + pub state_dir_override: Option, + pub hostname_override: Option, +} + +struct RunningSidecar { + child: Child, + control_addr: String, + token: String, + enabled: bool, + last_status: SidecarStatus, + _stderr_task: tokio::task::JoinHandle<()>, +} + +struct Inner { + running: Option, + last_error: Option, +} + +pub struct TailscaleController { + inner: Mutex, + http: reqwest::Client, +} + +impl Default for TailscaleController { + fn default() -> Self { + Self::new() + } +} + +impl TailscaleController { + pub fn new() -> Self { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .no_proxy() + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + Self { + inner: Mutex::new(Inner { + running: None, + last_error: None, + }), + http, + } + } + + pub async fn status(&self) -> TailscaleFunnelStatus { + let mut guard = self.inner.lock().await; + if let Some(err) = guard.last_error.clone() { + if guard.running.is_none() { + return err; + } + } + let Some(running) = guard.running.as_mut() else { + return TailscaleFunnelStatus::stopped(); + }; + + match fetch_status(&self.http, &running.control_addr, &running.token).await { + Ok(raw) => { + running.last_status = raw.clone(); + map_status(true, running.enabled, raw) + } + Err(err) => { + // Sidecar may have crashed. + let detail = err.to_string(); + if let Some(mut running) = guard.running.take() { + let _ = running.child.kill().await; + } + let st = TailscaleFunnelStatus { + supported: locate_codeg_tsnet_binary().is_some(), + enabled: false, + state: "error".into(), + login_url: None, + funnel_url: None, + hostname: None, + ipv4: None, + last_error: Some(detail), + error_key: Some(ERR_START_FAILED.into()), + }; + guard.last_error = Some(st.clone()); + st + } + } + } + + pub async fn enable_funnel( + &self, + opts: EnableFunnelOpts, + ) -> Result { + if opts.require_auth_key + && opts + .auth_key + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .is_none() + { + let st = TailscaleFunnelStatus::unsupported( + ERR_AUTHKEY_REQUIRED, + "CODEG_TS_AUTHKEY is required for headless Funnel", + ); + let mut guard = self.inner.lock().await; + guard.last_error = Some(st.clone()); + return Ok(st); + } + + let Some(bin) = locate_codeg_tsnet_binary() else { + let st = TailscaleFunnelStatus::unsupported( + ERR_SIDECAR_MISSING, + "codeg-tsnet sidecar binary not found", + ); + let mut guard = self.inner.lock().await; + guard.last_error = Some(st.clone()); + return Ok(st); + }; + + let state_dir = opts + .state_dir_override + .clone() + .unwrap_or_else(|| default_state_dir(&opts.data_dir)); + let hostname = opts + .hostname_override + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| default_hostname(&opts.data_dir)); + + if let Err(err) = std::fs::create_dir_all(&state_dir) { + return Err(AppCommandError::io_error(format!( + "failed to create Tailscale state dir: {err}" + ))); + } + + // Restart sidecar cleanly if already running. + { + let mut guard = self.inner.lock().await; + if let Some(mut running) = guard.running.take() { + let _ = post_json( + &self.http, + &running.control_addr, + &running.token, + "/shutdown", + &serde_json::json!({}), + ) + .await; + let _ = tokio::time::timeout(Duration::from_secs(2), running.child.wait()).await; + let _ = running.child.kill().await; + } + guard.last_error = None; + } + + let token = Uuid::new_v4().to_string(); + let mut cmd = crate::process::tokio_command(&bin); + cmd.arg("--control-addr") + .arg("127.0.0.1:0") + .arg("--state-dir") + .arg(&state_dir) + .arg("--hostname") + .arg(&hostname) + .arg("--control-token") + .arg(&token) + .kill_on_drop(true) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(key) = opts.auth_key.as_ref().filter(|s| !s.trim().is_empty()) { + cmd.arg("--auth-key").arg(key); + } + + let mut child = cmd.spawn().map_err(|err| { + AppCommandError::external_command( + ERR_START_FAILED, + format!("failed to spawn codeg-tsnet: {err}"), + ) + })?; + + let stdout = child.stdout.take().ok_or_else(|| { + AppCommandError::external_command(ERR_START_FAILED, "missing sidecar stdout") + })?; + let stderr = child.stderr.take(); + let stderr_task = spawn_stderr_forwarder(stderr); + + let bootstrap = match read_bootstrap_line(stdout, BOOTSTRAP_TIMEOUT).await { + Ok(b) => b, + Err(err) => { + let _ = child.kill().await; + return Err(err); + } + }; + + { + let mut guard = self.inner.lock().await; + guard.running = Some(RunningSidecar { + child, + control_addr: bootstrap.control_addr.clone(), + token: token.clone(), + enabled: true, + last_status: SidecarStatus { + state: "starting".into(), + login_url: None, + funnel_url: None, + hostname: Some(hostname.clone()), + ipv4: None, + last_error: None, + error_key: None, + backend_state: None, + }, + _stderr_task: stderr_task, + }); + } + + // Bring the node up. + let up_body = match opts.auth_key.as_ref().filter(|s| !s.trim().is_empty()) { + Some(key) => serde_json::json!({ "authKey": key }), + None => serde_json::json!({}), + }; + if let Err(err) = post_json( + &self.http, + &bootstrap.control_addr, + &token, + "/up", + &up_body, + ) + .await + { + tracing::warn!(%err, "[tailscale] POST /up failed"); + } + + // Wait for online / needs_login, then enable funnel. + let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT; + loop { + if tokio::time::Instant::now() > deadline { + let st = TailscaleFunnelStatus { + supported: true, + enabled: true, + state: "error".into(), + login_url: None, + funnel_url: None, + hostname: Some(hostname.clone()), + ipv4: None, + last_error: Some("Tailscale login timed out".into()), + error_key: Some(ERR_LOGIN_TIMEOUT.into()), + }; + let mut guard = self.inner.lock().await; + guard.last_error = Some(st.clone()); + return Ok(st); + } + + let raw = match fetch_status(&self.http, &bootstrap.control_addr, &token).await { + Ok(s) => s, + Err(err) => { + tracing::warn!(%err, "[tailscale] status poll failed"); + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + }; + + { + let mut guard = self.inner.lock().await; + if let Some(running) = guard.running.as_mut() { + running.last_status = raw.clone(); + running.enabled = true; + } + } + + match raw.state.as_str() { + "needs_login" => { + // Return promptly so the UI can open the login URL. + // Subsequent status polls / enable retries will continue + // once the user finishes interactive auth. + let mapped = map_status(true, true, raw); + let mut guard = self.inner.lock().await; + if let Some(running) = guard.running.as_mut() { + running.enabled = true; + } + return Ok(mapped); + } + "online" | "funnel_ready" | "funnel_enabling" => { + // Enable / refresh funnel against current localhost port. + let body = serde_json::json!({ + "enabled": true, + "localhostPort": opts.localhost_port, + }); + match post_status( + &self.http, + &bootstrap.control_addr, + &token, + "/funnel", + &body, + ) + .await + { + Ok(st_raw) => { + let mapped = map_status(true, true, st_raw.clone()); + let mut guard = self.inner.lock().await; + if let Some(running) = guard.running.as_mut() { + running.last_status = st_raw; + running.enabled = true; + } + if mapped.state == "funnel_ready" + || mapped.state == "needs_login" + || mapped.state == "error" + { + return Ok(mapped); + } + } + Err(err) => { + tracing::warn!(%err, "[tailscale] POST /funnel failed"); + let st = TailscaleFunnelStatus { + supported: true, + enabled: true, + state: "error".into(), + login_url: raw.login_url.clone(), + funnel_url: None, + hostname: Some(hostname.clone()), + ipv4: raw.ipv4.clone(), + last_error: Some(err.to_string()), + error_key: Some(ERR_FUNNEL_FAILED.into()), + }; + let mut guard = self.inner.lock().await; + guard.last_error = Some(st.clone()); + return Ok(st); + } + } + } + "error" => { + let mapped = map_status(true, true, raw); + let mut guard = self.inner.lock().await; + guard.last_error = Some(mapped.clone()); + return Ok(mapped); + } + _ => {} + } + + tokio::time::sleep(POLL_INTERVAL).await; + } + } + + pub async fn disable_funnel(&self) -> Result<(), AppCommandError> { + let mut guard = self.inner.lock().await; + let Some(mut running) = guard.running.take() else { + guard.last_error = None; + return Ok(()); + }; + + let _ = post_json( + &self.http, + &running.control_addr, + &running.token, + "/funnel", + &serde_json::json!({ "enabled": false, "localhostPort": 0 }), + ) + .await; + let _ = post_json( + &self.http, + &running.control_addr, + &running.token, + "/shutdown", + &serde_json::json!({}), + ) + .await; + let _ = tokio::time::timeout(Duration::from_secs(2), running.child.wait()).await; + let _ = running.child.kill().await; + guard.last_error = None; + Ok(()) + } + + pub async fn open_login_hint(&self) -> Result, AppCommandError> { + let st = self.status().await; + Ok(st.login_url) + } + + pub async fn shutdown(&self) { + let _ = self.disable_funnel().await; + } +} + +async fn read_bootstrap_line( + stdout: ChildStdout, + timeout: Duration, +) -> Result { + let mut reader = BufReader::new(stdout).lines(); + let line = tokio::time::timeout(timeout, reader.next_line()) + .await + .map_err(|_| { + AppCommandError::external_command(ERR_START_FAILED, "timed out waiting for sidecar bootstrap") + })? + .map_err(|err| { + AppCommandError::external_command( + ERR_START_FAILED, + format!("failed reading sidecar bootstrap: {err}"), + ) + })? + .ok_or_else(|| { + AppCommandError::external_command(ERR_START_FAILED, "sidecar exited before bootstrap") + })?; + + serde_json::from_str::(&line).map_err(|err| { + AppCommandError::external_command( + ERR_START_FAILED, + format!("invalid sidecar bootstrap JSON: {err}"), + ) + }) +} + +fn spawn_stderr_forwarder(stderr: Option) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let Some(stderr) = stderr else { + return; + }; + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + // Never log secrets; the sidecar already redacts auth keys. + if line.to_ascii_lowercase().contains("authkey") + || line.to_ascii_lowercase().contains("auth key") + || line.to_ascii_lowercase().contains("control-token") + { + continue; + } + tracing::info!(target: "codeg_tsnet", "{line}"); + } + }) +} + +async fn fetch_status( + http: &reqwest::Client, + control_addr: &str, + token: &str, +) -> Result { + let url = format!("http://{control_addr}/status"); + let resp = http + .get(url) + .header(CONTROL_TOKEN_HEADER, token) + .send() + .await + .map_err(|err| AppCommandError::network(format!("sidecar /status failed: {err}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "sidecar /status HTTP {}", + resp.status() + ))); + } + resp.json::() + .await + .map_err(|err| AppCommandError::network(format!("sidecar /status decode failed: {err}"))) +} + +async fn post_json( + http: &reqwest::Client, + control_addr: &str, + token: &str, + path: &str, + body: &serde_json::Value, +) -> Result<(), AppCommandError> { + let url = format!("http://{control_addr}{path}"); + let resp = http + .post(url) + .header(CONTROL_TOKEN_HEADER, token) + .json(body) + .send() + .await + .map_err(|err| AppCommandError::network(format!("sidecar {path} failed: {err}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "sidecar {path} HTTP {}", + resp.status() + ))); + } + Ok(()) +} + +async fn post_status( + http: &reqwest::Client, + control_addr: &str, + token: &str, + path: &str, + body: &serde_json::Value, +) -> Result { + let url = format!("http://{control_addr}{path}"); + let resp = http + .post(url) + .header(CONTROL_TOKEN_HEADER, token) + .json(body) + .send() + .await + .map_err(|err| AppCommandError::network(format!("sidecar {path} failed: {err}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "sidecar {path} HTTP {}", + resp.status() + ))); + } + resp.json::() + .await + .map_err(|err| AppCommandError::network(format!("sidecar {path} decode failed: {err}"))) +} + +// Keep Arc alias available for callers that want shared ownership. +pub type SharedTailscaleController = Arc; + +#[cfg(test)] +mod tests { + use super::*; + use crate::web::tailscale::protocol::SidecarStatus; + use crate::web::tailscale::status::map_status; + + #[tokio::test] + async fn missing_binary_is_unsupported() { + // Ensure no accidental binary on PATH for this process by pointing to + // a non-existent override. locate still falls back to PATH, so this + // assertion is best-effort and only checks the controller error path + // when the binary truly cannot be found. + let c = TailscaleController::new(); + // Force authkey required path first (deterministic). + let st = c + .enable_funnel(EnableFunnelOpts { + data_dir: std::env::temp_dir().join("codeg-ts-test-missing"), + localhost_port: 3080, + auth_key: None, + require_auth_key: true, + state_dir_override: None, + hostname_override: Some("codeg-test".into()), + }) + .await + .unwrap(); + assert!(!st.supported); + assert_eq!(st.error_key.as_deref(), Some(ERR_AUTHKEY_REQUIRED)); + } + + #[test] + fn maps_sidecar_status_to_funnel_status() { + let raw = SidecarStatus { + state: "funnel_ready".into(), + login_url: None, + funnel_url: Some("https://codeg-abc.ts.net".into()), + hostname: Some("codeg-abc".into()), + ipv4: Some("100.64.0.1".into()), + last_error: None, + error_key: None, + backend_state: Some("Running".into()), + }; + let st = map_status(true, true, raw); + assert_eq!(st.state, "funnel_ready"); + assert_eq!(st.funnel_url.as_deref(), Some("https://codeg-abc.ts.net")); + } +} diff --git a/src-tauri/src/web/tailscale/mod.rs b/src-tauri/src/web/tailscale/mod.rs new file mode 100644 index 000000000..c5e88e1a1 --- /dev/null +++ b/src-tauri/src/web/tailscale/mod.rs @@ -0,0 +1,207 @@ +//! Tailscale Funnel sidecar control plane. +//! +//! Codeg never talks to system Tailscale. All Funnel lifecycle is owned by the +//! pure-Go `codeg-tsnet` userspace sidecar under an independent state directory. + +pub mod binary; +pub mod controller; +pub mod protocol; +pub mod status; + +pub use binary::{default_hostname, default_state_dir, locate_codeg_tsnet_binary}; +pub use controller::{EnableFunnelOpts, TailscaleController}; +pub use protocol::{SidecarBootstrap, SidecarStatus}; +pub use status::TailscaleFunnelStatus; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::web::{load_web_service_config, update_web_service_config_core, DEFAULT_WEB_SERVICE_PORT}; + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetTailscaleFunnelEnabledParams { + pub enabled: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenTailscaleLoginResult { + pub login_url: Option, +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn get_tailscale_funnel_status( + controller: tauri::State<'_, std::sync::Arc>, +) -> Result { + Ok(controller.status().await) +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn set_tailscale_funnel_enabled( + app: tauri::AppHandle, + controller: tauri::State<'_, std::sync::Arc>, + ws: tauri::State<'_, crate::web::WebServerState>, + params: SetTailscaleFunnelEnabledParams, +) -> Result { + use tauri::Manager; + let db = app.state::(); + let data_dir = crate::paths::resolve_effective_data_dir( + &app.path().app_data_dir().unwrap_or_default(), + ); + let port = ws.current_port(); + set_funnel_enabled_parts( + controller.inner().clone(), + &db.conn, + data_dir, + port, + params.enabled, + false, + ) + .await +} + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn open_tailscale_login( + controller: tauri::State<'_, std::sync::Arc>, +) -> Result { + let login_url = controller.open_login_hint().await?; + Ok(OpenTailscaleLoginResult { login_url }) +} + +/// Resolve the localhost port for Funnel. +/// +/// Desktop Tauri embeds a placeholder `WebServerState` inside the router +/// `AppState`, so `current_port()` may be 0 even while the managed listener +/// is live. Fall back to the persisted config port in that case. +async fn resolve_funnel_port( + conn: &sea_orm::DatabaseConnection, + current_port: u16, +) -> u16 { + if current_port != 0 { + return current_port; + } + match load_web_service_config(conn).await { + Ok(cfg) => cfg.port.unwrap_or(DEFAULT_WEB_SERVICE_PORT), + Err(_) => DEFAULT_WEB_SERVICE_PORT, + } +} + +pub async fn set_funnel_enabled_core( + state: std::sync::Arc, + enabled: bool, +) -> Result { + let port = state.web_server_state.current_port(); + set_funnel_enabled_parts( + state.tailscale.clone(), + &state.db.conn, + state.data_dir.clone(), + port, + enabled, + false, + ) + .await +} + +pub async fn set_funnel_enabled_parts( + controller: std::sync::Arc, + conn: &sea_orm::DatabaseConnection, + data_dir: std::path::PathBuf, + current_port: u16, + enabled: bool, + require_auth_key: bool, +) -> Result { + if !enabled { + // Persist off first so a later restart doesn't re-enable. + if let Ok(mut cfg) = load_web_service_config(conn).await { + if cfg.funnel_enabled { + cfg.funnel_enabled = false; + let _ = update_web_service_config_core(conn, cfg).await; + } + } + controller.disable_funnel().await?; + return Ok(controller.status().await); + } + + let port = resolve_funnel_port(conn, current_port).await; + if port == 0 { + return Err(AppCommandError::invalid_input( + "Web Service must be running before enabling Funnel", + )); + } + + // Persist on. + if let Ok(mut cfg) = load_web_service_config(conn).await { + if !cfg.funnel_enabled { + cfg.funnel_enabled = true; + let _ = update_web_service_config_core(conn, cfg).await; + } + } + + controller + .enable_funnel(EnableFunnelOpts { + data_dir, + localhost_port: port, + auth_key: std::env::var("CODEG_TS_AUTHKEY") + .ok() + .filter(|s| !s.trim().is_empty()), + require_auth_key, + state_dir_override: std::env::var_os("CODEG_TS_STATE_DIR").map(std::path::PathBuf::from), + hostname_override: std::env::var("CODEG_TS_HOSTNAME") + .ok() + .filter(|s| !s.trim().is_empty()), + }) + .await +} + +/// Best-effort Funnel enable used after a successful web bind. +pub async fn maybe_enable_funnel_after_web_start( + state: &AppState, + port: u16, + require_auth_key: bool, +) { + let enabled = match load_web_service_config(&state.db.conn).await { + Ok(cfg) => cfg.funnel_enabled, + Err(err) => { + tracing::warn!(%err, "[tailscale] failed to load funnel config"); + false + } + }; + let env_enabled = matches!( + std::env::var("CODEG_TS_FUNNEL").as_deref(), + Ok("1") | Ok("true") | Ok("yes") + ); + if !(enabled || env_enabled) { + return; + } + + match state + .tailscale + .enable_funnel(EnableFunnelOpts { + data_dir: state.data_dir.clone(), + localhost_port: port, + auth_key: std::env::var("CODEG_TS_AUTHKEY") + .ok() + .filter(|s| !s.trim().is_empty()), + require_auth_key, + state_dir_override: std::env::var_os("CODEG_TS_STATE_DIR").map(std::path::PathBuf::from), + hostname_override: std::env::var("CODEG_TS_HOSTNAME") + .ok() + .filter(|s| !s.trim().is_empty()), + }) + .await + { + Ok(st) => { + tracing::info!( + state = %st.state, + funnel_url = ?st.funnel_url, + "[tailscale] Funnel status after web start" + ); + } + Err(err) => { + tracing::warn!(%err, "[tailscale] Funnel not enabled"); + } + } +} diff --git a/src-tauri/src/web/tailscale/protocol.rs b/src-tauri/src/web/tailscale/protocol.rs new file mode 100644 index 000000000..3e6a4cd41 --- /dev/null +++ b/src-tauri/src/web/tailscale/protocol.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SidecarBootstrap { + pub control_addr: String, + pub pid: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SidecarStatus { + pub state: String, + #[serde(default)] + pub login_url: Option, + #[serde(default)] + pub funnel_url: Option, + #[serde(default)] + pub hostname: Option, + #[serde(default)] + pub ipv4: Option, + #[serde(default)] + pub last_error: Option, + #[serde(default)] + pub error_key: Option, + #[serde(default)] + pub backend_state: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bootstrap_json() { + let raw = r#"{"controlAddr":"127.0.0.1:9","pid":42}"#; + let b: SidecarBootstrap = serde_json::from_str(raw).unwrap(); + assert_eq!(b.control_addr, "127.0.0.1:9"); + assert_eq!(b.pid, 42); + } + + #[test] + fn parses_status_json_camel_case() { + let raw = r#"{ + "state":"needs_login", + "loginUrl":"https://login.tailscale.com/a/x", + "hostname":"codeg-test" + }"#; + let s: SidecarStatus = serde_json::from_str(raw).unwrap(); + assert_eq!(s.state, "needs_login"); + assert_eq!( + s.login_url.as_deref(), + Some("https://login.tailscale.com/a/x") + ); + assert_eq!(s.hostname.as_deref(), Some("codeg-test")); + } +} diff --git a/src-tauri/src/web/tailscale/status.rs b/src-tauri/src/web/tailscale/status.rs new file mode 100644 index 000000000..b56f5172b --- /dev/null +++ b/src-tauri/src/web/tailscale/status.rs @@ -0,0 +1,111 @@ +use serde::{Deserialize, Serialize}; + +use crate::web::tailscale::binary::locate_codeg_tsnet_binary; +use crate::web::tailscale::protocol::SidecarStatus; + +pub const ERR_SIDECAR_MISSING: &str = "tailscale.sidecar_missing"; +pub const ERR_START_FAILED: &str = "tailscale.start_failed"; +pub const ERR_LOGIN_TIMEOUT: &str = "tailscale.login_timeout"; +pub const ERR_FUNNEL_DENIED: &str = "tailscale.funnel_denied"; +pub const ERR_FUNNEL_FAILED: &str = "tailscale.funnel_failed"; +pub const ERR_AUTHKEY_REQUIRED: &str = "tailscale.authkey_required"; +pub const ERR_UNSUPPORTED: &str = "tailscale.unsupported"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TailscaleFunnelStatus { + pub supported: bool, + pub enabled: bool, + pub state: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub login_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub funnel_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ipv4: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_key: Option, +} + +impl TailscaleFunnelStatus { + pub fn unsupported(error_key: &str, last_error: impl Into) -> Self { + Self { + supported: false, + enabled: false, + state: "error".into(), + login_url: None, + funnel_url: None, + hostname: None, + ipv4: None, + last_error: Some(last_error.into()), + error_key: Some(error_key.to_string()), + } + } + + pub fn stopped() -> Self { + Self { + supported: locate_codeg_tsnet_binary().is_some(), + enabled: false, + state: "stopped".into(), + login_url: None, + funnel_url: None, + hostname: None, + ipv4: None, + last_error: None, + error_key: None, + } + } +} + +pub fn map_status(supported: bool, enabled: bool, raw: SidecarStatus) -> TailscaleFunnelStatus { + TailscaleFunnelStatus { + supported, + enabled, + state: raw.state, + login_url: empty_to_none(raw.login_url), + funnel_url: empty_to_none(raw.funnel_url), + hostname: empty_to_none(raw.hostname), + ipv4: empty_to_none(raw.ipv4), + last_error: empty_to_none(raw.last_error), + error_key: empty_to_none(raw.error_key), + } +} + +fn empty_to_none(v: Option) -> Option { + v.and_then(|s| { + let t = s.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_sidecar_status_to_funnel_status() { + let raw = SidecarStatus { + state: "funnel_ready".into(), + login_url: None, + funnel_url: Some("https://codeg-abc.ts.net".into()), + hostname: Some("codeg-abc".into()), + ipv4: Some("100.64.0.1".into()), + last_error: None, + error_key: None, + backend_state: Some("Running".into()), + }; + let st = map_status(true, true, raw); + assert_eq!(st.state, "funnel_ready"); + assert_eq!(st.funnel_url.as_deref(), Some("https://codeg-abc.ts.net")); + assert!(st.enabled); + assert!(st.supported); + } +} From 8056dd64976ae20e107b6033b1dc9b2fed7f28f8 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:43:23 +0800 Subject: [PATCH 05/10] feat(web): wire Tailscale Funnel into web service lifecycle and APIs Hook Funnel enable/disable into desktop and codeg-server web start/stop paths, and expose status/enable/login via Tauri commands and HTTP routes. --- src-tauri/src/app_state.rs | 3 ++ src-tauri/src/bin/codeg_server.rs | 7 +++++ src-tauri/src/lib.rs | 11 +++++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/handlers/tailscale.rs | 38 ++++++++++++++++++++++++ src-tauri/src/web/handlers/web_server.rs | 2 ++ src-tauri/src/web/router.rs | 12 ++++++++ 7 files changed, 74 insertions(+) create mode 100644 src-tauri/src/web/handlers/tailscale.rs diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index 7d43d873e..68c265fef 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -26,6 +26,8 @@ pub struct AppState { pub emitter: EventEmitter, pub data_dir: PathBuf, pub web_server_state: WebServerState, + /// Private Tailscale Funnel sidecar controller. Never touches system Tailscale. + pub tailscale: Arc, pub chat_channel_manager: ChatChannelManager, pub workspace_transfer: Arc, /// Latest ambient `PetState` written by `pet_state_subscriber_task`. @@ -211,6 +213,7 @@ impl AppState { emitter, data_dir, web_server_state: crate::web::WebServerState::new(), + tailscale: Arc::new(crate::web::tailscale::TailscaleController::new()), chat_channel_manager: default_chat_channel_manager(), workspace_transfer: Arc::new( crate::workspace_transfer::WorkspaceTransferManager::new_for_tests( diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 07b30df01..658c8a93d 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -278,6 +278,7 @@ async fn async_main() -> ExitCode { emitter, data_dir, web_server_state: WebServerState::new(), + tailscale: std::sync::Arc::new(codeg_lib::web::tailscale::TailscaleController::new()), chat_channel_manager: codeg_lib::app_state::default_chat_channel_manager(), workspace_transfer: Arc::new( codeg_lib::workspace_transfer::WorkspaceTransferManager::new_from_env(), @@ -521,13 +522,19 @@ async fn async_main() -> ExitCode { tracing::info!(" {}", addr); } + // Optional Tailscale Funnel public HTTPS. Failures never take down the + // local server; headless mode requires CODEG_TS_AUTHKEY. + codeg_lib::web::tailscale::maybe_enable_funnel_after_web_start(&state, actual_port, true).await; + // Start serving if let Err(e) = axum::serve(listener, router).await { tracing::error!("[SERVER] Server error: {}", e); + state.tailscale.shutdown().await; return ExitCode::from(1); } // Graceful shutdown: release any live office watch preview servers // (kill_on_drop is the backstop, but this frees their ports promptly). + state.tailscale.shutdown().await; codeg_lib::office_watch::stop_all_office_watches(); ExitCode::SUCCESS } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23dea8969..3fa1c4d3b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -195,6 +195,9 @@ mod tauri_app { .manage(windows::CommitWindowState::new()) .manage(windows::MergeWindowState::new()) .manage(web::WebServerState::new()) + .manage(std::sync::Arc::new( + web::tailscale::TailscaleController::new(), + )) // Remote-workspace IPC proxy. Routes HTTP / WS for windows // opened against a remote codeg-server through Rust so we // bypass webview mixed-content blocking and can centrally @@ -1222,6 +1225,9 @@ mod tauri_app { web::get_web_service_config, web::update_web_service_config, web::probe_web_service_port, + web::tailscale::get_tailscale_funnel_status, + web::tailscale::set_tailscale_funnel_enabled, + web::tailscale::open_tailscale_login, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -1236,6 +1242,11 @@ mod tauri_app { if let Some(pet) = app.get_webview_window("pet") { let _ = pet.close(); } + if let Some(controller) = + app.try_state::>() + { + tauri::async_runtime::block_on(controller.shutdown()); + } if let Some(ws) = app.try_state::() { tauri::async_runtime::block_on(web::do_stop_web_server(&ws)); } diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index 914ed7218..a7797aba5 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -29,6 +29,7 @@ pub mod system_settings; pub mod terminal; mod upload_jail; pub mod version_control; +pub mod tailscale; pub mod web_server; pub mod workspace_files; pub mod workspace_state; diff --git a/src-tauri/src/web/handlers/tailscale.rs b/src-tauri/src/web/handlers/tailscale.rs new file mode 100644 index 000000000..63e9460de --- /dev/null +++ b/src-tauri/src/web/handlers/tailscale.rs @@ -0,0 +1,38 @@ +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; +use crate::web::tailscale::{ + set_funnel_enabled_core, OpenTailscaleLoginResult, TailscaleFunnelStatus, +}; + +pub async fn get_status( + Extension(state): Extension>, +) -> Result, AppCommandError> { + Ok(Json(state.tailscale.status().await)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetEnabledParams { + pub enabled: bool, +} + +pub async fn set_enabled( + Extension(state): Extension>, + Json(params): Json, +) -> Result, AppCommandError> { + set_funnel_enabled_core(state, params.enabled) + .await + .map(Json) +} + +pub async fn open_login( + Extension(state): Extension>, +) -> Result, AppCommandError> { + let login_url = state.tailscale.open_login_hint().await?; + Ok(Json(OpenTailscaleLoginResult { login_url })) +} diff --git a/src-tauri/src/web/handlers/web_server.rs b/src-tauri/src/web/handlers/web_server.rs index 30cd210de..a5c8aa937 100644 --- a/src-tauri/src/web/handlers/web_server.rs +++ b/src-tauri/src/web/handlers/web_server.rs @@ -77,6 +77,8 @@ pub async fn stop_web_server( "Cannot stop web server from within web mode", )); } + // Best-effort Funnel teardown before stopping the local listener. + state.tailscale.shutdown().await; do_stop_web_server(&state.web_server_state).await; Ok(Json(())) } diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 65bd39246..888d6ee7e 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -923,6 +923,18 @@ pub fn build_router( "/probe_web_service_port", post(handlers::web_server::probe_web_service_port), ) + .route( + "/get_tailscale_funnel_status", + post(handlers::tailscale::get_status), + ) + .route( + "/set_tailscale_funnel_enabled", + post(handlers::tailscale::set_enabled), + ) + .route( + "/open_tailscale_login", + post(handlers::tailscale::open_login), + ) .route( "/check_app_update", post(handlers::web_server::check_app_update), From f29b3f603cb008a0b91b40dd7670c3e2f76508b4 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:43:29 +0800 Subject: [PATCH 06/10] feat(ui): add Tailscale Funnel controls to Web Service settings Add Funnel status UI, API client wrappers, and i18n keys so desktop users can enable Funnel, open login, and copy the public URL. --- .../web-service-funnel-section.test.tsx | 91 +++++++ .../settings/web-service-funnel-section.tsx | 236 ++++++++++++++++++ .../settings/web-service-settings.tsx | 15 ++ src/i18n/messages/ar.json | 19 ++ src/i18n/messages/de.json | 19 ++ src/i18n/messages/en.json | 19 ++ src/i18n/messages/es.json | 19 ++ src/i18n/messages/fr.json | 19 ++ src/i18n/messages/ja.json | 19 ++ src/i18n/messages/ko.json | 19 ++ src/i18n/messages/pt.json | 19 ++ src/i18n/messages/zh-CN.json | 19 ++ src/i18n/messages/zh-TW.json | 19 ++ src/lib/api.ts | 40 +++ 14 files changed, 572 insertions(+) create mode 100644 src/components/settings/web-service-funnel-section.test.tsx create mode 100644 src/components/settings/web-service-funnel-section.tsx diff --git a/src/components/settings/web-service-funnel-section.test.tsx b/src/components/settings/web-service-funnel-section.test.tsx new file mode 100644 index 000000000..3dc785972 --- /dev/null +++ b/src/components/settings/web-service-funnel-section.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/lib/api", () => ({ + getTailscaleFunnelStatus: vi.fn(), + setTailscaleFunnelEnabled: vi.fn(), + openTailscaleLogin: vi.fn(), +})) + +vi.mock("@/lib/platform", () => ({ + openUrl: vi.fn(), +})) + +vi.mock("sonner", () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/utils", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + copyTextToClipboard: vi.fn(), + } +}) + +vi.mock("@/hooks/use-copied-flag", () => ({ + useCopiedFlag: () => [false, vi.fn()], +})) + +import { WebServiceFunnelSection } from "./web-service-funnel-section" +import enMessages from "@/i18n/messages/en.json" +import { + getTailscaleFunnelStatus, + type TailscaleFunnelStatus, +} from "@/lib/api" + +const mockGet = vi.mocked(getTailscaleFunnelStatus) + +function status( + overrides: Partial = {} +): TailscaleFunnelStatus { + return { + supported: true, + enabled: false, + state: "stopped", + ...overrides, + } +} + +function renderSection(webRunning = true) { + return render( + + + + ) +} + +beforeEach(() => { + mockGet.mockReset() +}) + +describe("WebServiceFunnelSection", () => { + it("shows login action when needs_login", async () => { + mockGet.mockResolvedValue( + status({ + enabled: true, + state: "needs_login", + loginUrl: "https://login.tailscale.com/a/x", + }) + ) + renderSection(true) + expect( + await screen.findByRole("button", { name: /Open Tailscale login/i }) + ).toBeInTheDocument() + }) + + it("renders funnel url when ready", async () => { + mockGet.mockResolvedValue( + status({ + enabled: true, + state: "funnel_ready", + funnelUrl: "https://codeg-abc.ts.net", + }) + ) + renderSection(true) + await waitFor(() => { + expect(screen.getByText("https://codeg-abc.ts.net")).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/settings/web-service-funnel-section.tsx b/src/components/settings/web-service-funnel-section.tsx new file mode 100644 index 000000000..3682ff672 --- /dev/null +++ b/src/components/settings/web-service-funnel-section.tsx @@ -0,0 +1,236 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { useTranslations } from "next-intl" +import { Check, Copy, ExternalLink } from "lucide-react" +import { openUrl } from "@/lib/platform" +import { toast } from "sonner" + +import { Switch } from "@/components/ui/switch" +import { + getTailscaleFunnelStatus, + openTailscaleLogin, + setTailscaleFunnelEnabled, + type TailscaleFunnelStatus, +} from "@/lib/api" +import { copyTextToClipboard } from "@/lib/utils" +import { useCopiedFlag } from "@/hooks/use-copied-flag" + +const TRANSITIONAL = new Set([ + "starting", + "needs_login", + "connecting", + "online", + "funnel_enabling", + "stopping", +]) + +function errorMessage( + t: ReturnType, + status: TailscaleFunnelStatus | null +): string | null { + if (!status) return null + const key = status.errorKey + if (key === "tailscale.sidecar_missing") return t("funnelErrors.sidecarMissing") + if (key === "tailscale.start_failed") return t("funnelErrors.startFailed") + if (key === "tailscale.login_timeout") return t("funnelErrors.loginTimeout") + if (key === "tailscale.funnel_denied") return t("funnelErrors.funnelDenied") + if (key === "tailscale.funnel_failed") return t("funnelErrors.funnelFailed") + if (key === "tailscale.authkey_required") + return t("funnelErrors.authkeyRequired") + if (key === "tailscale.unsupported") return t("funnelErrors.unsupported") + return status.lastError ?? null +} + +export function WebServiceFunnelSection({ + webRunning, +}: { + webRunning: boolean +}) { + const t = useTranslations("WebServiceSettings") + const [status, setStatus] = useState(null) + const [busy, setBusy] = useState(false) + const [copied, markCopied] = useCopiedFlag() + const [error, setError] = useState("") + + const refresh = useCallback(async () => { + try { + const next = await getTailscaleFunnelStatus() + setStatus(next) + setError("") + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + }, []) + + useEffect(() => { + void refresh() + }, [refresh, webRunning]) + + useEffect(() => { + if (!status || !TRANSITIONAL.has(status.state)) return + const id = window.setInterval(() => { + void refresh() + }, 1500) + return () => window.clearInterval(id) + }, [status, refresh]) + + const mappedError = useMemo(() => errorMessage(t, status), [status, t]) + + async function handleToggle(enabled: boolean) { + setBusy(true) + setError("") + try { + const next = await setTailscaleFunnelEnabled(enabled) + setStatus(next) + if (next.loginUrl && next.state === "needs_login") { + try { + await openUrl(next.loginUrl) + } catch { + // ignore opener failures; login button remains available + } + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + async function handleLogin() { + setBusy(true) + try { + const res = await openTailscaleLogin() + const url = res.loginUrl || status?.loginUrl + if (url) { + await openUrl(url) + } else { + toast.error(t("funnelErrors.loginTimeout")) + } + await refresh() + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + async function handleCopy() { + if (!status?.funnelUrl) return + try { + const ok = await copyTextToClipboard(status.funnelUrl) + if (!ok) { + toast.error(t("copyFailed")) + return + } + markCopied() + } catch { + toast.error(t("copyFailed")) + } + } + + const supported = status?.supported ?? true + const enabled = status?.enabled ?? false + const switchDisabled = busy || !webRunning || supported === false + + return ( +
+
+
{t("funnelTitle")}
+

+ {t("funnelDescription")} +

+
+ +
+ +
+ { + void handleToggle(checked) + }} + aria-label={t("funnelEnable")} + /> + + {t("funnelEnableHint")} + +
+
+ +
+ +
+ + {status?.state ?? "stopped"} + + {status?.hostname ? ( + + {status.hostname} + {status.ipv4 ? ` · ${status.ipv4}` : ""} + + ) : null} +
+
+ + {status?.state === "needs_login" || status?.loginUrl ? ( +
+ +
+ ) : null} + + {status?.funnelUrl ? ( +
+
+ {t("funnelUrl")} +
+
+ + {status.funnelUrl} + + + +
+
+ ) : null} + + {!supported ? ( +

{t("funnelUnsupported")}

+ ) : null} + {(mappedError || error) && ( +

{mappedError || error}

+ )} + +

{t("funnelTokenNote")}

+

+ {t("funnelPrivateNodeNote")} +

+
+ ) +} diff --git a/src/components/settings/web-service-settings.tsx b/src/components/settings/web-service-settings.tsx index b31f61f7f..0af03693d 100644 --- a/src/components/settings/web-service-settings.tsx +++ b/src/components/settings/web-service-settings.tsx @@ -38,6 +38,7 @@ import { type WebServerInfo, type WebServicePortProbe, } from "@/lib/api" +import { WebServiceFunnelSection } from "@/components/settings/web-service-funnel-section" const DEFAULT_PORT = 3080 import { openUrl } from "@/lib/platform" @@ -304,6 +305,7 @@ export function WebServiceSettings() { token: null, port: null, autoStart: false, + funnelEnabled: false as boolean, } const [info, configResult] = await Promise.all([ getWebServerStatus(), @@ -379,10 +381,21 @@ export function WebServiceSettings() { } try { + // Funnel enablement is owned by WebServiceFunnelSection via its own + // API. Re-read the persisted flag so port/token autosave cannot clobber + // a concurrent Funnel toggle with a stale local value. + let nextFunnelEnabled = false + try { + const current = await getWebServiceConfig() + nextFunnelEnabled = current.funnelEnabled ?? false + } catch { + // Config unavailable; leave funnel disabled in this write. + } await updateWebServiceConfig({ port: portNum, token: token.trim() || null, autoStart: nextAutoStart, + funnelEnabled: nextFunnelEnabled, }) } catch { setError(t("saveConfigFailed")) @@ -595,6 +608,8 @@ export function WebServiceSettings() { )} )} + + diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 3506bf85c..02a80fbfe 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2790,6 +2790,25 @@ "permissionDenied": "الصلاحيات غير كافية. استخدم منفذاً أعلى من 1024 أو شغّل التطبيق بصلاحيات أعلى.", "addressUnavailable": "هذا العنوان غير متاح على هذا الجهاز", "bindFailed": "فشل ربط العنوان" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 94fc2df5c..fed4adcf7 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2790,6 +2790,25 @@ "permissionDenied": "Zugriff verweigert. Verwenden Sie einen Port über 1024 oder starten Sie mit höheren Rechten.", "addressUnavailable": "Die Adresse ist auf diesem Computer nicht verfügbar", "bindFailed": "Adresse konnte nicht gebunden werden" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 6d5d4d44c..d6a3bebd1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2790,6 +2790,25 @@ "permissionDenied": "Permission denied. Try a port above 1024 or run with higher privileges.", "addressUnavailable": "The address is not available on this machine", "bindFailed": "Failed to bind address" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index cfd14cfbb..abe8e5479 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2790,6 +2790,25 @@ "permissionDenied": "Permiso denegado. Utilice un puerto superior a 1024 o ejecute con mayores privilegios.", "addressUnavailable": "La dirección no está disponible en este equipo", "bindFailed": "No se pudo enlazar la dirección" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index f52515c28..c82690595 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2790,6 +2790,25 @@ "permissionDenied": "Permission refusée. Utilisez un port supérieur à 1024 ou exécutez avec des privilèges plus élevés.", "addressUnavailable": "Cette adresse n'est pas disponible sur cette machine", "bindFailed": "Échec de la liaison à l'adresse" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index d8aaaa371..58ad92276 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2790,6 +2790,25 @@ "permissionDenied": "権限が不足しています。1024 以上のポートを使用するか、より高い権限で実行してください", "addressUnavailable": "このアドレスはこの端末では利用できません", "bindFailed": "アドレスのバインドに失敗しました" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 5275e6918..643ac7732 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2790,6 +2790,25 @@ "permissionDenied": "권한이 부족합니다. 1024 이상의 포트를 사용하거나 더 높은 권한으로 실행하세요", "addressUnavailable": "이 주소는 현재 시스템에서 사용할 수 없습니다", "bindFailed": "주소 바인딩에 실패했습니다" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 9e9438f1a..31a2e711b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2790,6 +2790,25 @@ "permissionDenied": "Permissão negada. Use uma porta acima de 1024 ou execute com privilégios mais altos.", "addressUnavailable": "O endereço não está disponível nesta máquina", "bindFailed": "Falha ao vincular o endereço" + }, + "funnelTitle": "Public Access (Tailscale Funnel)", + "funnelDescription": "Expose this Web Service on a public HTTPS URL via Codeg's private Tailscale node.", + "funnelEnable": "Enable Funnel", + "funnelEnableHint": "Requires local Web Service running. Does not use or replace system Tailscale.", + "funnelState": "Funnel status", + "funnelLogin": "Open Tailscale login", + "funnelUrl": "Public URL", + "funnelTokenNote": "Your Codeg access token is still required after opening the public URL.", + "funnelPrivateNodeNote": "Codeg keeps Tailscale state under its own data directory and never manages system Tailscale.", + "funnelUnsupported": "Funnel sidecar is unavailable in this install", + "funnelErrors": { + "sidecarMissing": "codeg-tsnet sidecar binary not found", + "startFailed": "Failed to start Tailscale sidecar", + "loginTimeout": "Tailscale login timed out", + "funnelDenied": "Funnel is not permitted for this Tailscale account/policy", + "funnelFailed": "Failed to enable Funnel", + "authkeyRequired": "CODEG_TS_AUTHKEY is required for headless Funnel", + "unsupported": "Tailscale Funnel is not supported here" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 566eada14..97136d994 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2790,6 +2790,25 @@ "permissionDenied": "权限不足,请使用 1024 以上的端口,或以更高权限运行", "addressUnavailable": "该地址在本机不可用", "bindFailed": "绑定地址失败" + }, + "funnelTitle": "公网访问(Tailscale Funnel)", + "funnelDescription": "通过 Codeg 私有 Tailscale 节点,将本机 Web 服务暴露为公网 HTTPS 地址。", + "funnelEnable": "启用 Funnel", + "funnelEnableHint": "需要本地 Web 服务已启动。不会使用或接管系统 Tailscale。", + "funnelState": "Funnel 状态", + "funnelLogin": "打开 Tailscale 登录", + "funnelUrl": "公网地址", + "funnelTokenNote": "打开公网地址后仍需使用 Codeg 访问令牌。", + "funnelPrivateNodeNote": "Codeg 将 Tailscale 状态保存在自己的数据目录,不会管理系统 Tailscale。", + "funnelUnsupported": "当前安装缺少 Funnel sidecar", + "funnelErrors": { + "sidecarMissing": "未找到 codeg-tsnet sidecar 二进制", + "startFailed": "启动 Tailscale sidecar 失败", + "loginTimeout": "Tailscale 登录超时", + "funnelDenied": "当前 Tailscale 账号/策略不允许 Funnel", + "funnelFailed": "启用 Funnel 失败", + "authkeyRequired": "无头模式启用 Funnel 需要 CODEG_TS_AUTHKEY", + "unsupported": "此处不支持 Tailscale Funnel" } }, "DirectoryBrowser": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 3cf072b1d..7093ec63f 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2790,6 +2790,25 @@ "permissionDenied": "權限不足,請使用 1024 以上的連接埠,或以更高權限執行", "addressUnavailable": "該位址在本機不可用", "bindFailed": "綁定位址失敗" + }, + "funnelTitle": "公网访问(Tailscale Funnel)", + "funnelDescription": "通过 Codeg 私有 Tailscale 节点,将本机 Web 服务暴露为公网 HTTPS 地址。", + "funnelEnable": "启用 Funnel", + "funnelEnableHint": "需要本地 Web 服务已启动。不会使用或接管系统 Tailscale。", + "funnelState": "Funnel 状态", + "funnelLogin": "打开 Tailscale 登录", + "funnelUrl": "公网地址", + "funnelTokenNote": "打开公网地址后仍需使用 Codeg 访问令牌。", + "funnelPrivateNodeNote": "Codeg 将 Tailscale 状态保存在自己的数据目录,不会管理系统 Tailscale。", + "funnelUnsupported": "当前安装缺少 Funnel sidecar", + "funnelErrors": { + "sidecarMissing": "未找到 codeg-tsnet sidecar 二进制", + "startFailed": "启动 Tailscale sidecar 失败", + "loginTimeout": "Tailscale 登录超时", + "funnelDenied": "当前 Tailscale 账号/策略不允许 Funnel", + "funnelFailed": "启用 Funnel 失败", + "authkeyRequired": "无头模式启用 Funnel 需要 CODEG_TS_AUTHKEY", + "unsupported": "此处不支持 Tailscale Funnel" } }, "DirectoryBrowser": { diff --git a/src/lib/api.ts b/src/lib/api.ts index 15b1e99a5..ece57496f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -3059,6 +3059,7 @@ export interface WebServiceConfig { token: string | null port: number | null autoStart: boolean + funnelEnabled: boolean } export async function getWebServiceConfig(): Promise { @@ -3086,6 +3087,45 @@ export async function probeWebServicePort( }) } +export type TailscaleFunnelState = + | "stopped" + | "starting" + | "needs_login" + | "connecting" + | "online" + | "funnel_enabling" + | "funnel_ready" + | "error" + | "stopping" + +export interface TailscaleFunnelStatus { + supported: boolean + enabled: boolean + state: TailscaleFunnelState | string + loginUrl?: string + funnelUrl?: string + hostname?: string + ipv4?: string + lastError?: string + errorKey?: string +} + +export async function getTailscaleFunnelStatus(): Promise { + return getTransport().call("get_tailscale_funnel_status") +} + +export async function setTailscaleFunnelEnabled( + enabled: boolean +): Promise { + return getTransport().call("set_tailscale_funnel_enabled", { enabled }) +} + +export async function openTailscaleLogin(): Promise<{ + loginUrl?: string | null +}> { + return getTransport().call("open_tailscale_login") +} + // ─── Chat Channels ─── export async function listChatChannels(): Promise { From 893669715e399f4716fa1876eae9bf36a40ba47c Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:43:29 +0800 Subject: [PATCH 07/10] build: package codeg-tsnet sidecar across desktop, server, and Docker Ship codeg-tsnet via prepare-sidecars, Tauri externalBin, release matrix, installers, and Docker images next to codeg-mcp. --- .github/workflows/release.yml | 108 +++++++++++++------- Dockerfile | 21 ++-- Dockerfile.ci | 2 + install.ps1 | 24 ++++- install.sh | 11 +- src-tauri/scripts/prepare-sidecars.mjs | 136 +++++++++++++++++-------- src-tauri/tauri.conf.json | 2 +- 7 files changed, 217 insertions(+), 87 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fd5a48c5..68d1f99b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -300,32 +300,37 @@ jobs: - name: Install frontend dependencies run: pnpm install --frozen-lockfile - # Build the `codeg-mcp` companion binary for the matrix target and - # stage it as a Tauri sidecar at - # `src-tauri/binaries/codeg-mcp-{.exe}`. `tauri-action` (below) - # then bundles it via the `bundle.externalBin` entry in - # `tauri.conf.json` — Tauri installs it next to the main executable - # (Contents/MacOS on macOS, install root on Linux/Windows) where the - # runtime `locate_codeg_mcp_binary()` finds it via `current_exe` - # sibling lookup. The Linux arm64 cross env vars set above also - # apply to this cargo invocation. - - name: Stage codeg-mcp sidecar for Tauri bundle + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + + # Build both sidecars for the matrix target and stage them as Tauri + # externalBin companions: + # - codeg-mcp: stdio MCP companion + # - codeg-tsnet: pure-Go Tailscale Funnel control plane + # `tauri-action` (below) bundles them via `bundle.externalBin` in + # `tauri.conf.json` next to the main executable, where runtime + # sibling lookup finds them. + - name: Stage sidecars for Tauri bundle shell: bash run: pnpm tauri:prepare-sidecars --target ${{ matrix.target }} - - name: Verify codeg-mcp sidecar landed + - name: Verify sidecars landed shell: bash run: | ext="" case "${{ matrix.target }}" in *windows*) ext=".exe" ;; esac - file="src-tauri/binaries/codeg-mcp-${{ matrix.target }}${ext}" - if [ ! -f "$file" ]; then - echo "FATAL: sidecar $file missing after prepare-sidecars" - exit 1 - fi - ls -la "$file" + for name in codeg-mcp codeg-tsnet; do + file="src-tauri/binaries/${name}-${{ matrix.target }}${ext}" + if [ ! -f "$file" ]; then + echo "FATAL: sidecar $file missing after prepare-sidecars" + exit 1 + fi + ls -la "$file" + done - name: Import Apple Developer ID certificate if: contains(matrix.target, 'apple-darwin') @@ -662,16 +667,39 @@ jobs: pnpm install --frozen-lockfile pnpm build - - name: Build server + codeg-mcp companion - working-directory: src-tauri + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.22.x" + + - name: Build server + sidecars + shell: bash # `codeg-mcp` is the stdio MCP companion the runtime injects per - # session (see acp/delegation/companion.rs). Built with the same - # `--no-default-features --target` flags as the server so it shares - # the cross-compile env (Linux arm64) without dragging in tauri - # runtime deps. + # session (see acp/delegation/companion.rs). `codeg-tsnet` is the + # pure-Go Tailscale Funnel control plane. Both ship next to + # codeg-server so sibling lookup works. run: | - cargo build --release --bin codeg-server --no-default-features --target ${{ matrix.target }} - cargo build --release --bin codeg-mcp --no-default-features --target ${{ matrix.target }} + set -euo pipefail + cargo build --release --bin codeg-server --no-default-features --target ${{ matrix.target }} --manifest-path src-tauri/Cargo.toml + cargo build --release --bin codeg-mcp --no-default-features --target ${{ matrix.target }} --manifest-path src-tauri/Cargo.toml + # Map Rust triple -> Go env for cross builds. + case "${{ matrix.target }}" in + x86_64-apple-darwin) export GOOS=darwin GOARCH=amd64 ;; + aarch64-apple-darwin) export GOOS=darwin GOARCH=arm64 ;; + x86_64-unknown-linux-gnu) export GOOS=linux GOARCH=amd64 ;; + aarch64-unknown-linux-gnu) export GOOS=linux GOARCH=arm64 ;; + x86_64-pc-windows-msvc) export GOOS=windows GOARCH=amd64 ;; + aarch64-pc-windows-msvc) export GOOS=windows GOARCH=arm64 ;; + *) echo "unsupported target for codeg-tsnet: ${{ matrix.target }}"; exit 1 ;; + esac + export CGO_ENABLED=0 + if [ "$GOOS" = "windows" ]; then + out="src-tauri/target/${{ matrix.target }}/release/codeg-tsnet.exe" + else + out="src-tauri/target/${{ matrix.target }}/release/codeg-tsnet" + fi + mkdir -p "$(dirname "$out")" + (cd codeg-tsnet && go build -o "../$out" .) - name: Package (Unix) if: runner.os != 'Windows' @@ -679,7 +707,8 @@ jobs: mkdir -p dist/${{ matrix.artifact }} cp src-tauri/target/${{ matrix.target }}/release/codeg-server dist/${{ matrix.artifact }}/ cp src-tauri/target/${{ matrix.target }}/release/codeg-mcp dist/${{ matrix.artifact }}/ - chmod +x dist/${{ matrix.artifact }}/codeg-server dist/${{ matrix.artifact }}/codeg-mcp + cp src-tauri/target/${{ matrix.target }}/release/codeg-tsnet dist/${{ matrix.artifact }}/ + chmod +x dist/${{ matrix.artifact }}/codeg-server dist/${{ matrix.artifact }}/codeg-mcp dist/${{ matrix.artifact }}/codeg-tsnet cp -r out dist/${{ matrix.artifact }}/web cd dist && tar czf ${{ matrix.artifact }}.tar.gz ${{ matrix.artifact }} @@ -690,6 +719,7 @@ jobs: New-Item -ItemType Directory -Force -Path dist/${{ matrix.artifact }} Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-server.exe dist/${{ matrix.artifact }}/ Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-mcp.exe dist/${{ matrix.artifact }}/ + Copy-Item src-tauri/target/${{ matrix.target }}/release/codeg-tsnet.exe dist/${{ matrix.artifact }}/ Copy-Item -Recurse out dist/${{ matrix.artifact }}/web Compress-Archive -Path dist/${{ matrix.artifact }} -DestinationPath dist/${{ matrix.artifact }}.zip @@ -699,15 +729,16 @@ jobs: set -euo pipefail test -x "dist/${{ matrix.artifact }}/codeg-server" test -x "dist/${{ matrix.artifact }}/codeg-mcp" - # `codeg-mcp --help` exits 0 without flags and prints the usage - # line. Only run it for native targets; cross-compiled arm64 - # binaries on x64 runners can't execute. + test -x "dist/${{ matrix.artifact }}/codeg-tsnet" + # Only execute native binaries; cross-compiled arm64 on x64 + # runners cannot run. if [ "${{ matrix.target }}" = "x86_64-unknown-linux-gnu" ] || \ [ "${{ matrix.target }}" = "x86_64-apple-darwin" ] || \ [ "${{ matrix.target }}" = "aarch64-apple-darwin" ]; then "dist/${{ matrix.artifact }}/codeg-mcp" --help | grep -F 'codeg-mcp --parent-connection-id' + "dist/${{ matrix.artifact }}/codeg-tsnet" --help | grep -Ei 'control-addr|hostname|state-dir' else - echo "skipping codeg-mcp --help on cross-target ${{ matrix.target }}" + echo "skipping sidecar --help on cross-target ${{ matrix.target }}" fi - name: Smoke test packaged artifact (Windows) @@ -720,10 +751,17 @@ jobs: if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-mcp.exe")) { throw "codeg-mcp.exe missing from packaged artifact" } + if (-not (Test-Path "dist/${{ matrix.artifact }}/codeg-tsnet.exe")) { + throw "codeg-tsnet.exe missing from packaged artifact" + } $help = & "dist/${{ matrix.artifact }}/codeg-mcp.exe" --help if ($help -notlike "*codeg-mcp --parent-connection-id*") { throw "codeg-mcp --help output unexpected: $help" } + $tsHelp = & "dist/${{ matrix.artifact }}/codeg-tsnet.exe" --help + if ($tsHelp -notmatch "control-addr|hostname|state-dir") { + throw "codeg-tsnet --help output unexpected: $tsHelp" + } - name: Upload artifact for Docker build (Linux only) if: startsWith(matrix.target, 'x86_64-unknown-linux') || startsWith(matrix.target, 'aarch64-unknown-linux') @@ -809,14 +847,16 @@ jobs: mkdir -p dist/amd64 dist/arm64 cp artifacts/linux-x64/codeg-server dist/amd64/codeg-server cp artifacts/linux-x64/codeg-mcp dist/amd64/codeg-mcp + cp artifacts/linux-x64/codeg-tsnet dist/amd64/codeg-tsnet cp artifacts/linux-arm64/codeg-server dist/arm64/codeg-server cp artifacts/linux-arm64/codeg-mcp dist/arm64/codeg-mcp + cp artifacts/linux-arm64/codeg-tsnet dist/arm64/codeg-tsnet cp -r artifacts/linux-x64/web dist/web - chmod +x dist/amd64/codeg-server dist/amd64/codeg-mcp \ - dist/arm64/codeg-server dist/arm64/codeg-mcp + chmod +x dist/amd64/codeg-server dist/amd64/codeg-mcp dist/amd64/codeg-tsnet \ + dist/arm64/codeg-server dist/arm64/codeg-mcp dist/arm64/codeg-tsnet # Fail fast if any companion went missing — Docker would otherwise - # produce an image where delegation silently degrades. - for f in dist/amd64/codeg-mcp dist/arm64/codeg-mcp; do + # produce an image where delegation / Funnel silently degrades. + for f in dist/amd64/codeg-mcp dist/arm64/codeg-mcp dist/amd64/codeg-tsnet dist/arm64/codeg-tsnet; do test -x "$f" || { echo "FATAL: $f missing or non-exec"; exit 1; } done diff --git a/Dockerfile b/Dockerfile index 3a53a2036..275680ba9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,16 +9,24 @@ COPY public/ ./public/ COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ RUN pnpm build -# Stage 2: Build Rust server binary + codeg-mcp companion +# Stage 2: Build Rust server binary + sidecars FROM rust:slim-bookworm AS backend -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y pkg-config libssl-dev curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# Install Go for the pure-Go Tailscale Funnel sidecar (codeg-tsnet). +RUN curl -fsSL https://go.dev/dl/go1.22.12.linux-amd64.tar.gz | tar -C /usr/local -xz +ENV PATH="/usr/local/go/bin:${PATH}" +WORKDIR /app +COPY src-tauri/ ./src-tauri/ +COPY codeg-tsnet/ ./codeg-tsnet/ WORKDIR /app/src-tauri -COPY src-tauri/ ./ # codeg-mcp is the stdio MCP companion the runtime injects per session -# (see acp/delegation/companion.rs). It must ship next to codeg-server so -# `locate_codeg_mcp_binary()` finds it via the exe-sibling lookup. +# (see acp/delegation/companion.rs). codeg-tsnet is the private Tailscale +# Funnel control plane. Both must ship next to codeg-server so sibling +# lookup works. RUN cargo build --release --bin codeg-server --no-default-features \ - && cargo build --release --bin codeg-mcp --no-default-features + && cargo build --release --bin codeg-mcp --no-default-features \ + && cd /app/codeg-tsnet && CGO_ENABLED=0 go build -o /app/src-tauri/target/release/codeg-tsnet . # Stage 3: Runtime FROM node:24-bookworm-slim @@ -42,6 +50,7 @@ RUN apt-get update && apt-get install -y \ COPY --from=backend /app/src-tauri/target/release/codeg-server /usr/local/bin/codeg-server COPY --from=backend /app/src-tauri/target/release/codeg-mcp /usr/local/bin/codeg-mcp +COPY --from=backend /app/src-tauri/target/release/codeg-tsnet /usr/local/bin/codeg-tsnet COPY --from=frontend /app/out /app/web ENV CODEG_STATIC_DIR=/app/web diff --git a/Dockerfile.ci b/Dockerfile.ci index ee6bca35e..fec76e2e8 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -27,6 +27,8 @@ COPY dist/${TARGETARCH}/codeg-server /usr/local/bin/codeg-server # (see acp/delegation/companion.rs). It must ship next to codeg-server so # `locate_codeg_mcp_binary()` finds it via the exe-sibling lookup. COPY dist/${TARGETARCH}/codeg-mcp /usr/local/bin/codeg-mcp +# codeg-tsnet is the private Tailscale Funnel control plane. +COPY dist/${TARGETARCH}/codeg-tsnet /usr/local/bin/codeg-tsnet COPY dist/web /app/web ENV CODEG_STATIC_DIR=/app/web diff --git a/install.ps1 b/install.ps1 index ef279135f..d6017c5e0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -20,9 +20,9 @@ $Artifact = "codeg-server-windows-x64" # layer spawns per session for delegation. Both must live in the same # directory — `locate_codeg_mcp_binary()` in src-tauri/src/acp/connection.rs # resolves the companion as a sibling of the running server executable. -$ManagedBins = @("codeg-server", "codeg-mcp") +$ManagedBins = @("codeg-server", "codeg-mcp", "codeg-tsnet") -# Stale codeg-server / codeg-mcp binaries elsewhere in PATH are removed by +# Stale codeg-server / codeg-mcp / codeg-tsnet binaries elsewhere in PATH are removed by # default so the user's `codeg-server` command always runs the freshly # installed binary AND the runtime locates the matching companion via the # exe-sibling lookup. Pass -NoCleanup (or set CODEG_NO_CLEANUP=1) to disable. @@ -211,6 +211,18 @@ if ($McpProcesses) { } } +$TsnetProcesses = Get-Process -Name "codeg-tsnet" -ErrorAction SilentlyContinue +if ($TsnetProcesses) { + Write-Host "Stopping running codeg-tsnet Funnel process(es)..." + $TsnetProcesses | Stop-Process -Force + Start-Sleep -Seconds 1 + $StillRunning = Get-Process -Name "codeg-tsnet" -ErrorAction SilentlyContinue + if ($StillRunning) { + $StillRunning | Stop-Process -Force + Start-Sleep -Seconds 1 + } +} + # ── Download and extract ── $Url = "https://github.com/$Repo/releases/download/$Version/$Artifact.zip" @@ -321,6 +333,7 @@ if (-not $InstalledVer) { $InstalledVer = $TargetVer } Write-Host "" Write-Host "codeg-server installed to $InstallDir\codeg-server.exe" Write-Host "codeg-mcp installed to $InstallDir\codeg-mcp.exe" +Write-Host "codeg-tsnet installed to $InstallDir\codeg-tsnet.exe" Write-Host "Version: $InstalledVer" # Final smoke: codeg-mcp.exe must exist next to codeg-server.exe so the @@ -334,6 +347,13 @@ if (-not (Test-Path -LiteralPath $McpPath)) { Write-Host " Delegation (sub-agent tooling) will not work. Re-run the installer." $ExitStatus = 1 } +$TsnetPath = Join-Path $InstallDir "codeg-tsnet.exe" +if (-not (Test-Path -LiteralPath $TsnetPath)) { + Write-Host "" + Write-Host "Error: $TsnetPath missing after install." + Write-Host " Tailscale Funnel public access will not work. Re-run the installer." + $ExitStatus = 1 +} # Verify the user's `codeg-server` command actually resolves to the new binary. $ActiveBinAfter = "" diff --git a/install.sh b/install.sh index a225fcc48..6ae294e68 100755 --- a/install.sh +++ b/install.sh @@ -12,7 +12,7 @@ REPO="xintaofei/codeg" INSTALL_DIR="${CODEG_INSTALL_DIR:-/usr/local/bin}" WEB_DIR="${CODEG_WEB_DIR:-/usr/local/share/codeg/web}" VERSION="" -# Stale codeg-server / codeg-mcp binaries elsewhere in PATH are removed by +# Stale codeg-server / codeg-mcp / codeg-tsnet binaries elsewhere in PATH are removed by # default so the user's `codeg-server` command always runs the freshly # installed binary AND the runtime locates the matching companion via the # exe-sibling lookup. Set CODEG_NO_CLEANUP=1 (or pass --no-cleanup) to @@ -27,7 +27,7 @@ fi # layer spawns per session for delegation. Both must live in the same # directory — `locate_codeg_mcp_binary()` in src-tauri/src/acp/connection.rs # resolves the companion as a sibling of the running server executable. -MANAGED_BINS=(codeg-server codeg-mcp) +MANAGED_BINS=(codeg-server codeg-mcp codeg-tsnet) # ── Parse arguments ── @@ -453,6 +453,7 @@ fi echo "" echo "codeg-server installed to ${INSTALL_DIR}/codeg-server" echo "codeg-mcp installed to ${INSTALL_DIR}/codeg-mcp" +echo "codeg-tsnet installed to ${INSTALL_DIR}/codeg-tsnet" INSTALLED_VER=$("${INSTALL_DIR}/codeg-server" --version 2>/dev/null || echo "${TARGET_VER}") echo "Version: ${INSTALLED_VER}" @@ -466,6 +467,12 @@ if [ ! -x "${INSTALL_DIR}/codeg-mcp" ]; then echo " Delegation (sub-agent tooling) will not work. Re-run the installer." EXIT_STATUS=1 fi +if [ ! -x "${INSTALL_DIR}/codeg-tsnet" ]; then + echo "" + echo "Error: ${INSTALL_DIR}/codeg-tsnet missing or not executable after install." + echo " Tailscale Funnel public access will not work. Re-run the installer." + EXIT_STATUS=1 +fi # Verify the user's `codeg-server` command actually resolves to the new binary. ACTIVE_BIN_AFTER="" diff --git a/src-tauri/scripts/prepare-sidecars.mjs b/src-tauri/scripts/prepare-sidecars.mjs index cf255749d..a49d191b8 100644 --- a/src-tauri/scripts/prepare-sidecars.mjs +++ b/src-tauri/scripts/prepare-sidecars.mjs @@ -5,11 +5,12 @@ // What it does: // 1. Resolves the target triple — `--target ` arg, or // `TAURI_TARGET_TRIPLE` env, or the host's `rustc -vV` host triple. -// 2. Runs `cargo build --release --bin codeg-mcp --no-default-features` -// for that triple from `src-tauri/`. -// 3. Copies the produced binary to -// `src-tauri/binaries/codeg-mcp-{.exe}` so Tauri's externalBin -// bundler picks it up under the bare name `codeg-mcp` at install time. +// 2. Builds both sidecars for that triple: +// - `codeg-mcp` via `cargo build --release --bin codeg-mcp --no-default-features` +// - `codeg-tsnet` via `go build` (pure Go tsnet Funnel control plane) +// 3. Copies each produced binary to +// `src-tauri/binaries/-{.exe}` so Tauri's externalBin +// bundler picks them up under the bare names at install time. // // Why a separate script (not inline in beforeBuildCommand / GitHub Actions): // - Cross-compile in release.yml passes `--target ` so we honour @@ -17,7 +18,7 @@ // - Local `pnpm tauri dev` / `pnpm tauri build` invoke it without args and // get a host-triple build, so the externalBin lookup still finds a file. // - Skippable: set `CODEG_SKIP_SIDECAR=1` when iterating on the frontend -// and you don't care about delegation. +// and you don't care about delegation / Funnel. // // Intentionally Node-only (no shell): runs identically on macOS, Linux, // Windows GitHub runners. @@ -30,8 +31,20 @@ import process from "node:process" const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const SRC_TAURI = resolve(SCRIPT_DIR, "..") +const REPO_ROOT = resolve(SRC_TAURI, "..") const BINARIES_DIR = join(SRC_TAURI, "binaries") -const BIN_NAME = "codeg-mcp" +const TSNET_DIR = join(REPO_ROOT, "codeg-tsnet") + +const SIDECARS = [ + { + name: "codeg-mcp", + kind: "cargo", + }, + { + name: "codeg-tsnet", + kind: "go", + }, +] function log(msg) { console.log(`[prepare-sidecars] ${msg}`) @@ -66,60 +79,99 @@ function resolveHostTriple() { } } -function main() { - if (process.env.CODEG_SKIP_SIDECAR === "1") { - log("CODEG_SKIP_SIDECAR=1 — skipping sidecar preparation") - return +function tripleToGoEnv(target) { + const map = { + "x86_64-apple-darwin": { GOOS: "darwin", GOARCH: "amd64" }, + "aarch64-apple-darwin": { GOOS: "darwin", GOARCH: "arm64" }, + "x86_64-unknown-linux-gnu": { GOOS: "linux", GOARCH: "amd64" }, + "aarch64-unknown-linux-gnu": { GOOS: "linux", GOARCH: "arm64" }, + "x86_64-pc-windows-msvc": { GOOS: "windows", GOARCH: "amd64" }, + "aarch64-pc-windows-msvc": { GOOS: "windows", GOARCH: "arm64" }, } + const env = map[target] + if (!env) { + die(`unsupported target triple for codeg-tsnet Go build: ${target}`) + } + return env +} - const { target: cliTarget } = parseArgs(process.argv.slice(2)) - const target = - cliTarget || process.env.TAURI_TARGET_TRIPLE || resolveHostTriple() - const isWindows = target.includes("windows") - const ext = isWindows ? ".exe" : "" - - log(`target triple: ${target}`) - log(`building ${BIN_NAME} (--release --no-default-features)`) +function stageBinary(built, dest, isWindows) { + if (!existsSync(built)) { + die(`expected ${built} after build, but it does not exist`) + } + mkdirSync(BINARIES_DIR, { recursive: true }) + copyFileSync(built, dest) + if (!isWindows) { + chmodSync(dest, 0o755) + } + log(`sidecar staged at ${dest}`) +} - // cargo build needs to run from src-tauri so it resolves the local manifest - // and shares the swatinem/rust-cache key with other cargo invocations. - // `--no-default-features` keeps codeg-mcp free of the Tauri runtime deps — - // the bin's required-features is empty, so this just enables cross-compile - // without dragging in macOS-private-api / Linux WebKit / Windows WebView2. +function buildCargoSidecar(name, target, isWindows) { + const ext = isWindows ? ".exe" : "" + log(`building ${name} (--release --no-default-features)`) execFileSync( "cargo", [ "build", "--release", "--bin", - BIN_NAME, + name, "--no-default-features", "--target", target, ], { stdio: "inherit", cwd: SRC_TAURI } ) + const built = join(SRC_TAURI, "target", target, "release", `${name}${ext}`) + const dest = join(BINARIES_DIR, `${name}-${target}${ext}`) + stageBinary(built, dest, isWindows) +} - const built = join( - SRC_TAURI, - "target", - target, - "release", - `${BIN_NAME}${ext}` - ) - if (!existsSync(built)) { - die(`expected ${built} after cargo build, but it does not exist`) +function buildGoSidecar(name, target, isWindows) { + const ext = isWindows ? ".exe" : "" + if (!existsSync(TSNET_DIR)) { + die(`missing ${TSNET_DIR}; cannot build ${name}`) } + const goEnv = tripleToGoEnv(target) + const outPath = join(TSNET_DIR, `${name}${ext}`) + log(`building ${name} (go build GOOS=${goEnv.GOOS} GOARCH=${goEnv.GOARCH})`) + execFileSync("go", ["build", "-o", outPath, "."], { + stdio: "inherit", + cwd: TSNET_DIR, + env: { + ...process.env, + GOOS: goEnv.GOOS, + GOARCH: goEnv.GOARCH, + CGO_ENABLED: "0", + }, + }) + const dest = join(BINARIES_DIR, `${name}-${target}${ext}`) + stageBinary(outPath, dest, isWindows) +} - mkdirSync(BINARIES_DIR, { recursive: true }) - const dest = join(BINARIES_DIR, `${BIN_NAME}-${target}${ext}`) - copyFileSync(built, dest) - if (!isWindows) { - // copyFileSync preserves modes on POSIX, but be explicit for tarball - // sources that may strip the +x bit. - chmodSync(dest, 0o755) +function main() { + if (process.env.CODEG_SKIP_SIDECAR === "1") { + log("CODEG_SKIP_SIDECAR=1 — skipping sidecar preparation") + return + } + + const { target: cliTarget } = parseArgs(process.argv.slice(2)) + const target = + cliTarget || process.env.TAURI_TARGET_TRIPLE || resolveHostTriple() + const isWindows = target.includes("windows") + + log(`target triple: ${target}`) + + for (const sidecar of SIDECARS) { + if (sidecar.kind === "cargo") { + buildCargoSidecar(sidecar.name, target, isWindows) + } else if (sidecar.kind === "go") { + buildGoSidecar(sidecar.name, target, isWindows) + } else { + die(`unknown sidecar kind for ${sidecar.name}`) + } } - log(`sidecar staged at ${dest}`) } main() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4f2c94145..aa51ad5ed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,7 +30,7 @@ "resources": { "../out": "web/" }, - "externalBin": ["binaries/codeg-mcp"], + "externalBin": ["binaries/codeg-mcp", "binaries/codeg-tsnet"], "windows": { "nsis": { "installerHooks": "./windows/installer-hooks.nsh" From 91279f1d08e1732506d35f46e395dc426fd22605 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:43:30 +0800 Subject: [PATCH 08/10] docs: document optional Tailscale Funnel sidecar Document CODEG_TS_FUNNEL, auth key, state dir, hostname, and private userspace node behavior in EN/ZH README. --- README.md | 7 ++++++- docs/readme/README.zh-CN.md | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7e2f825c8..07f9dea98 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,12 @@ Environment variables: | `CODEG_DATA_DIR` | `~/.local/share/codeg` | SQLite database directory (also roots `uploads/`, `pets/`) | | `CODEG_STATIC_DIR` | `./web` or `./out` | Next.js static export directory | | `CODEG_MCP_BIN` | _(unset)_ | Absolute path to the `codeg-mcp` companion. Overrides the default sibling-of-executable + `PATH` lookup. Use this for source builds or custom layouts where the companion lives outside the server's install directory. | -| `CODEG_SKIP_SIDECAR` | _(unset)_ | Frontend-only convenience for `pnpm tauri dev` / `pnpm tauri build` — when `1`, skips building the `codeg-mcp` sidecar. Delegation is disabled in that build; ship-quality artifacts must leave it unset. | +| `CODEG_TSNET_BIN` | _(unset)_ | Absolute path to the `codeg-tsnet` Funnel sidecar. Overrides sibling-of-executable + `PATH` lookup. | +| `CODEG_TS_FUNNEL` | _(unset)_ | When `1` / `true` / `yes`, enable Tailscale Funnel on `codeg-server` after bind. Failures never take down local web. Headless mode requires `CODEG_TS_AUTHKEY`. | +| `CODEG_TS_AUTHKEY` | _(unset)_ | Tailscale auth key for headless Funnel login (required for server/Docker when Funnel is enabled). Never logged. | +| `CODEG_TS_STATE_DIR` | `/tailscale` | Independent userspace Tailscale state directory. Codeg never manages system Tailscale. | +| `CODEG_TS_HOSTNAME` | `codeg-<8hex>` | Hostname for Codeg's private Tailscale node (stable hash of data dir by default). | +| `CODEG_SKIP_SIDECAR` | _(unset)_ | Frontend-only convenience for `pnpm tauri dev` / `pnpm tauri build` — when `1`, skips building both `codeg-mcp` and `codeg-tsnet` sidecars. Delegation/Funnel are disabled in that build; ship-quality artifacts must leave it unset. | | `CODEG_UPLOAD_MAX_TOTAL_BYTES` | _(unset)_ | Hard cap on total bytes resident under `/uploads/`. Plain decimal byte count (e.g. `10737418240` for 10 GiB). Unset, `0`, or an unparseable value disables the cap and prints a startup line so the posture is visible. The cap is enforced within a single `codeg-server` process — horizontally-scaled deployments sharing one `uploads/` volume need external coordination (file lock, Redis, reverse-proxy quota). | | `CODEG_UPLOAD_QUOTA_STRICT` | _(unset)_ | When truthy (`1` / `true` / `yes` / `on`), abort startup with exit code 2 if `CODEG_UPLOAD_MAX_TOTAL_BYTES` is set to an unparseable value, instead of fail-open with a WARN. Use this when your security policy requires "configured quota must be effective". | diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 9e8629aab..c823ab492 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -377,7 +377,12 @@ CODEG_STATIC_DIR=./web ./codeg-server --supervise | `CODEG_DATA_DIR` | `~/.local/share/codeg` | SQLite 数据库目录(同时也是 `uploads/`、`pets/` 的根目录) | | `CODEG_STATIC_DIR` | `./web` 或 `./out` | Next.js 静态导出目录 | | `CODEG_MCP_BIN` | _(未设置)_ | `codeg-mcp` 协作进程的绝对路径。会覆盖默认的"可执行文件同级目录 + `PATH`"查找逻辑。用于源码构建或协作进程不在服务端安装目录内的自定义部署。 | -| `CODEG_SKIP_SIDECAR` | _(未设置)_ | 仅供 `pnpm tauri dev` / `pnpm tauri build` 调试前端时使用——当值为 `1` 时,跳过 `codeg-mcp` sidecar 的构建。此类构建不支持委托功能;发布质量的产物必须保持此变量未设置。 | +| `CODEG_TSNET_BIN` | _(未设置)_ | `codeg-tsnet` Funnel sidecar 的绝对路径。会覆盖默认同级目录 + `PATH` 查找。 | +| `CODEG_TS_FUNNEL` | _(未设置)_ | 设为 `1` / `true` / `yes` 时,`codeg-server` 绑定成功后启用 Tailscale Funnel。失败不会影响本地 Web。无头模式需要 `CODEG_TS_AUTHKEY`。 | +| `CODEG_TS_AUTHKEY` | _(未设置)_ | 无头 Funnel 登录用的 Tailscale auth key(服务端/Docker 启用 Funnel 时必需)。不会被日志打印。 | +| `CODEG_TS_STATE_DIR` | `/tailscale` | 独立的 userspace Tailscale 状态目录。Codeg 不会管理系统 Tailscale。 | +| `CODEG_TS_HOSTNAME` | `codeg-<8hex>` | Codeg 私有 Tailscale 节点主机名(默认由 data dir 稳定哈希生成)。 | +| `CODEG_SKIP_SIDECAR` | _(未设置)_ | 仅供 `pnpm tauri dev` / `pnpm tauri build` 调试前端时使用——当值为 `1` 时,跳过 `codeg-mcp` 与 `codeg-tsnet` sidecar 的构建。此类构建不支持委托/Funnel;发布质量的产物必须保持此变量未设置。 | | `CODEG_UPLOAD_MAX_TOTAL_BYTES` | _(未设置)_ | `/uploads/` 下所有文件总字节数的硬上限。十进制字节数(例如 `10737418240` 表示 10 GiB)。未设置、`0` 或无法解析的值会禁用上限,并在启动时打印一行日志以便观察当前状态。该上限仅在单个 `codeg-server` 进程内生效——共享一个 `uploads/` 卷的横向扩展部署需要外部协调(文件锁、Redis、反向代理配额)。 | | `CODEG_UPLOAD_QUOTA_STRICT` | _(未设置)_ | 当值为真(`1` / `true` / `yes` / `on`)时,若 `CODEG_UPLOAD_MAX_TOTAL_BYTES` 设置为无法解析的值,则以退出码 2 中止启动,而不是发出 WARN 后继续运行。当安全策略要求"配置的配额必须生效"时使用此选项。 | From 0b24c229996ac4821f05a486f663ce8509a56404 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 14:57:07 +0800 Subject: [PATCH 09/10] fix: unblock CI for codeg-tsnet externalBin and Funnel prettier Create 0-byte placeholders for both Tauri externalBin sidecars in build.rs, and fix prettier formatting in the Funnel settings component/test. --- src-tauri/build.rs | 60 ++++++++++--------- .../web-service-funnel-section.test.tsx | 5 +- .../settings/web-service-funnel-section.tsx | 3 +- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09e74bc16..51d964e6d 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,31 +1,31 @@ fn main() { #[cfg(feature = "tauri-runtime")] { - ensure_sidecar_placeholder(); + ensure_sidecar_placeholders(); tauri_build::build(); } } /// Tauri's bundler validates that every `bundle.externalBin` path resolves -/// to an existing file at build.rs time. The real `codeg-mcp` sidecar is -/// produced by `pnpm tauri:prepare-sidecars` (invoked from +/// to an existing file at build.rs time. Real sidecars (`codeg-mcp`, +/// `codeg-tsnet`) are produced by `pnpm tauri:prepare-sidecars` (invoked from /// `beforeBuildCommand` / `beforeDevCommand` and the CI release matrix) — /// but plain `cargo check --features tauri-runtime` doesn't go through that /// path, so without a backstop every contributor would hit /// `resource path ... doesn't exist` on first compile. /// -/// We write a zero-byte placeholder when the sidecar is missing so +/// We write a zero-byte placeholder when a sidecar is missing so /// `cargo check` / clippy / rust-analyzer succeed. Production paths /// overwrite the placeholder with the real binary before Tauri bundles it: /// * `pnpm tauri build` → `beforeBuildCommand` → `prepare-sidecars.mjs` -/// * release.yml → explicit "Stage codeg-mcp sidecar" step +/// * release.yml → explicit "Stage sidecars for Tauri bundle" step /// * `pnpm tauri dev` → `beforeDevCommand` → `prepare-sidecars.mjs` /// /// If you ever bypass those wrappers (e.g. invoking the Tauri CLI directly /// without beforeBuildCommand) you'd ship the placeholder, so emit a /// cargo:warning that surfaces in any compile log to make that loud. #[cfg(feature = "tauri-runtime")] -fn ensure_sidecar_placeholder() { +fn ensure_sidecar_placeholders() { use std::fs; use std::path::PathBuf; @@ -39,34 +39,36 @@ fn ensure_sidecar_placeholder() { "" }; let dir = PathBuf::from("binaries"); - let path = dir.join(format!("codeg-mcp-{triple}{ext}")); - println!("cargo:rerun-if-changed={}", path.display()); + for name in ["codeg-mcp", "codeg-tsnet"] { + let path = dir.join(format!("{name}-{triple}{ext}")); + println!("cargo:rerun-if-changed={}", path.display()); - let needs_placeholder = match fs::metadata(&path) { - Ok(meta) => meta.len() == 0, - Err(_) => true, - }; + let needs_placeholder = match fs::metadata(&path) { + Ok(meta) => meta.len() == 0, + Err(_) => true, + }; - if needs_placeholder { - if let Err(e) = fs::create_dir_all(&dir) { - panic!("failed to create {}: {e}", dir.display()); - } - if let Err(e) = fs::write(&path, b"") { - panic!( - "failed to write sidecar placeholder {}: {e}", + if needs_placeholder { + if let Err(e) = fs::create_dir_all(&dir) { + panic!("failed to create {}: {e}", dir.display()); + } + if let Err(e) = fs::write(&path, b"") { + panic!( + "failed to write sidecar placeholder {}: {e}", + path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); + } + println!( + "cargo:warning={name} sidecar missing at {}; wrote 0-byte placeholder. \ + Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", path.display() ); } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o755)); - } - println!( - "cargo:warning=codeg-mcp sidecar missing at {}; wrote 0-byte placeholder. \ - Run `pnpm tauri:prepare-sidecars` before `tauri build` to ship a working binary.", - path.display() - ); } } diff --git a/src/components/settings/web-service-funnel-section.test.tsx b/src/components/settings/web-service-funnel-section.test.tsx index 3dc785972..52ca47703 100644 --- a/src/components/settings/web-service-funnel-section.test.tsx +++ b/src/components/settings/web-service-funnel-section.test.tsx @@ -30,10 +30,7 @@ vi.mock("@/hooks/use-copied-flag", () => ({ import { WebServiceFunnelSection } from "./web-service-funnel-section" import enMessages from "@/i18n/messages/en.json" -import { - getTailscaleFunnelStatus, - type TailscaleFunnelStatus, -} from "@/lib/api" +import { getTailscaleFunnelStatus, type TailscaleFunnelStatus } from "@/lib/api" const mockGet = vi.mocked(getTailscaleFunnelStatus) diff --git a/src/components/settings/web-service-funnel-section.tsx b/src/components/settings/web-service-funnel-section.tsx index 3682ff672..fdf1f6177 100644 --- a/src/components/settings/web-service-funnel-section.tsx +++ b/src/components/settings/web-service-funnel-section.tsx @@ -31,7 +31,8 @@ function errorMessage( ): string | null { if (!status) return null const key = status.errorKey - if (key === "tailscale.sidecar_missing") return t("funnelErrors.sidecarMissing") + if (key === "tailscale.sidecar_missing") + return t("funnelErrors.sidecarMissing") if (key === "tailscale.start_failed") return t("funnelErrors.startFailed") if (key === "tailscale.login_timeout") return t("funnelErrors.loginTimeout") if (key === "tailscale.funnel_denied") return t("funnelErrors.funnelDenied") From 6755b69ee6715e91934b82bd6f2dd4805862d778 Mon Sep 17 00:00:00 2001 From: jry Date: Tue, 14 Jul 2026 15:07:22 +0800 Subject: [PATCH 10/10] fix(ui): add WebServiceSettings.copyFailed i18n key Static export typecheck failed because Funnel copy toast referenced a missing copyFailed message key. --- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 10 files changed, 10 insertions(+) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 02a80fbfe..dc7efa5ab 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2772,6 +2772,7 @@ "hide": "إخفاء", "show": "إظهار", "copy": "نسخ", + "copyFailed": "فشل النسخ", "qrcode": "رمز QR", "qrcodeTitle": "امسح للفتح", "qrcodeHint": "امسح بهاتفك لفتح Codeg في المتصفح", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index fed4adcf7..79c393324 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2772,6 +2772,7 @@ "hide": "Ausblenden", "show": "Einblenden", "copy": "Kopieren", + "copyFailed": "Kopieren fehlgeschlagen", "qrcode": "QR-Code", "qrcodeTitle": "Zum Öffnen scannen", "qrcodeHint": "Scanne mit deinem Handy, um Codeg im Browser zu öffnen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d6a3bebd1..997111da3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2772,6 +2772,7 @@ "hide": "Hide", "show": "Show", "copy": "Copy", + "copyFailed": "Failed to copy", "qrcode": "QR Code", "qrcodeTitle": "Scan to Open", "qrcodeHint": "Scan with your phone to open Codeg in a browser", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index abe8e5479..cbf8e87f9 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2772,6 +2772,7 @@ "hide": "Ocultar", "show": "Mostrar", "copy": "Copiar", + "copyFailed": "Error al copiar", "qrcode": "Código QR", "qrcodeTitle": "Escanear para abrir", "qrcodeHint": "Escanea con tu teléfono para abrir Codeg en el navegador", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c82690595..014291855 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2772,6 +2772,7 @@ "hide": "Masquer", "show": "Afficher", "copy": "Copier", + "copyFailed": "Échec de la copie", "qrcode": "Code QR", "qrcodeTitle": "Scanner pour ouvrir", "qrcodeHint": "Scannez avec votre téléphone pour ouvrir Codeg dans un navigateur", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 58ad92276..1212b30d6 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2772,6 +2772,7 @@ "hide": "非表示", "show": "表示", "copy": "コピー", + "copyFailed": "コピーに失敗しました", "qrcode": "QRコード", "qrcodeTitle": "スキャンして開く", "qrcodeHint": "スマートフォンでスキャンして、ブラウザで Codeg を開きます", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 643ac7732..e11b3462c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2772,6 +2772,7 @@ "hide": "숨기기", "show": "표시", "copy": "복사", + "copyFailed": "복사에 실패했습니다", "qrcode": "QR 코드", "qrcodeTitle": "스캔하여 열기", "qrcodeHint": "휴대폰으로 스캔하여 브라우저에서 Codeg를 엽니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 31a2e711b..5dc50758d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2772,6 +2772,7 @@ "hide": "Ocultar", "show": "Mostrar", "copy": "Copiar", + "copyFailed": "Falha ao copiar", "qrcode": "Código QR", "qrcodeTitle": "Escanear para abrir", "qrcodeHint": "Escaneie com seu telefone para abrir o Codeg no navegador", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 97136d994..92da70512 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2772,6 +2772,7 @@ "hide": "隐藏", "show": "显示", "copy": "复制", + "copyFailed": "复制失败", "qrcode": "二维码", "qrcodeTitle": "扫码打开", "qrcodeHint": "用手机扫描,在浏览器中打开 Codeg", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 7093ec63f..be6b78a0c 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2772,6 +2772,7 @@ "hide": "隱藏", "show": "顯示", "copy": "複製", + "copyFailed": "複製失敗", "qrcode": "QR 碼", "qrcodeTitle": "掃碼開啟", "qrcodeHint": "用手機掃描,在瀏覽器中開啟 Codeg",