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/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/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" +) 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 后继续运行。当安全策略要求"配置的配额必须生效"时使用此选项。 | 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. 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 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/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-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/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/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/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), 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); + } +} 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" 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..52ca47703 --- /dev/null +++ b/src/components/settings/web-service-funnel-section.test.tsx @@ -0,0 +1,88 @@ +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..fdf1f6177 --- /dev/null +++ b/src/components/settings/web-service-funnel-section.tsx @@ -0,0 +1,237 @@ +"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..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 في المتصفح", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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 を開きます", @@ -2790,6 +2791,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..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를 엽니다", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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..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", @@ -2790,6 +2791,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 {