diff --git a/.claude/rules/go-tests.md b/.claude/rules/go-tests.md index 445eaef..7462b13 100644 --- a/.claude/rules/go-tests.md +++ b/.claude/rules/go-tests.md @@ -6,9 +6,11 @@ paths: # Test conventions (moved from CLAUDE.md "Conventions in force" + Footguns) - Table-driven; requirement IDs in comments ("(3.4)", "(A14)", "(D7)"). -- Any test that starts a real Engine / `ipc.Serve` must set a process-private - `ipc.PipeName` in `init()` (pattern: `app/setup_test.go`) — otherwise parallel - test binaries and a live daemon deadlock each other. +- Any test that starts a real Engine / `ipc.Serve` must set + `ipc.PipeName = ipc.PrivatePipeName("")` in `init()` (pattern: + `app/setup_test.go`) — otherwise parallel test binaries and a live daemon fight + over one endpoint. `PrivatePipeName` is platform-correct (pipe name on Windows, + socket path elsewhere); never hand-write a `\\.\pipe\…` name in a test. - e2e is goleak-verified (`e2e_test.go TestMain`); it must stay green through any refactor. - Fuzz targets for every internet-facing parser (`internal/mc`, `internal/control`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2355a58..a7d0709 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,20 @@ # # Two things are load-bearing here and easy to break: # -# 1. main.go embeds `all:frontend/dist`, which is .gitignore'd. A go:embed that -# matches zero files is a COMPILE ERROR, so `go vet`/`go test`/`go build` all -# fail on a fresh clone. Every Go job therefore downloads the `frontend-dist` -# artifact built once by the `frontend` job. Never add a Go job without it. +# 1. gui.go embeds `all:frontend/dist`, which is .gitignore'd. A go:embed that +# matches zero files is a COMPILE ERROR, so on Windows/macOS `go vet`/`go test`/ +# `go build` all fail on a fresh clone. Every non-linux Go job therefore +# downloads the `frontend-dist` artifact built once by the `frontend` job; +# never add one without it. gui.go is !linux (Linux runs headless), so the +# linux jobs need none. # 2. gofmt only means anything because of .gitattributes (eol=lf). Windows # runners check out CRLF by default, which makes gofmt reject every file. # -# The Go jobs run on Windows because the engine is Windows-only in practice -# (named pipes, service, firewall). The linux/macos build jobs prove the tree -# still compiles elsewhere; their binaries are NOT shippable — see the Reality -# check in CLAUDE.md. +# The Go jobs run on Windows because Windows is the only shipped platform +# (named-pipe IPC, service, firewall). `test-linux` runs the suite on ubuntu so +# the unix-socket IPC is exercised too; `build-linux` builds the headless linux +# binary and `build-macos` proves the GUI still compiles there. Their binaries +# are artifacts, NOT release assets — see the Reality check in CLAUDE.md. name: ci on: @@ -88,6 +91,23 @@ jobs: # fuzz seed corpora, and the internal/doccheck citation test. - run: go test -short ./... + # The unix-socket IPC (internal/ipc/ipc_unix.go) and every other !windows + # path only ever execute on a non-Windows OS; without this job nothing in CI + # would run them, and the Linux port would rot silently. + test-linux: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + - run: go vet ./... + - run: go test -short ./... + race: needs: frontend runs-on: windows-latest @@ -163,9 +183,8 @@ jobs: } # Runs on WINDOWS, not ubuntu: build tags make linting GOOS-sensitive. On linux - # the linter sees the !windows build, where Windows-only symbols look dead - # (`unused` flags ipc.go's maxIPCEntries, which server_windows.go uses). Lint - # the platform that ships. + # the linter sees the !windows build, where helpers used only from _windows.go + # files look dead to `unused`. Lint the platform that ships. lint: needs: frontend runs-on: windows-latest @@ -300,11 +319,13 @@ jobs: retention-days: 14 if-no-files-found: error - # Linux and macOS binaries are built to keep the *_other.go stubs honest and to - # make the eventual port a small step. They are NOT shipped: ipc.Serve returns - # ErrUnsupported off Windows, so the engine cannot start. See CLAUDE.md. + # Linux and macOS binaries are built so the non-Windows paths (unix-socket IPC, + # the *_other.go stubs) keep compiling. They run, but are artifacts only, never + # release assets: there is no packaging story yet. See CLAUDE.md. + # + # Linux is headless only (gui_linux.go), so its build is a plain static + # `go build`: no frontend, no cgo, no gtk/webkit, no wails CLI. build-linux: - needs: frontend runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -315,20 +336,10 @@ jobs: with: go-version-file: go.mod cache-dependency-path: go.sum - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: frontend-dist - path: frontend/dist - # Ubuntu 24.04 dropped libwebkit2gtk-4.0-dev. Wails v2.13 does NOT - # auto-detect this: -tags webkit2_41 is a compile-time cgo switch that - # selects the 4.1 pkg-config file. - - name: gtk/webkit deps - run: | - sudo apt-get update - sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config - - run: go install "github.com/wailsapp/wails/v2/cmd/wails@$WAILS_VERSION" - shell: bash - - run: wails build -platform linux/amd64 -clean -s -skipbindings -tags webkit2_41 + - name: go build (static, headless) + env: + CGO_ENABLED: "0" + run: go build -trimpath -ldflags "-s -w" -o build/bin/proxyforward . - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: EXPERIMENTAL-linux-amd64-${{ github.sha }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc365b1..f33c521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,8 @@ # exe, pair a loopback gateway+agent, watch bytes move - cannot be automated. # The draft exists so a human does that, then presses Publish. # -# Only Windows binaries ship. Linux/macOS build in ci.yml but cannot run: off -# Windows, ipc.Serve returns ErrUnsupported and the engine never starts. +# Only Windows binaries ship. Linux/macOS build in ci.yml as artifacts only: +# they run, but have no packaging story yet (Reality check in CLAUDE.md). name: release on: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 249958b..9203a9b 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -3,8 +3,10 @@ # review all change licensing terms if either of those facts changes. # # Note the recurring constraint from ci.yml: anything that TYPE-CHECKS Go code -# (CodeQL's manual build, govulncheck's package loader) must materialise -# frontend/dist first, or the root package fails to compile on `all:frontend/dist`. +# for a non-linux GOOS (CodeQL's manual build, govulncheck's package loader) must +# materialise frontend/dist first, or the root package fails to compile on +# `all:frontend/dist`. Linux builds are headless and skip that embed — so a scan +# that only sees GOOS=linux never sees the shipped GUI build at all. name: security on: @@ -84,11 +86,19 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json # govulncheck's source mode loads packages with full type info, so the - # embed target has to exist or every package fails to load. + # embed target has to exist for the windows pass or every package fails + # to load. - run: npm ci --prefix frontend && npm run build --prefix frontend - run: go install golang.org/x/vuln/cmd/govulncheck@latest # Reports only vulnerabilities actually reachable from our call graph. + # Twice, because reachability is per-GOOS: linux sees only the headless + # build (gui.go is !linux), so the shipped Windows build, Wails and all, + # gets its own pass. - run: govulncheck ./... + - name: govulncheck (windows build) + env: + GOOS: windows + run: govulncheck ./... gitleaks: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 15b51be..3eb3776 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,8 @@ An ngrok-style reverse tunnel that makes a Minecraft server behind NAT public. One Windows binary, two roles: the **agent** (beside Minecraft, dials OUT) and the **gateway** (publicly reachable, accepts players, relays traffic back through that one -outbound TLS link). Stack: Go engine → Wails v2 shell → React 19 GUI (`main.go`). -The tunnel works; the current phase is performance, stability, and above all UI/UX +outbound TLS link). Stack: Go engine → Wails v2 shell → React 19 GUI (`main.go`, +`gui.go`); on Linux the same binary runs headless only. The tunnel works; the current phase is performance, stability, and above all UI/UX polish. Protect what works; be honest about what doesn't exist yet (Reality check). ## Where everything lives @@ -102,7 +102,11 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i ID can never name an enrolled one (`link/cred.go IsDerivedAgentID`, `gateway/auth.go sharedTokenValidator`). Disabling `AcceptSharedToken` once every agent is enrolled closes it. - The IPC pipe ACL admits Administrators, SYSTEM, and the interactive user only - (`ipc/server_windows.go pipeSecurity`). + (`ipc/server_windows.go pipeSecurity`). Off Windows the same principals — this user + and root — via a checked 0700 socket directory, a 0600 socket, and on Linux a + per-connection `SO_PEERCRED` uid check (`ipc/ipc_unix.go checkPrivateDir`, + `ipc/peercred_linux.go peerAllowed`). The pipe carries gateway admin ops, so this + is a security boundary. - Diagnostics bundles redact every secret, host, IP, and identity; peer IPs become stable sha256 pseudonyms (`app/tools.go`, leak-tested in `app/tools_test.go`). Anything new that exports data must pass the same no-leak test style — seeding @@ -138,9 +142,13 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i evicting one agent leaves the others' listeners and connections untouched (the per-agent mux is the connection-drain boundary). (`actor.go evict` / `bindLocked`; regressions: e2e `TestAgentRestartRebinds`, `TestEvictionIsolatesAndDrains`.) -- Exactly one process owns ports and config: every engine serves the named pipe - `\\.\pipe\proxyforward`; a GUI that finds it attaches as a thin client; pipe - conflict is fatal by design (`engine.go Run`, `app/app.go Startup`). +- Exactly one process owns ports and config: every engine serves `ipc.PipeName` — the + named pipe `\\.\pipe\proxyforward` on Windows, a unix socket under `$XDG_RUNTIME_DIR` + elsewhere; a GUI that finds it attaches as a thin client; pipe conflict is fatal by + design (`engine.go Run`, `app/app.go Startup`). A socket path doesn't refuse a + second listener the way a pipe name does, so the unix side holds an `flock` for the + listener's lifetime (`ipc/ipc_unix.go listen`); only the endpoint is per-platform, + the request loop and client are shared (`ipc/server.go serveConn`). ### Hot path & performance budgets (`internal/relay/relay.go`) - The splice (`relay.go Splice`): pooled buffers, zero per-iteration allocations, @@ -196,7 +204,7 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9: | UDP tunnels | Not implemented: no UDP socket code, `validateSpec` rejects `type:"udp"`. No longer advertised (the `tunnel-udp` capability was removed); config still accepts `type:"udp"` but the gateway rejects it — a latent gap, not an oversell. | | MC status polling (MOTD/players) | Only login sniffing (`mcsniff/`); the health probe is a bare TCP dial (`health.go probeOnce`). | | Tray / minimize-to-tray / autostart | Hidden `tray_spike.go` command only; `MinimizeToTray` / `Autostart` stored, unused. | -| Linux / macOS binaries | CI **builds** them (`.github/workflows/ci.yml`) so the `*_other.go` stubs can't rot, but they **cannot run**: `ipc.Serve` returns `ErrUnsupported` off Windows and every engine must serve the pipe (`engine.go Run`), so the window opens and the engine dies. Unpublished artifacts, never release assets. Fixing this means a real unix-socket IPC port. | +| Linux / macOS support | Linux is **headless only**: the `gateway`/`pair`/`agent` commands over unix-socket IPC (`ipc/ipc_unix.go`), from a static cgo-free `go build` (recipe in `docs/agent/commands.md`); there is **no Linux GUI** — a bare launch refuses (`gui_linux.go runGUI`). **No packaged build** — CI artifacts only, never release assets — and the Windows integrations stay Windows-only: `service`/`firewall` answer `svc.ErrUnsupported` (`svc/stub_other.go`), no systemd unit. macOS still builds the GUI but has never been run. | Rules: don't document these as working; don't build UI atop them; trust code over the README everywhere. When you implement one, delete its row **in the same commit** (the @@ -228,9 +236,9 @@ Nothing enforces this; it is judgment, and ignoring it has already cost a sessio ## Enforcement — what is a gate, not advice Blocks a merge → CI. `.github/workflows/ci.yml`: gofmt, vet, `go test -short` (unit + -e2e + goleak + doccheck), `-race` (CI is the **only** place it runs — it needs cgo), -the burst floor (own job, best-of-3 — never lower the floor to go green), -`golangci-lint` (`.golangci.yml`), a TODO/FIXME ban, `actionlint`, the naming gate +e2e + goleak + doccheck; on Windows, and on Linux in `test-linux`), `-race` (CI is the +**only** place it runs — it needs cgo), the burst floor (own job, best-of-3 — never +lower the floor to go green), `golangci-lint` (`.golangci.yml`), a TODO/FIXME ban, `actionlint`, the naming gate (plan jargon + attribution trailers anywhere in a PR — `docs/agent/naming.md`), and a stale-`frontend/wailsjs` check. `.github/workflows/security.yml`: CodeQL, govulncheck, gitleaks, dependency-review, npm audit (gosec/zizmor/Scorecard are advisory → Security @@ -244,9 +252,9 @@ file and `docs/agent/reasoning.md`. Before adding a rule here, ask which of thos homes it belongs in. Two CI facts that are load-bearing and non-obvious: `.gitattributes` pins `eol=lf` -(Windows runners check out CRLF, which makes gofmt reject **every** file), and every Go -job must materialize `frontend/dist` first — `main.go` embeds it, and a `go:embed` -matching zero files is a compile error. +(Windows runners check out CRLF, which makes gofmt reject **every** file), and every +non-linux Go job must materialize `frontend/dist` first — `gui.go` embeds it, and a +`go:embed` matching zero files is a compile error (linux builds are headless and skip it). ## Maintaining this file diff --git a/README.md b/README.md index c16f6a0..779e34c 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,11 @@ machine's router changes; only the gateway forwards a port. Grab the installer or the portable exe from the [latest release](https://github.com/xeri/proxyforward/releases/latest). Windows 10/11 x64; -you need it on **both** machines. There is no Linux or macOS build — the engine is -Windows-only (named pipes, service, firewall integration). +you need it on **both** machines. There is no packaged Linux or macOS build. Linux +builds from source and runs both roles **headless** — `proxyforward gateway`, or +`proxyforward pair ` then `proxyforward agent`; there is no Linux GUI (recipe in +[`docs/agent/commands.md`](docs/agent/commands.md)). The Windows service and firewall +integration have no Linux equivalent. The binaries are **not code-signed**, so SmartScreen will warn on first run ("unknown publisher"). Rather than asking you to trust that, every release is built by @@ -340,7 +343,8 @@ the Go→JS boundary is coalesced into one `tick` event so the UI never touches count, so a legitimately flapping agent is never locked out while a brute-forcer is. - **Connection gates** — 4096 global / 32 per-IP, plus a public-port allowlist. - **Locked-down IPC** — the GUI attaches to the engine over a named pipe whose ACL admits - only Administrators, SYSTEM, and the interactive user. + only Administrators, SYSTEM, and the interactive user (on Linux, a unix socket in a + private `0700` directory whose peers are checked by uid). - **Redacted diagnostics** — bundles strip every secret, host, IP, and identity; peer IPs become stable `sha256` pseudonyms, leak-tested in CI. @@ -445,7 +449,8 @@ wails build # produces the single Windows/amd64 proxyforward.exe ``` A fresh clone must build the frontend once before any Go command -(`cd frontend && npm ci && npm run build`) — `main.go` embeds `frontend/dist`. +(`cd frontend && npm ci && npm run build`) — `gui.go` embeds `frontend/dist`. Linux +builds are headless and skip it. ## Development diff --git a/app/app.go b/app/app.go index 432f632..b847e4f 100644 --- a/app/app.go +++ b/app/app.go @@ -42,6 +42,14 @@ const ( const tickInterval = 500 * time.Millisecond type App struct { + // BeforeStartup, when set, runs first in Startup, before the page loads — + // main uses it to fit the window to the screen. It is a field, not a wrapper + // around Startup: Wails keeps lifecycle callbacks out of the JS bindings by + // matching OnStartup's function name against the bound methods, so OnStartup + // must be the a.Startup method value itself. Wrap it in a closure and Startup + // becomes callable from the frontend (CI's stale-wailsjs check catches it). + BeforeStartup func(ctx context.Context) + ctx context.Context configPath string configDir string @@ -106,6 +114,9 @@ func New(configPath string, cfg *config.Config, ring *logging.Ring, logger *slog // process owns it, and begin the status ticker. func (a *App) Startup(ctx context.Context) { a.ctx = ctx + if a.BeforeStartup != nil { + a.BeforeStartup(ctx) + } if c, err := ipc.Dial(300 * time.Millisecond); err == nil { if err := c.Ping(); err == nil { diff --git a/app/setup_test.go b/app/setup_test.go index a94ba99..3f9a0d0 100644 --- a/app/setup_test.go +++ b/app/setup_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "fmt" "io" "log/slog" "os" @@ -12,6 +11,7 @@ import ( "proxyforward/internal/config" "proxyforward/internal/ipc" + "proxyforward/internal/link" "proxyforward/internal/setup" ) @@ -19,7 +19,7 @@ import ( // binary a private pipe so it can never collide with the ipc/engine test // packages running in parallel or with a live daemon on the machine. func init() { - ipc.PipeName = fmt.Sprintf(`\\.\pipe\proxyforward-apptest-%d`, os.Getpid()) + ipc.PipeName = ipc.PrivatePipeName("apptest") } // newTestAppDir builds an App rooted in a temp config dir. @@ -115,3 +115,37 @@ func TestExportRefusedWithoutRole(t *testing.T) { t.Fatalf("want no-role refusal, got %v", err) } } + +// TestSetupAgentWithEnrollmentTicket: the wizard's default pairing code carries a +// tkt_ enrollment ticket and no shared token. SetupAgent must save and start +// that config — Validate once demanded agent.token, so every ticket pairing +// failed with "refusing to save invalid config". +func TestSetupAgentWithEnrollmentTicket(t *testing.T) { + ticket, err := link.NewEnrollTicket() + if err != nil { + t.Fatal(err) + } + code := link.PairingCode{ + Host: "127.0.0.1", + Port: 1, // nothing listens; the agent just retries + Token: ticket, + Fingerprint: "sha256:" + strings.Repeat("ab", 32), + }.String() + + a := newTestAppDir(t, config.Default()) // fresh install, wizard state + if err := a.SetupAgent(code, "", 0); err != nil { + t.Fatalf("SetupAgent with a ticket: %v", err) + } + defer a.Shutdown(context.Background()) + + if a.cfg.Agent.EnrollTicket != ticket || a.cfg.Agent.Token != "" { + t.Fatalf("ticket routed wrong: enroll_ticket=%q token=%q", a.cfg.Agent.EnrollTicket, a.cfg.Agent.Token) + } + saved, err := config.Load(a.configPath) + if err != nil { + t.Fatal(err) + } + if saved.Agent.EnrollTicket != ticket { + t.Fatalf("saved enroll_ticket = %q, want the pasted ticket", saved.Agent.EnrollTicket) + } +} diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index cd9e896..37f9650 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -105,7 +105,7 @@ persisted to config. | Rollup cadence | 5 min + on start + final on close | `rollup.go:22`, `db.go writer` | | Resolver | bulk 10 names / 2 s coalesce; 1 rps burst 3; +TTL 30 d, −TTL 24 h; profile re-check 24 h | `players/resolver.go:24-50` | | Avatars | sizes 16–128 (default 64), 8×8 master; Mojang spacing 60 s/player, 1 rps burst 3; miss TTL 15 min; evict 4000 files / 64 MiB / 6 h | `app/avatars.go:37-56` | -| Pipe | 5 s request / 2 min idle timeouts; ACL BA+SY+IU | `ipc/server_windows.go` | +| Pipe | 5 s request / 2 min idle timeouts; ACL BA+SY+IU (unix: 0700 dir, 0600 socket, peer uid = self or root) | `ipc/server.go`, `ipc/server_windows.go`, `ipc/ipc_unix.go` | | Cert | ECDSA P-256, 20-year validity (trust = pin, not expiry) | `link/cert.go:86` | | Key exchange | X25519MLKEM768 (PQ hybrid, Go default; `CurvePreferences` unset) | `link/cert.go`, `link/pq_test.go` | | Agent identity | Ed25519 (`agent_identity.key`, PKCS#8 PEM, `0600`); `agentID` = `agt_` + 8-char Crockford base32 of sha256(pubkey)[:5] = 40 bits | `link/cred.go LoadOrCreateIdentity fingerprint` | diff --git a/docs/agent/commands.md b/docs/agent/commands.md index b9cd201..39a334f 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -23,6 +23,36 @@ wails build -ldflags "-X proxyforward/internal/version.Version=vX.Y.Z -X proxyfo build/bin/proxyforward.exe --version # verify the stamp ``` +## Linux + +Linux runs **headless only**: `gateway`, `pair`, `agent`. A bare launch refuses with +a pointer to them (`gui_linux.go runGUI`) — there is no Linux GUI. `gui.go` (Wails + +the `frontend/dist` embed) is `!linux`, so a linux build pulls in no Wails, no cgo, +and no frontend: a fresh clone builds without `npm`, and the binary is static. + +``` +CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o build/bin/proxyforward . +build/bin/proxyforward gateway # first run writes the config + prints the pairing code +build/bin/proxyforward pair '' # on the agent machine +build/bin/proxyforward agent +``` + +- Config, analytics, and logs live in `~/.config/proxyforward` (`config.DefaultDir`); + the IPC is a unix socket at `$XDG_RUNTIME_DIR/proxyforward/ipc.sock`. A unix socket + path caps at 108 bytes, so a deeply nested `XDG_RUNTIME_DIR` fails the listen. +- `service` / `firewall` answer `svc.ErrUnsupported` here; there is no systemd unit. +- `wails build -platform windows/amd64` cross-compiles the Windows GUI exe from Linux + (no cgo on that target; it does need `frontend/dist`). +- `-tags bindings` is the one linux build that compiles `gui.go`: `wails build` + generates `frontend/wailsjs` by building the app with that tag for the *host* and + running it, so `gui_linux.go` is `linux && !bindings`. The same binary checks the + bindings without the wails CLI — `go build -tags bindings -o /wb . && /wb` + from the repo root, then `git diff frontend/wailsjs` (mode-only changes are noise). +- Go newer than the Wails CLI's `golang.org/x/tools` breaks `wails build` with + "package … without types was imported". Build the CLI against a current x/tools + (`go get golang.org/x/tools@latest` in a scratch module, then `go build` the + `cmd/wails` package) or use the go.mod Go version. + ## UI-only iteration (the mock state matrix) `http://localhost:5173/?mock=agent` — scenarios `agent|gateway|wizard`, plus @@ -44,14 +74,15 @@ Private configs keep your real setup untouched (`docs/agent/reasoning.md` step 0 ## Gotchas -- **Fresh clone**: every Go command fails until the frontend has been built once — - `main.go` embeds `all:frontend/dist`, which is gitignored, and a `go:embed` matching - zero files is a compile error. Run `cd frontend && npm ci && npm run build` first. - CI does this in one job and shares `frontend/dist` with every Go job. -- `GOOS=linux go build ./...` and `GOOS=darwin` now **compile** (CI builds both). They - do not *run*: `ipc.Serve` returns `ErrUnsupported` off Windows, so the engine never - starts. Cross-compiling darwin from Windows still fails on `energye/systray`, which - needs cgo — that's the cross-compile, not the code; the native macOS runner is fine. +- **Fresh clone**: on Windows/macOS every Go command fails until the frontend has been + built once — `gui.go` embeds `all:frontend/dist`, which is gitignored, and a + `go:embed` matching zero files is a compile error. Run `cd frontend && npm ci && npm + run build` first. CI does this in one job and shares `frontend/dist` with every + non-linux Go job; linux builds skip the embed. +- `GOOS=linux` builds the headless binary (see "Linux" above); `GOOS=darwin` compiles + the GUI (CI builds it). Cross-compiling darwin from Windows still fails on + `energye/systray`, which needs cgo — that's the cross-compile, not the code; the + native macOS runner is fine. - `go test -race` needs cgo (a C toolchain); unavailable on machines without one — the `race` job in CI is the only place it actually runs. - gofmt cleanliness is enforced (edit hook + CI) and depends on `.gitattributes` diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md index 97e8aa7..c2e2a23 100644 --- a/docs/agent/polish-backlog.md +++ b/docs/agent/polish-backlog.md @@ -159,10 +159,12 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change. writing explicit `_ =` assignments with a reason. Worth doing as its own commit; it was kept out of the CI suite so the lint gate could be green on day one. -22. **Linux/macOS binaries build but cannot run** — CI compiles them to keep the - `*_other.go` stubs honest, but `ipc.Serve` returns `ErrUnsupported` off Windows - (`internal/ipc/stub_other.go`), so the engine never starts. Shipping them means a - real unix-socket IPC port — an `overhaul`-skill change, not a stub fix. +22. **Linux runs headless, but nothing about it is packaged** — `gateway`/`pair`/`agent` + work over the unix-socket IPC (`internal/ipc/ipc_unix.go`); there is no Linux GUI by + design (`gui_linux.go`). Missing: a release asset; a systemd unit (the `service` + command answers `svc.ErrUnsupported`); and `service run` resolves + `config.DefaultDir(true)` to a relative `C:\ProgramData` path off Windows + (`internal/config/paths.go`). Each is small; none blocks running. ## Agent-management surfacing (backend ahead of the GUI) diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 4f2ff7f..fa50823 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,6 +1,7 @@ import {ReactNode, useEffect, useLayoutEffect, useRef, useState} from 'react' import {createPortal} from 'react-dom' import {ClipboardSetText} from '../../wailsjs/runtime/runtime' +import {hostPlatform} from '../platform' import {IconAlert, IconCheck, IconChevronDown, IconCopy, IconEye, IconEyeOff, IconInfo, IconClose} from './icons' type State = 'good' | 'warn' | 'bad' | 'unknown' @@ -930,14 +931,46 @@ export function ErrorBanner({message, onDismiss}: {message: string; onDismiss?: return {message} } -/** copyText prefers the Wails runtime clipboard (works in the WebView even - * without a secure context), falling back to the browser API in dev. */ +/** copyText prefers the Wails runtime clipboard on Windows (works in the + * WebView even without a secure context), falling back to the browser API in + * dev. On Linux it copies through WebKit itself: Wails' ClipboardSetText + * writes PRIMARY right after CLIPBOARD, and under GTK3 on Wayland that second + * write empties CLIPBOARD while still reporting success — so every Copy + * button silently cleared the clipboard. WebKit's own copy writes CLIPBOARD + * only, and must run synchronously inside the click (user activation), so the + * Linux branch comes before any await. */ export async function copyText(text: string): Promise { + if (hostPlatform() === 'linux') { + if (copyViaWebKit(text)) return + await navigator.clipboard?.writeText(text).catch(() => {}) + return + } // Real Wails resolves true; the dev mock resolves undefined → fall back. const ok = await Promise.resolve() .then(() => ClipboardSetText(text)) .catch(() => false) - if (!ok) await navigator.clipboard.writeText(text).catch(() => {}) + if (!ok) await navigator.clipboard?.writeText(text).catch(() => {}) +} + +/** Copies via a throwaway selection and the engine's copy command; returns + * whether the engine accepted it. Focus returns to whatever held it. */ +function copyViaWebKit(text: string): boolean { + const prev = document.activeElement instanceof HTMLElement ? document.activeElement : null + const ta = document.createElement('textarea') + ta.value = text + ta.readOnly = true + ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none' + document.body.appendChild(ta) + ta.select() + let ok = false + try { + ok = document.execCommand('copy') + } catch { + ok = false + } + ta.remove() + prev?.focus() + return ok } /** CopyButton with icon + brief confirmation. */ diff --git a/frontend/src/devmock.ts b/frontend/src/devmock.ts index 3310ac3..90c111f 100644 --- a/frontend/src/devmock.ts +++ b/frontend/src/devmock.ts @@ -80,6 +80,7 @@ export function installDevMock() { WindowMinimise: () => {}, WindowToggleMaximise: () => {}, WindowSetBackgroundColour: () => {}, + Environment: () => Promise.resolve({buildType: 'dev', platform: 'windows', arch: 'amd64'}), Quit: () => { console.info('[devmock] Quit()') }, ClipboardSetText: (text: string) => navigator.clipboard.writeText(text).then(() => true, () => false), }, {get: (t, p) => (p in t ? (t as any)[p] : () => {})}) diff --git a/frontend/src/platform.ts b/frontend/src/platform.ts new file mode 100644 index 0000000..8c135b2 --- /dev/null +++ b/frontend/src/platform.ts @@ -0,0 +1,31 @@ +import {Environment} from '../wailsjs/runtime/runtime' + +/** + * The host OS as the Wails runtime reports it ('windows' | 'linux' | + * 'darwin'), learned once at startup. '' until it arrives and in a plain + * browser (vite dev). Only the few native seams that genuinely differ per OS + * read it — the window background (theme.ts) and the clipboard (ui.tsx + * copyText) — never layout or copy. + */ +let platform = '' +const waiting = new Set<(p: string) => void>() + +try { + Environment().then(env => { + platform = env.platform + waiting.forEach(fn => fn(platform)) + waiting.clear() + }, () => {}) +} catch { + /* plain browser (vite dev) */ +} + +export function hostPlatform(): string { + return platform +} + +/** Runs fn with the platform now if known, else once it arrives. */ +export function onHostPlatform(fn: (p: string) => void) { + if (platform) fn(platform) + else waiting.add(fn) +} diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts index a408789..4d87aa6 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -2,6 +2,7 @@ import {useEffect, useState} from 'react' import {SetTheme} from '../wailsjs/go/app/App' import {WindowSetBackgroundColour} from '../wailsjs/runtime/runtime' import {prefersReduced} from './motion' +import {hostPlatform, onHostPlatform} from './platform' /** * Tri-state theme engine: Dark, Light, or System (follows the OS live). @@ -26,17 +27,30 @@ export function resolvedTheme(): ResolvedTheme { return pref } -function apply() { - const t = resolvedTheme() - document.documentElement.setAttribute('data-theme', t) - // Keep the native window's pre-paint colour in sync so resize flashes and - // restore frames match the theme. No-op outside the WebView2 host. +// The native pre-paint colour exists for WebView2's resize/restore flash. On +// Linux, Wails routes the same call into WebKitGTK + GTK CSS on the GTK +// thread, and a theme switch there segfaults the whole app — so it is a +// Windows-only call. The platform arrives asynchronously; until it does +// nothing is sent, and Windows syncs once it is known (main.go +// startupBackground already painted the first frame). +onHostPlatform(() => syncNativeBackground(resolvedTheme())) + +function syncNativeBackground(t: ResolvedTheme) { + if (hostPlatform() !== 'windows') return try { - if (t === 'light') WindowSetBackgroundColour(236, 238, 246, 1) - else WindowSetBackgroundColour(11, 13, 19, 1) + if (t === 'light') WindowSetBackgroundColour(236, 238, 246, 255) + else WindowSetBackgroundColour(11, 13, 19, 255) } catch { /* plain browser (vite dev) */ } +} + +function apply() { + const t = resolvedTheme() + document.documentElement.setAttribute('data-theme', t) + // Keep the native window's pre-paint colour in sync so resize flashes and + // restore frames match the theme. + syncNativeBackground(t) listeners.forEach(fn => fn()) } diff --git a/gui.go b/gui.go new file mode 100644 index 0000000..f3319f9 --- /dev/null +++ b/gui.go @@ -0,0 +1,114 @@ +//go:build !linux || bindings + +package main + +import ( + "embed" + "path/filepath" + + "github.com/wailsapp/wails/v2" + wailslogger "github.com/wailsapp/wails/v2/pkg/logger" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" + wailswin "github.com/wailsapp/wails/v2/pkg/options/windows" + + "proxyforward/app" + "proxyforward/internal/config" + "proxyforward/internal/logging" +) + +// The GUI is built for Windows (and macOS); Linux runs headless, see gui_linux.go. +// Only this file embeds the frontend, so a linux build needs no frontend/dist. +// The bindings tag is the exception: `wails build` generates frontend/wailsjs by +// building this package with -tags bindings for the HOST and running it, so a +// linux host still compiles the GUI wiring for that one job (it opens no window). + +//go:embed all:frontend/dist +var assets embed.FS + +// runGUI opens the window: the bare `proxyforward` launch, and the target of a +// pxf:// protocol launch (deepLink, empty otherwise). +func runGUI(deepLink string) error { + configPath := config.DefaultPath(false) + cfg, err := config.Load(configPath) + if err != nil { + // The GUI must still open on a broken config so the user can fix it; + // start from defaults and surface the load error in the UI later. + cfg = config.Default() + } + ring := logging.NewRing(2000) + var filePath string + if cfg.Logging.FileEnabled { + filePath = logging.DefaultFilePath(config.DefaultDir(false)) + } + logger, closeLogs, lerr := logging.New(logging.Options{ + Level: cfg.Logging.Level, + FilePath: filePath, + Ring: ring, + }) + if lerr != nil { + return lerr + } + defer closeLogs() + if err != nil { + logger.Error("config failed to load; using defaults", "err", err) + } + + a := app.New(configPath, cfg, ring, logger) + // Fit before the page loads, so the window never shows oversized. It rides + // in Startup rather than wrapping it (see App.BeforeStartup). + a.BeforeStartup = fitWindowToScreen + // A cold start via the OS protocol handler: the frontend isn't listening yet, + // so stash the link now for it to pull once it mounts (App.TakePendingDeepLink). + if deepLink != "" { + a.HandleDeepLink(deepLink) + } + // Wails reports webview/runtime failures through its own logger and can + // os.Exit without returning an error; in a windowsgui process that output + // is invisible, so route it to a file next to the crash log. + wailsLog := wailslogger.NewFileLogger(filepath.Join(config.DefaultDir(false), "logs", "wails.log")) + return wails.Run(&options.App{ + Title: "proxyforward", + Width: windowWidth, + Height: windowHeight, + MinWidth: windowMinWidth, + MinHeight: windowMinHeight, + // One GUI window owns the pairing flow: a second launch (e.g. clicking + // another pxf:// link) is forwarded here and exits, so the link always + // lands in the running window instead of opening a duplicate. + SingleInstanceLock: &options.SingleInstanceLock{ + UniqueId: "proxyforward-gui-single-instance", + OnSecondInstanceLaunch: func(data options.SecondInstanceData) { + a.OnSecondInstance(deepLinkArg(data.Args)) + }, + }, + // The window is frameless: the frontend draws its own title bar and + // window controls. DWM decorations stay on (default) so the frameless + // window keeps its drop shadow, rounded corners and Snap Layouts. + Frameless: true, + // The avatar handler serves /pf/avatar/ for paths the bundled assets + // don't cover (player heads are fetched and cached at runtime). + AssetServer: &assetserver.Options{Assets: assets, Handler: a.AvatarHandler()}, + BackgroundColour: startupBackground(cfg.UI.Theme), + Windows: &wailswin.Options{ + Theme: wailswin.SystemDefault, + }, + OnStartup: a.Startup, + OnShutdown: a.Shutdown, + Bind: []interface{}{a}, + Logger: wailsLog, + LogLevelProduction: wailslogger.ERROR, + }) +} + +// startupBackground picks the pre-paint window colour so the first frame (and +// resize flash) matches the theme the frontend is about to render. "system" +// resolves in the frontend; dark is the safer first frame for it here. Alpha +// stays 255 (opaque); Wails' Windows backend rounds any non-zero alpha up to 255 +// anyway. +func startupBackground(theme string) *options.RGBA { + if theme == "light" { + return &options.RGBA{R: 236, G: 238, B: 246, A: 255} + } + return &options.RGBA{R: 11, G: 13, B: 19, A: 255} +} diff --git a/gui_linux.go b/gui_linux.go new file mode 100644 index 0000000..9c927f6 --- /dev/null +++ b/gui_linux.go @@ -0,0 +1,18 @@ +//go:build linux && !bindings + +package main + +import "errors" + +// errNoGUI answers a bare launch on Linux with what to run instead. +var errNoGUI = errors.New("proxyforward has no GUI on linux; run it headless: " + + "`proxyforward gateway`, or `proxyforward pair ` then `proxyforward agent` (see --help)") + +// runGUI on Linux refuses: Linux runs headless only. The GUI (Wails over +// WebKitGTK) needed per-driver workarounds just to paint there, and leaving it +// out keeps cgo, gtk and webkit out of the linux build, so a plain `go build` +// yields one static binary with no frontend to embed. A pxf:// link passed as +// the sole argument lands here too; the message points it at `pair`. +func runGUI(string) error { + return errNoGUI +} diff --git a/internal/config/config.go b/internal/config/config.go index 90b669b..a32be6b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,9 +84,11 @@ type AgentConfig struct { Transport string `toml:"transport"` // "auto" | "quic" | "per-conn" | "mux" Tunnels []Tunnel `toml:"tunnels"` // EnrollTicket is a one-time pairing ticket carried on the first connect to a - // gateway running per-agent identity; the agent clears it once the gateway - // confirms enrollment. Empty in steady state (the Ed25519 identity authenticates - // thereafter). + // gateway running per-agent identity. It stays set after enrollment: the + // gateway authenticates an allowlisted key first and ignores a spent ticket + // (gateway/auth.go identityValidator), and the Ed25519 identity is what + // authenticates thereafter. A ticket-paired agent has no Token, so Validate + // accepts either as proof of pairing. EnrollTicket string `toml:"enroll_ticket"` } @@ -350,7 +352,9 @@ func (c *Config) validateAgent() []error { if err := validPort(a.GatewayPort); err != nil { errs = append(errs, fmt.Errorf("agent.gateway_port: %w", err)) } - if a.Token == "" { + // A pairing code carries exactly one credential (SetupAgent routes it): a + // shared Token, or an EnrollTicket for per-agent identity. + if a.Token == "" && a.EnrollTicket == "" { errs = append(errs, errors.New("agent.token: required (pair with a gateway first)")) } if a.CertFingerprint != "" && !strings.HasPrefix(a.CertFingerprint, "sha256:") { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2c6c478..ff0e124 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -170,6 +170,10 @@ func TestValidateAccepts(t *testing.T) { "bandwidth cap with empty scope": func(c *Config) { c.Agent.Tunnels[0].Options.BandwidthLimitMbps = 5 // empty scope normalizes to combined }, + "enrollment ticket instead of a shared token": func(c *Config) { + c.Agent.Token = "" + c.Agent.EnrollTicket = "tkt_abc" + }, "bandwidth scope ignored without a cap": func(c *Config) { c.Agent.Tunnels[0].Options.BandwidthLimitScope = "sideways" // no cap, so not validated }, diff --git a/internal/e2e/attach_test.go b/internal/e2e/attach_test.go index 9865ebc..eef13bb 100644 --- a/internal/e2e/attach_test.go +++ b/internal/e2e/attach_test.go @@ -4,10 +4,8 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "log/slog" - "os" "path/filepath" "testing" "time" @@ -28,7 +26,7 @@ func TestAttachedAnalytics(t *testing.T) { // A private pipe: parallel test binaries (and a live daemon) must never // share the production name. old := ipc.PipeName - ipc.PipeName = fmt.Sprintf(`\\.\pipe\proxyforward-e2e-attach-%d`, os.Getpid()) + ipc.PipeName = ipc.PrivatePipeName("e2e-attach") t.Cleanup(func() { ipc.PipeName = old }) dir := t.TempDir() diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 06952ea..60f79f6 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -7,7 +7,6 @@ package engine import ( "context" - "errors" "fmt" "log/slog" "net" @@ -184,10 +183,7 @@ func (e *Engine) Run(ctx context.Context) error { Peers: e.Peers, Analytics: e.analyticsSource, }) - if errors.Is(err, ipc.ErrUnsupported) { - e.logger.Debug("ipc pipe unsupported on this platform") - err = nil - } else if err != nil && runCtx.Err() == nil { + if err != nil && runCtx.Err() == nil { err = fmt.Errorf("ipc pipe failed (is another proxyforward daemon running?): %w", err) } errCh <- err diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index d1d0714..0085839 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -13,12 +13,18 @@ import ( "proxyforward/internal/analytics" "proxyforward/internal/config" + "proxyforward/internal/ipc" ) +// Run serves IPC, and a second server on one endpoint is fatal, so this test +// binary gets a private pipe — a live daemon on the dev machine (or a +// parallel test package) would otherwise stop the engine under test. +func init() { + ipc.PipeName = ipc.PrivatePipeName("enginetest") +} + // TestStatsLifecycle runs a real gateway engine briefly and verifies the -// stats store samples and lands in the analytics database at shutdown. The -// IPC pipe may be owned by another process on a dev machine; that only fails -// Run's error path, not the store lifecycle this test asserts on. +// stats store samples and lands in the analytics database at shutdown. func TestStatsLifecycle(t *testing.T) { dir := t.TempDir() // Config validation requires a concrete port; borrow a free one. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 641fcc6..214fcc2 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -136,6 +136,16 @@ func RunStarted(ctx context.Context, g *Gateway, cfg *config.Config, logger *slo if err := g.Start(ctx); err != nil { return err } + if !cfg.Gateway.AcceptSharedToken { + // The shared-token code is useless while that path is off (the + // validator refuses it), and it would still put the token on screen. + // Agents join with an enrollment ticket instead (issue_pairing). + logger.Info("gateway ready — shared-token pairing is off; agents enroll with a ticket") + fmt.Println("\nshared-token pairing is off: agents join with an enrollment ticket (issue one from the GUI or over IPC).") + <-ctx.Done() + g.Shutdown() + return nil + } code := link.PairingCode{ Host: "YOUR-PUBLIC-ADDRESS", Port: g.ControlAddr().(*net.TCPAddr).Port, diff --git a/internal/ipc/client.go b/internal/ipc/client.go new file mode 100644 index 0000000..fad40fa --- /dev/null +++ b/internal/ipc/client.go @@ -0,0 +1,91 @@ +package ipc + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/stats" +) + +// Client is one pipe connection to the daemon. Not safe for concurrent use; +// the GUI polls from a single loop. +type Client struct { + conn net.Conn +} + +// Dial connects to a running daemon's pipe. A quick failure means no daemon +// is running — the GUI's cue to start its own engine in-process. +func Dial(timeout time.Duration) (*Client, error) { + conn, err := dialPipe(timeout) + if err != nil { + return nil, err + } + return &Client{conn: conn}, nil +} + +func (c *Client) Close() error { return c.conn.Close() } + +// roundTrip sends one request and decodes the expected response type. +func roundTrip[T any](c *Client, reqType string, req any, respType string) (*T, error) { + c.conn.SetDeadline(time.Now().Add(requestTimeout)) + defer c.conn.SetDeadline(time.Time{}) + if err := control.WriteMsg(c.conn, reqType, req); err != nil { + return nil, fmt.Errorf("ipc: send %s: %w", reqType, err) + } + env, err := control.ReadMsg(c.conn, control.MaxFrame) + if err != nil { + return nil, fmt.Errorf("ipc: read reply to %s: %w", reqType, err) + } + if env.Type != respType { + return nil, fmt.Errorf("ipc: unexpected reply %q to %s", env.Type, reqType) + } + return control.Decode[T](env) +} + +// Ping verifies the daemon is alive. +func (c *Client) Ping() error { + _, err := roundTrip[struct{}](c, TypePing, struct{}{}, TypePong) + return err +} + +// Status fetches the daemon's current snapshot. +func (c *Client) Status() (*Status, error) { + return roundTrip[Status](c, TypeStatusReq, struct{}{}, TypeStatusResp) +} + +// History fetches bandwidth history from the daemon. An old daemon that +// predates this request never replies, so the call fails with a read timeout +// after requestTimeout — callers should degrade, not retry. +func (c *Client) History(windowMs int64, maxBuckets int) (*stats.HistoryResult, error) { + return roundTrip[stats.HistoryResult](c, TypeHistoryReq, + HistoryReq{WindowMs: windowMs, MaxBuckets: maxBuckets}, TypeHistoryResp) +} + +// Peers fetches the daemon's per-client lifetime records. +func (c *Client) Peers() ([]stats.PeerStat, error) { + resp, err := roundTrip[PeersResp](c, TypePeersReq, struct{}{}, TypePeersResp) + if err != nil { + return nil, err + } + return resp.Peers, nil +} + +// Analytics runs one generic analytics op against the daemon. An old daemon +// that predates the analytics envelope never replies, so the call fails with +// a read timeout — the caller latches "unsupported" and stops asking. A +// daemon that answered but reported a failure returns *OpError, which the +// caller must treat as transient (the protocol works) — never latch on it. +func (c *Client) Analytics(op string, body json.RawMessage) (json.RawMessage, error) { + resp, err := roundTrip[AnalyticsResp](c, TypeAnalyticsReq, + AnalyticsReq{Op: op, Body: body}, TypeAnalyticsResp) + if err != nil { + return nil, err + } + if resp.Err != "" { + return nil, &OpError{Op: op, Msg: resp.Err} + } + return resp.Body, nil +} diff --git a/internal/ipc/client_windows.go b/internal/ipc/client_windows.go index 21062b0..6ffeb1e 100644 --- a/internal/ipc/client_windows.go +++ b/internal/ipc/client_windows.go @@ -3,93 +3,12 @@ package ipc import ( - "encoding/json" - "fmt" "net" "time" "github.com/Microsoft/go-winio" - - "proxyforward/internal/control" - "proxyforward/internal/stats" ) -// Client is one pipe connection to the daemon. Not safe for concurrent use; -// the GUI polls from a single loop. -type Client struct { - conn net.Conn -} - -// Dial connects to a running daemon's pipe. A quick failure means no daemon -// is running — the GUI's cue to start its own engine in-process. -func Dial(timeout time.Duration) (*Client, error) { - conn, err := winio.DialPipe(PipeName, &timeout) - if err != nil { - return nil, err - } - return &Client{conn: conn}, nil -} - -func (c *Client) Close() error { return c.conn.Close() } - -// roundTrip sends one request and decodes the expected response type. -func roundTrip[T any](c *Client, reqType string, req any, respType string) (*T, error) { - c.conn.SetDeadline(time.Now().Add(requestTimeout)) - defer c.conn.SetDeadline(time.Time{}) - if err := control.WriteMsg(c.conn, reqType, req); err != nil { - return nil, fmt.Errorf("ipc: send %s: %w", reqType, err) - } - env, err := control.ReadMsg(c.conn, control.MaxFrame) - if err != nil { - return nil, fmt.Errorf("ipc: read reply to %s: %w", reqType, err) - } - if env.Type != respType { - return nil, fmt.Errorf("ipc: unexpected reply %q to %s", env.Type, reqType) - } - return control.Decode[T](env) -} - -// Ping verifies the daemon is alive. -func (c *Client) Ping() error { - _, err := roundTrip[struct{}](c, TypePing, struct{}{}, TypePong) - return err -} - -// Status fetches the daemon's current snapshot. -func (c *Client) Status() (*Status, error) { - return roundTrip[Status](c, TypeStatusReq, struct{}{}, TypeStatusResp) -} - -// History fetches bandwidth history from the daemon. An old daemon that -// predates this request never replies, so the call fails with a read timeout -// after requestTimeout — callers should degrade, not retry. -func (c *Client) History(windowMs int64, maxBuckets int) (*stats.HistoryResult, error) { - return roundTrip[stats.HistoryResult](c, TypeHistoryReq, - HistoryReq{WindowMs: windowMs, MaxBuckets: maxBuckets}, TypeHistoryResp) -} - -// Peers fetches the daemon's per-client lifetime records. -func (c *Client) Peers() ([]stats.PeerStat, error) { - resp, err := roundTrip[PeersResp](c, TypePeersReq, struct{}{}, TypePeersResp) - if err != nil { - return nil, err - } - return resp.Peers, nil -} - -// Analytics runs one generic analytics op against the daemon. An old daemon -// that predates the analytics envelope never replies, so the call fails with -// a read timeout — the caller latches "unsupported" and stops asking. A -// daemon that answered but reported a failure returns *OpError, which the -// caller must treat as transient (the protocol works) — never latch on it. -func (c *Client) Analytics(op string, body json.RawMessage) (json.RawMessage, error) { - resp, err := roundTrip[AnalyticsResp](c, TypeAnalyticsReq, - AnalyticsReq{Op: op, Body: body}, TypeAnalyticsResp) - if err != nil { - return nil, err - } - if resp.Err != "" { - return nil, &OpError{Op: op, Msg: resp.Err} - } - return resp.Body, nil +func dialPipe(timeout time.Duration) (net.Conn, error) { + return winio.DialPipe(PipeName, &timeout) } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 73ea51e..272128b 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -1,23 +1,25 @@ -// Package ipc is the GUI↔daemon control channel: a named-pipe JSON-RPC -// carried in the same length-prefixed framing as the wire protocol -// (internal/control). The daemon (service or headless run) serves it; a GUI +// Package ipc is the GUI↔daemon control channel: a local JSON-RPC carried in +// the same length-prefixed framing as the wire protocol (internal/control), +// over a named pipe on Windows and a unix socket elsewhere. Only the endpoint +// is per-platform (server_windows.go / ipc_unix.go); the request loop and the +// client are shared. The daemon (service or headless run) serves it; a GUI // that finds the pipe attaches as a thin client instead of starting its own // engine, so exactly one process ever owns ports and config. package ipc import ( "encoding/json" - "errors" "proxyforward/internal/conntrack" "proxyforward/internal/stats" ) -// PipeName is the daemon's well-known local endpoint. It is a var only so -// test packages that run a real engine can point it at a private name — -// parallel test binaries (and a developer's live daemon) must never fight -// over the production pipe. -var PipeName = `\\.\pipe\proxyforward` +// PipeName is the daemon's well-known local endpoint: `\\.\pipe\proxyforward` +// on Windows, a socket path under $XDG_RUNTIME_DIR elsewhere. It is a var +// only so test packages that run a real engine can point it at +// PrivatePipeName — parallel test binaries (and a developer's live daemon) +// must never fight over the production endpoint. +var PipeName = defaultPipeName() // Message types (control.Envelope.Type values on the pipe). const ( @@ -84,9 +86,6 @@ type PeersResp struct { Peers []stats.PeerStat `json:"peers"` } -// ErrUnsupported is returned on platforms without named pipes. -var ErrUnsupported = errors.New("ipc: only supported on Windows") - // Status is the daemon's self-description, polled by status surfaces. type Status struct { Role string `json:"role"` diff --git a/internal/ipc/ipc_windows_test.go b/internal/ipc/ipc_test.go similarity index 98% rename from internal/ipc/ipc_windows_test.go rename to internal/ipc/ipc_test.go index 4c2aad1..b41d538 100644 --- a/internal/ipc/ipc_windows_test.go +++ b/internal/ipc/ipc_test.go @@ -1,14 +1,10 @@ -//go:build windows - package ipc import ( "context" "encoding/json" "errors" - "fmt" "log/slog" - "os" "strings" "testing" "time" @@ -19,7 +15,7 @@ import ( // A private pipe keeps this binary from colliding with parallel test // packages that serve IPC (engine, app, e2e) or a live daemon. func init() { - PipeName = fmt.Sprintf(`\\.\pipe\proxyforward-ipctest-%d`, os.Getpid()) + PipeName = PrivatePipeName("ipctest") } func TestPipeStatusRoundTrip(t *testing.T) { diff --git a/internal/ipc/ipc_unix.go b/internal/ipc/ipc_unix.go new file mode 100644 index 0000000..4452555 --- /dev/null +++ b/internal/ipc/ipc_unix.go @@ -0,0 +1,146 @@ +//go:build !windows + +package ipc + +// Off Windows the endpoint is a unix socket, and access control is the +// filesystem plus the kernel's peer credentials, admitting the same +// principals pipeSecurity does — this user, and root as the +// Administrators/SYSTEM analogue: +// +// - the socket lives in a directory only this user can enter (0700 and +// owned by us — checked at listen, never assumed, so a pre-created or +// symlinked directory in a shared /tmp fails closed); +// - the socket itself is 0600; +// - on Linux every accepted peer's uid is checked as well (peercred_linux.go). +// +// The pipe carries gateway admin ops (issue_pairing, revoke_agent, …), so +// this is a security boundary, not a convenience. +// +// Single ownership: a pipe name refuses a second listener by itself; a socket +// path does not (a second engine could unlink the first's socket and bind +// over it). So listen takes a non-blocking flock on a sibling lock file first +// and holds it for the listener's lifetime: held → another engine is live → +// fatal, exactly like a pipe conflict. The kernel drops the lock when its +// holder dies, so a crashed engine's leftover socket is safe to remove. + +import ( + "errors" + "fmt" + "io/fs" + "net" + "os" + "path/filepath" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +// defaultPipeName prefers $XDG_RUNTIME_DIR (per-user, tmpfs, mode 0700, +// cleared at logout) and falls back to a per-uid directory under the temp dir +// where no runtime dir exists (e.g. a non-login service). +func defaultPipeName() string { + if dir := os.Getenv("XDG_RUNTIME_DIR"); filepath.IsAbs(dir) { + return filepath.Join(dir, "proxyforward", "ipc.sock") + } + return filepath.Join(os.TempDir(), fmt.Sprintf("proxyforward-%d", os.Geteuid()), "ipc.sock") +} + +// PrivatePipeName is a process-private endpoint for tests that run a real +// engine or Serve, so parallel test binaries and a live daemon never share +// the production socket. +func PrivatePipeName(tag string) string { + return filepath.Join(os.TempDir(), fmt.Sprintf("proxyforward-%s-%d", tag, os.Getpid()), "ipc.sock") +} + +func listen() (net.Listener, error) { + path := PipeName + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("ipc: create socket directory: %w", err) + } + if err := checkPrivateDir(dir); err != nil { + return nil, err + } + + lock, err := os.OpenFile(path+".lock", os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, fmt.Errorf("ipc: open lock file: %w", err) + } + if err := unix.Flock(int(lock.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + lock.Close() + if errors.Is(err, unix.EWOULDBLOCK) { + return nil, fmt.Errorf("ipc: %s is already served by another proxyforward engine; close that one first", path) + } + return nil, fmt.Errorf("ipc: lock %s: %w", lock.Name(), err) + } + + // The lock is ours, so anything at path is a dead engine's leftover. + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + lock.Close() + return nil, fmt.Errorf("ipc: remove stale socket: %w", err) + } + ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"}) + if err != nil { + lock.Close() + return nil, err + } + // The directory already keeps others out; 0600 keeps the socket private + // even if the directory is later loosened. + if err := os.Chmod(path, 0o600); err != nil { + ln.Close() + lock.Close() + return nil, fmt.Errorf("ipc: restrict socket: %w", err) + } + return &unixListener{UnixListener: ln, lock: lock}, nil +} + +// checkPrivateDir refuses a socket directory anyone but us could enter or +// swap: it must be a real directory (Lstat — not a symlink), owned by our +// euid, with no group/other permission bits. +func checkPrivateDir(dir string) error { + fi, err := os.Lstat(dir) + if err != nil { + return fmt.Errorf("ipc: %w", err) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !fi.IsDir() || !ok || int(st.Uid) != os.Geteuid() || fi.Mode().Perm()&0o077 != 0 { + return fmt.Errorf("ipc: socket directory %s must be a directory you own with mode 0700 (got %v); fix it with chmod 700, or remove it", dir, fi.Mode()) + } + return nil +} + +// unixListener drops peers that fail the credential check and releases the +// ownership lock on Close — after the socket is unlinked, so a successor that +// takes the lock can never have its fresh socket deleted by us. +type unixListener struct { + *net.UnixListener + lock *os.File + closeOnce sync.Once +} + +func (l *unixListener) Accept() (net.Conn, error) { + for { + c, err := l.AcceptUnix() + if err != nil { + return nil, err + } + if peerAllowed(c) { + return c, nil + } + c.Close() + } +} + +func (l *unixListener) Close() error { + err := l.UnixListener.Close() // unlinks the socket path + l.closeOnce.Do(func() { l.lock.Close() }) + return err +} + +func isListenerClosed(err error) bool { return errors.Is(err, net.ErrClosed) } + +func dialPipe(timeout time.Duration) (net.Conn, error) { + return net.DialTimeout("unix", PipeName, timeout) +} diff --git a/internal/ipc/ipc_unix_test.go b/internal/ipc/ipc_unix_test.go new file mode 100644 index 0000000..e4379a8 --- /dev/null +++ b/internal/ipc/ipc_unix_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package ipc + +import ( + "context" + "log/slog" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +// useSocket points PipeName at path for one test, restoring it after. +func useSocket(t *testing.T, path string) { + t.Helper() + old := PipeName + PipeName = path + t.Cleanup(func() { PipeName = old }) +} + +// serveUntilUp starts Serve and waits until a Ping succeeds; the returned +// stop cancels it and waits for Serve to return. +func serveUntilUp(t *testing.T) (stop func() error) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Serve(ctx, slog.New(slog.DiscardHandler), Sources{Status: func() Status { return Status{} }}) + }() + deadline := time.Now().Add(5 * time.Second) + for { + c, err := Dial(200 * time.Millisecond) + if err == nil { + err = c.Ping() + c.Close() + if err == nil { + break + } + } + select { + case serr := <-done: + cancel() + t.Fatalf("Serve exited before coming up: %v", serr) + default: + } + if time.Now().After(deadline) { + cancel() + t.Fatalf("socket never came up: %v", err) + } + time.Sleep(20 * time.Millisecond) + } + return func() error { + cancel() + select { + case err := <-done: + return err + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return after cancel") + return nil + } + } +} + +// The socket directory and the socket are private to this user, and a clean +// shutdown removes the socket. +func TestUnixSocketIsPrivate(t *testing.T) { + dir := filepath.Join(t.TempDir(), "pxf") + sock := filepath.Join(dir, "ipc.sock") + useSocket(t, sock) + stop := serveUntilUp(t) + + di, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if got := di.Mode().Perm(); got != 0o700 { + t.Errorf("socket dir mode = %v, want 0700", got) + } + si, err := os.Stat(sock) + if err != nil { + t.Fatal(err) + } + if si.Mode()&os.ModeSocket == 0 { + t.Errorf("%s is not a socket: %v", sock, si.Mode()) + } + if got := si.Mode().Perm(); got != 0o600 { + t.Errorf("socket mode = %v, want 0600", got) + } + + if err := stop(); err != nil { + t.Fatalf("Serve: %v", err) + } + if _, err := os.Stat(sock); !os.IsNotExist(err) { + t.Errorf("socket left behind after shutdown (stat err = %v)", err) + } +} + +// A crashed engine leaves its socket file behind with nobody listening; the +// next engine must take over instead of failing "address already in use". +func TestUnixStaleSocketReplaced(t *testing.T) { + dir := filepath.Join(t.TempDir(), "pxf") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + sock := filepath.Join(dir, "ipc.sock") + ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: sock, Net: "unix"}) + if err != nil { + t.Fatal(err) + } + ln.SetUnlinkOnClose(false) + ln.Close() + if _, err := os.Stat(sock); err != nil { + t.Fatalf("stale socket not left in place: %v", err) + } + + useSocket(t, sock) + stop := serveUntilUp(t) + if err := stop(); err != nil { + t.Fatalf("Serve: %v", err) + } +} + +// A socket directory others can enter is refused outright, never used. +func TestUnixInsecureDirRefused(t *testing.T) { + dir := filepath.Join(t.TempDir(), "pxf") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o755); err != nil { // explicit: Mkdir is umask-filtered + t.Fatal(err) + } + useSocket(t, filepath.Join(dir, "ipc.sock")) + + done := make(chan error, 1) + go func() { + done <- Serve(context.Background(), slog.New(slog.DiscardHandler), Sources{Status: func() Status { return Status{} }}) + }() + select { + case err := <-done: + if err == nil { + t.Fatal("Serve accepted a group/world-accessible socket directory") + } + case <-time.After(5 * time.Second): + t.Fatal("Serve is serving from a group/world-accessible socket directory") + } +} diff --git a/internal/ipc/peercred_linux.go b/internal/ipc/peercred_linux.go new file mode 100644 index 0000000..2ef740d --- /dev/null +++ b/internal/ipc/peercred_linux.go @@ -0,0 +1,31 @@ +//go:build linux + +package ipc + +import ( + "net" + "os" + + "golang.org/x/sys/unix" +) + +// peerAllowed admits a connection only from this user or root — the unix +// reading of pipeSecurity. The 0700 socket directory already keeps everyone +// else from connecting; SO_PEERCRED is the kernel's word on who actually did, +// so the boundary holds even if that directory is loosened after listen. +func peerAllowed(c *net.UnixConn) bool { + raw, err := c.SyscallConn() + if err != nil { + return false + } + var ( + cred *unix.Ucred + credErr error + ) + if err := raw.Control(func(fd uintptr) { + cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) + }); err != nil || credErr != nil { + return false + } + return cred.Uid == uint32(os.Geteuid()) || cred.Uid == 0 +} diff --git a/internal/ipc/peercred_other.go b/internal/ipc/peercred_other.go new file mode 100644 index 0000000..c1fe53f --- /dev/null +++ b/internal/ipc/peercred_other.go @@ -0,0 +1,10 @@ +//go:build !windows && !linux + +package ipc + +import "net" + +// peerAllowed has no per-connection check off Linux: the 0700 socket +// directory (checked at listen) is the whole boundary there. macOS could add +// LOCAL_PEERCRED here if it ever ships. +func peerAllowed(*net.UnixConn) bool { return true } diff --git a/internal/ipc/server.go b/internal/ipc/server.go new file mode 100644 index 0000000..bde919f --- /dev/null +++ b/internal/ipc/server.go @@ -0,0 +1,136 @@ +package ipc + +import ( + "context" + "errors" + "log/slog" + "net" + "sync" + "time" + + "proxyforward/internal/control" + "proxyforward/internal/stats" +) + +const ( + // requestTimeout bounds one request/response cycle; idleTimeout is how + // long a quiet client may hold its pipe connection. + requestTimeout = 5 * time.Second + idleTimeout = 2 * time.Minute +) + +// Serve owns the IPC endpoint (PipeName) until ctx is cancelled. Each client +// connection gets its own goroutine; requests are strictly request/response. +// Only the endpoint is per-platform (listen: a named pipe on Windows, a unix +// socket elsewhere); everything carried over it is shared. A second Serve +// while one is live fails, so two engines can never both own ports and config. +func Serve(ctx context.Context, logger *slog.Logger, src Sources) error { + ln, err := listen() + if err != nil { + return err + } + // Closing the listener unblocks Accept when ctx dies. + stopped := make(chan struct{}) + defer func() { <-stopped }() + watchCtx, cancelWatch := context.WithCancel(ctx) + defer cancelWatch() + go func() { + defer close(stopped) + <-watchCtx.Done() + ln.Close() + }() + + logger.Info("ipc pipe up", "pipe", PipeName) + var ( + wg sync.WaitGroup + conns []net.Conn // only the accept loop appends; no lock needed + ) + defer wg.Wait() // runs after the conns close below (defers are LIFO) + defer func() { + for _, c := range conns { + c.Close() // unblocks each handler's read + } + }() + for { + conn, err := ln.Accept() + if err != nil { + if ctx.Err() != nil { + return nil + } + if isListenerClosed(err) { + return nil + } + return err + } + conns = append(conns, conn) + wg.Add(1) + go func() { + defer wg.Done() + serveConn(ctx, logger, conn, src) + }() + } +} + +func serveConn(ctx context.Context, logger *slog.Logger, conn net.Conn, src Sources) { + defer conn.Close() + for ctx.Err() == nil { + conn.SetReadDeadline(time.Now().Add(idleTimeout)) + env, err := control.ReadMsg(conn, control.MaxFrame) + if err != nil { + return // client went away or idled out + } + conn.SetWriteDeadline(time.Now().Add(requestTimeout)) + switch env.Type { + case TypePing: + err = control.WriteMsg(conn, TypePong, struct{}{}) + case TypeStatusReq: + err = control.WriteMsg(conn, TypeStatusResp, src.Status()) + case TypeHistoryReq: + req, decErr := control.Decode[HistoryReq](env) + if decErr != nil { + logger.Debug("ipc: bad history request", "err", decErr) + return + } + resp := stats.HistoryResult{Buckets: []stats.Bucket{}} + if src.History != nil { + // Clamp so the reply always fits control.MaxFrame. + resp = src.History(req.WindowMs, min(req.MaxBuckets, maxIPCEntries)) + } + err = control.WriteMsg(conn, TypeHistoryResp, resp) + case TypePeersReq: + var peers []stats.PeerStat + if src.Peers != nil { + peers = src.Peers() + } + if len(peers) > maxIPCEntries { + peers = peers[:maxIPCEntries] + } + err = control.WriteMsg(conn, TypePeersResp, PeersResp{Peers: peers}) + case TypeAnalyticsReq: + req, decErr := control.Decode[AnalyticsReq](env) + if decErr != nil { + logger.Debug("ipc: bad analytics request", "err", decErr) + return + } + resp := AnalyticsResp{Err: "analytics unavailable"} + if src.Analytics != nil { + resp = src.Analytics(*req) + } + err = control.WriteMsg(conn, TypeAnalyticsResp, resp) + if errors.Is(err, control.ErrFrameTooLarge) { + // WriteMsg size-checks before writing a byte, so the pipe is + // still clean: substitute a served error instead of tearing + // the connection down. The client surfaces it as an OpError + // (transient), never an unsupported-daemon latch. + logger.Warn("ipc: analytics reply exceeds frame limit", "op", req.Op) + err = control.WriteMsg(conn, TypeAnalyticsResp, AnalyticsResp{Err: "reply exceeds frame limit"}) + } + default: + logger.Debug("ipc: ignoring unknown request", "type", env.Type) + continue + } + if err != nil { + return + } + } +} diff --git a/internal/ipc/server_windows.go b/internal/ipc/server_windows.go index 2639830..f51a0e4 100644 --- a/internal/ipc/server_windows.go +++ b/internal/ipc/server_windows.go @@ -3,24 +3,12 @@ package ipc import ( - "context" "errors" - "log/slog" + "fmt" "net" - "sync" - "time" + "os" "github.com/Microsoft/go-winio" - - "proxyforward/internal/control" - "proxyforward/internal/stats" -) - -const ( - // requestTimeout bounds one request/response cycle; idleTimeout is how - // long a quiet client may hold its pipe connection. - requestTimeout = 5 * time.Second - idleTimeout = 2 * time.Minute ) // pipeSecurity admits Administrators (BA), the interactive user (IU), and @@ -28,118 +16,22 @@ const ( // service; remote access is impossible on local pipes by construction. const pipeSecurity = "D:P(A;;GA;;;BA)(A;;GA;;;SY)(A;;GA;;;IU)" -// Serve owns the named pipe until ctx is cancelled. Each client connection -// gets its own goroutine; requests are strictly request/response. -func Serve(ctx context.Context, logger *slog.Logger, src Sources) error { - ln, err := winio.ListenPipe(PipeName, &winio.PipeConfig{ +func defaultPipeName() string { return `\\.\pipe\proxyforward` } + +// PrivatePipeName is a process-private endpoint for tests that run a real +// engine or Serve, so parallel test binaries and a live daemon never share +// the production pipe. +func PrivatePipeName(tag string) string { + return fmt.Sprintf(`\\.\pipe\proxyforward-%s-%d`, tag, os.Getpid()) +} + +// listen opens PipeName under pipeSecurity. A second listener on a live name +// fails, which is what makes a second engine fatal. +func listen() (net.Listener, error) { + return winio.ListenPipe(PipeName, &winio.PipeConfig{ SecurityDescriptor: pipeSecurity, MessageMode: false, }) - if err != nil { - return err - } - // Closing the listener unblocks Accept when ctx dies. - stopped := make(chan struct{}) - defer func() { <-stopped }() - watchCtx, cancelWatch := context.WithCancel(ctx) - defer cancelWatch() - go func() { - defer close(stopped) - <-watchCtx.Done() - ln.Close() - }() - - logger.Info("ipc pipe up", "pipe", PipeName) - var ( - wg sync.WaitGroup - conns []net.Conn // only the accept loop appends; no lock needed - ) - defer wg.Wait() // runs after the conns close below (defers are LIFO) - defer func() { - for _, c := range conns { - c.Close() // unblocks each handler's read - } - }() - for { - conn, err := ln.Accept() - if err != nil { - if ctx.Err() != nil { - return nil - } - if errors.Is(err, winio.ErrPipeListenerClosed) { - return nil - } - return err - } - conns = append(conns, conn) - wg.Add(1) - go func() { - defer wg.Done() - serveConn(ctx, logger, conn, src) - }() - } } -func serveConn(ctx context.Context, logger *slog.Logger, conn net.Conn, src Sources) { - defer conn.Close() - for ctx.Err() == nil { - conn.SetReadDeadline(time.Now().Add(idleTimeout)) - env, err := control.ReadMsg(conn, control.MaxFrame) - if err != nil { - return // client went away or idled out - } - conn.SetWriteDeadline(time.Now().Add(requestTimeout)) - switch env.Type { - case TypePing: - err = control.WriteMsg(conn, TypePong, struct{}{}) - case TypeStatusReq: - err = control.WriteMsg(conn, TypeStatusResp, src.Status()) - case TypeHistoryReq: - req, decErr := control.Decode[HistoryReq](env) - if decErr != nil { - logger.Debug("ipc: bad history request", "err", decErr) - return - } - resp := stats.HistoryResult{Buckets: []stats.Bucket{}} - if src.History != nil { - // Clamp so the reply always fits control.MaxFrame. - resp = src.History(req.WindowMs, min(req.MaxBuckets, maxIPCEntries)) - } - err = control.WriteMsg(conn, TypeHistoryResp, resp) - case TypePeersReq: - var peers []stats.PeerStat - if src.Peers != nil { - peers = src.Peers() - } - if len(peers) > maxIPCEntries { - peers = peers[:maxIPCEntries] - } - err = control.WriteMsg(conn, TypePeersResp, PeersResp{Peers: peers}) - case TypeAnalyticsReq: - req, decErr := control.Decode[AnalyticsReq](env) - if decErr != nil { - logger.Debug("ipc: bad analytics request", "err", decErr) - return - } - resp := AnalyticsResp{Err: "analytics unavailable"} - if src.Analytics != nil { - resp = src.Analytics(*req) - } - err = control.WriteMsg(conn, TypeAnalyticsResp, resp) - if errors.Is(err, control.ErrFrameTooLarge) { - // WriteMsg size-checks before writing a byte, so the pipe is - // still clean: substitute a served error instead of tearing - // the connection down. The client surfaces it as an OpError - // (transient), never an unsupported-daemon latch. - logger.Warn("ipc: analytics reply exceeds frame limit", "op", req.Op) - err = control.WriteMsg(conn, TypeAnalyticsResp, AnalyticsResp{Err: "reply exceeds frame limit"}) - } - default: - logger.Debug("ipc: ignoring unknown request", "type", env.Type) - continue - } - if err != nil { - return - } - } -} +func isListenerClosed(err error) bool { return errors.Is(err, winio.ErrPipeListenerClosed) } diff --git a/internal/ipc/stub_other.go b/internal/ipc/stub_other.go deleted file mode 100644 index 3c19de3..0000000 --- a/internal/ipc/stub_other.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build !windows - -package ipc - -import ( - "context" - "encoding/json" - "log/slog" - "time" - - "proxyforward/internal/stats" -) - -func Serve(ctx context.Context, logger *slog.Logger, src Sources) error { - return ErrUnsupported -} - -type Client struct{} - -func Dial(timeout time.Duration) (*Client, error) { return nil, ErrUnsupported } - -func (c *Client) Close() error { return ErrUnsupported } -func (c *Client) Ping() error { return ErrUnsupported } -func (c *Client) Status() (*Status, error) { return nil, ErrUnsupported } -func (c *Client) History(windowMs int64, maxBuckets int) (*stats.HistoryResult, error) { - return nil, ErrUnsupported -} -func (c *Client) Peers() ([]stats.PeerStat, error) { return nil, ErrUnsupported } -func (c *Client) Analytics(op string, body json.RawMessage) (json.RawMessage, error) { - return nil, ErrUnsupported -} diff --git a/main.go b/main.go index 37242e3..e20bce2 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "embed" "fmt" "log/slog" "os" @@ -14,13 +13,7 @@ import ( "time" "github.com/spf13/cobra" - "github.com/wailsapp/wails/v2" - wailslogger "github.com/wailsapp/wails/v2/pkg/logger" - "github.com/wailsapp/wails/v2/pkg/options" - "github.com/wailsapp/wails/v2/pkg/options/assetserver" - wailswin "github.com/wailsapp/wails/v2/pkg/options/windows" - "proxyforward/app" "proxyforward/internal/config" "proxyforward/internal/engine" "proxyforward/internal/link" @@ -30,9 +23,6 @@ import ( "proxyforward/internal/wincon" ) -//go:embed all:frontend/dist -var assets embed.FS - func main() { // Cobra's Windows "mousetrap" exits any Explorer-launched process after a // 5s sleep, assuming a CLI was double-clicked by mistake. This app IS the @@ -113,6 +103,13 @@ func newRootCmd(deepLink string) *cobra.Command { return root } +// configFlagUsage is the --config help text. It names the real per-user default +// (config.DefaultPath): %APPDATA%\proxyforward on Windows, ~/.config/proxyforward +// on Linux, where these headless commands are the only way to run. +func configFlagUsage() string { + return "path to config.toml (default: " + config.DefaultPath(false) + ")" +} + // newPairCmd wires an agent to a gateway from a pasted pairing code — the // headless counterpart of the GUI wizard. func newPairCmd() *cobra.Command { @@ -168,7 +165,7 @@ func newPairCmd() *cobra.Command { return nil }, } - cmd.Flags().StringVar(&configPath, "config", "", "path to config.toml (default: %APPDATA%\\proxyforward\\config.toml)") + cmd.Flags().StringVar(&configPath, "config", "", configFlagUsage()) cmd.Flags().StringVar(&localAddr, "local", "127.0.0.1:25565", "local Minecraft server address for the default tunnel") cmd.Flags().IntVar(&publicPort, "public-port", config.DefaultPublicPort, "public port to request on the gateway for the default tunnel") return cmd @@ -248,7 +245,7 @@ func newRoleCmd(role config.Role, short string) *cobra.Command { return runEngineWithIPC(ctx, cfg, configDir, configPath, logger) }, } - cmd.Flags().StringVar(&configPath, "config", "", "path to config.toml (default: %APPDATA%\\proxyforward\\config.toml)") + cmd.Flags().StringVar(&configPath, "config", "", configFlagUsage()) cmd.Flags().StringVar(&logLevel, "log-level", "", "override log level (debug|info|warn|error)") cmd.Flags().BoolVar(&headless, "headless", true, "run without GUI (always true for this subcommand)") return cmd @@ -417,83 +414,3 @@ func newFirewallCmd() *cobra.Command { } return cmd } - -func runGUI(deepLink string) error { - configPath := config.DefaultPath(false) - cfg, err := config.Load(configPath) - if err != nil { - // The GUI must still open on a broken config so the user can fix it; - // start from defaults and surface the load error in the UI later. - cfg = config.Default() - } - ring := logging.NewRing(2000) - var filePath string - if cfg.Logging.FileEnabled { - filePath = logging.DefaultFilePath(config.DefaultDir(false)) - } - logger, closeLogs, lerr := logging.New(logging.Options{ - Level: cfg.Logging.Level, - FilePath: filePath, - Ring: ring, - }) - if lerr != nil { - return lerr - } - defer closeLogs() - if err != nil { - logger.Error("config failed to load; using defaults", "err", err) - } - - a := app.New(configPath, cfg, ring, logger) - // A cold start via the OS protocol handler: the frontend isn't listening yet, - // so stash the link now for it to pull once it mounts (App.TakePendingDeepLink). - if deepLink != "" { - a.HandleDeepLink(deepLink) - } - // Wails reports webview/runtime failures through its own logger and can - // os.Exit without returning an error; in a windowsgui process that output - // is invisible, so route it to a file next to the crash log. - wailsLog := wailslogger.NewFileLogger(filepath.Join(config.DefaultDir(false), "logs", "wails.log")) - return wails.Run(&options.App{ - Title: "proxyforward", - Width: 1360, - Height: 820, - MinWidth: 1280, - MinHeight: 760, - // One GUI window owns the pairing flow: a second launch (e.g. clicking - // another pxf:// link) is forwarded here and exits, so the link always - // lands in the running window instead of opening a duplicate. - SingleInstanceLock: &options.SingleInstanceLock{ - UniqueId: "proxyforward-gui-single-instance", - OnSecondInstanceLaunch: func(data options.SecondInstanceData) { - a.OnSecondInstance(deepLinkArg(data.Args)) - }, - }, - // The window is frameless: the frontend draws its own title bar and - // window controls. DWM decorations stay on (default) so the frameless - // window keeps its drop shadow, rounded corners and Snap Layouts. - Frameless: true, - // The avatar handler serves /pf/avatar/ for paths the bundled assets - // don't cover (player heads are fetched and cached at runtime). - AssetServer: &assetserver.Options{Assets: assets, Handler: a.AvatarHandler()}, - BackgroundColour: startupBackground(cfg.UI.Theme), - Windows: &wailswin.Options{ - Theme: wailswin.SystemDefault, - }, - OnStartup: a.Startup, - OnShutdown: a.Shutdown, - Bind: []interface{}{a}, - Logger: wailsLog, - LogLevelProduction: wailslogger.ERROR, - }) -} - -// startupBackground picks the pre-paint window colour so the first frame (and -// resize flash) matches the theme the frontend is about to render. "system" -// resolves in the frontend; dark is the safer first frame for it here. -func startupBackground(theme string) *options.RGBA { - if theme == "light" { - return &options.RGBA{R: 236, G: 238, B: 246, A: 1} - } - return &options.RGBA{R: 11, G: 13, B: 19, A: 1} -} diff --git a/window.go b/window.go new file mode 100644 index 0000000..649106a --- /dev/null +++ b/window.go @@ -0,0 +1,78 @@ +//go:build !linux || bindings + +package main + +import ( + "context" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// Window geometry, in logical (DPI-scaled) pixels — the units Wails sizes +// windows and reports screens in. +const ( + // windowWidth/Height is the design size the window opens at when it fits. + windowWidth, windowHeight = 1360, 820 + // windowMinWidth/Height is the floor the layout still works at: the UI has + // a narrow step below 1150 px (tokens.css --ui-scale) and every screen + // scrolls. It must stay below common small work areas — a 1080p display + // at 150% is 1280×720 logical, ~1280×672 above the taskbar — or the OS + // cannot shrink the window to fit and it overflows the screen. + windowMinWidth, windowMinHeight = 1024, 640 + // screenMarginW/H is what fitting leaves around the window. Wails reports + // the whole monitor, not its work area, so the vertical margin covers a + // bottom taskbar/panel (48 px on Windows 11): centred, the window then + // ends exactly above it with its title bar — the frameless window's only + // drag handle and caption buttons — on-screen. + screenMarginW, screenMarginH = 32, 96 +) + +// fitWindowSize picks the startup window size for a screen of the given +// logical size. ok=false means the design size already fits (leave it); +// maximise=true means even the floor would not fit with margins, so the +// window should take the work area instead. +func fitWindowSize(screenW, screenH int) (w, h int, maximise, ok bool) { + if screenW <= 0 || screenH <= 0 { + return 0, 0, false, false // unknown screen: keep the design size + } + w = min(windowWidth, screenW-screenMarginW) + h = min(windowHeight, screenH-screenMarginH) + if w == windowWidth && h == windowHeight { + return 0, 0, false, false + } + if w < windowMinWidth || h < windowMinHeight { + return 0, 0, true, true + } + return w, h, false, true +} + +// fitWindowToScreen shrinks the window to the monitor it opened on when the +// design size does not fit. Without it, a screen smaller than the window +// (e.g. 1080p at 150%) gets a window overflowing every edge, with the title +// bar — and with it every way to move or restore the window — off-screen. +// Runs from OnStartup, before the page has loaded. +func fitWindowToScreen(ctx context.Context) { + screens, err := wailsruntime.ScreenGetAll(ctx) + if err != nil || len(screens) == 0 { + return + } + scr := screens[0] + for _, s := range screens { + if s.IsCurrent { + scr = s + break + } + if s.IsPrimary { + scr = s + } + } + w, h, maximise, ok := fitWindowSize(scr.Size.Width, scr.Size.Height) + switch { + case !ok: + case maximise: + wailsruntime.WindowMaximise(ctx) + default: + wailsruntime.WindowSetSize(ctx, w, h) + wailsruntime.WindowCenter(ctx) + } +} diff --git a/window_test.go b/window_test.go new file mode 100644 index 0000000..95656b0 --- /dev/null +++ b/window_test.go @@ -0,0 +1,36 @@ +//go:build !linux + +package main + +import "testing" + +// TestFitWindowSize pins the startup fit against real screens (logical px). An +// oversized window on a small screen overflowed every edge with the title bar +// off-screen, so the window could neither be moved nor restored. +func TestFitWindowSize(t *testing.T) { + cases := []struct { + name string + sw, sh int + w, h int + maximise, fits bool + }{ + {"1080p at 100% keeps the design size", 1920, 1080, 0, 0, false, false}, + {"1440p at 125% keeps the design size", 2048, 1152, 0, 0, false, false}, + {"1080p at 125% loses height to the taskbar", 1536, 864, 1360, 768, false, true}, + {"1366x768 laptop shrinks both ways", 1366, 768, 1334, 672, false, true}, + {"1080p at 150% is below the floor: maximise", 1280, 720, 0, 0, true, true}, + {"unknown screen keeps the design size", 0, 0, 0, 0, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w, h, maximise, ok := fitWindowSize(tc.sw, tc.sh) + if ok != tc.fits || maximise != tc.maximise || w != tc.w || h != tc.h { + t.Fatalf("fitWindowSize(%d, %d) = (%d, %d, maximise=%v, ok=%v), want (%d, %d, maximise=%v, ok=%v)", + tc.sw, tc.sh, w, h, maximise, ok, tc.w, tc.h, tc.maximise, tc.fits) + } + if ok && !maximise && (w > tc.sw || h > tc.sh || w < windowMinWidth || h < windowMinHeight) { + t.Fatalf("fitted %dx%d escapes the screen %dx%d or the layout floor", w, h, tc.sw, tc.sh) + } + }) + } +}