From c63ea67818fe3a9a43074eb3bbebc38bc04bc483 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:19:01 +1300 Subject: [PATCH 01/16] ipc: serve over a unix socket off windows so the engine runs on linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off Windows, Serve returned ErrUnsupported; engine.Run treated that as a clean first exit and cancelled everything, so the window opened and the engine silently stopped. The request loop and client move verbatim into untagged server.go and client.go; only the endpoint is per-platform. Windows keeps the winio pipe under pipeSecurity. Elsewhere the endpoint is $XDG_RUNTIME_DIR/proxyforward/ ipc.sock in a checked 0700 directory, the socket is 0600, and on linux each peer's uid must be ours or root (SO_PEERCRED) — the same principals the pipe ACL admits, since the pipe carries gateway admin ops. A socket path does not refuse a second listener the way a pipe name does, so listen holds an flock for the listener's lifetime: a live engine makes a second one fatal, and a crashed one's stale socket is safely replaced. Tests take their endpoint from PrivatePipeName, so the existing pipe tests run unchanged on both platforms; engine_test now takes a private one too. New unix tests cover socket/dir modes, stale-socket takeover, and refusing a loosened directory. Docs and the Reality-check row say what still is Windows-only. --- .claude/rules/go-tests.md | 8 +- .github/workflows/ci.yml | 19 ++- .github/workflows/release.yml | 4 +- CLAUDE.md | 18 ++- README.md | 9 +- app/setup_test.go | 3 +- docs/agent/architecture.md | 2 +- docs/agent/commands.md | 39 ++++- docs/agent/polish-backlog.md | 10 +- internal/e2e/attach_test.go | 4 +- internal/engine/engine.go | 6 +- internal/engine/engine_test.go | 12 +- internal/ipc/client.go | 91 +++++++++++ internal/ipc/client_windows.go | 85 +--------- internal/ipc/ipc.go | 23 ++- .../ipc/{ipc_windows_test.go => ipc_test.go} | 6 +- internal/ipc/ipc_unix.go | 146 +++++++++++++++++ internal/ipc/ipc_unix_test.go | 148 ++++++++++++++++++ internal/ipc/peercred_linux.go | 31 ++++ internal/ipc/peercred_other.go | 10 ++ internal/ipc/server.go | 136 ++++++++++++++++ internal/ipc/server_windows.go | 140 ++--------------- internal/ipc/stub_other.go | 31 ---- 23 files changed, 681 insertions(+), 300 deletions(-) create mode 100644 internal/ipc/client.go rename internal/ipc/{ipc_windows_test.go => ipc_test.go} (98%) create mode 100644 internal/ipc/ipc_unix.go create mode 100644 internal/ipc/ipc_unix_test.go create mode 100644 internal/ipc/peercred_linux.go create mode 100644 internal/ipc/peercred_other.go create mode 100644 internal/ipc/server.go delete mode 100644 internal/ipc/stub_other.go 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..6418df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,10 +9,10 @@ # 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). The linux/macos build jobs prove the tree +# still compiles elsewhere; their binaries are artifacts, NOT release assets — +# see the Reality check in CLAUDE.md. name: ci on: @@ -163,9 +163,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,9 +299,9 @@ 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. build-linux: needs: frontend runs-on: ubuntu-latest 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/CLAUDE.md b/CLAUDE.md index 15b51be..e73b999 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | The engine and GUI **run** on Linux (unix-socket IPC, `ipc/ipc_unix.go`; build + toolbox recipe in `docs/agent/commands.md`), but there is **no packaged build** — CI artifacts only, never release assets — and the Windows integrations stay Windows-only: `service`/`firewall` commands and the Settings → System buttons answer `svc.ErrUnsupported` (`svc/stub_other.go`), `pxf://` links aren't registered, and the copy still says "Windows Firewall". macOS compiles and should behave alike, 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 diff --git a/README.md b/README.md index c16f6a0..c2f5c62 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,10 @@ 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, GUI included (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 +342,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. diff --git a/app/setup_test.go b/app/setup_test.go index a94ba99..fc3159e 100644 --- a/app/setup_test.go +++ b/app/setup_test.go @@ -2,7 +2,6 @@ package app import ( "context" - "fmt" "io" "log/slog" "os" @@ -19,7 +18,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. 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..89311de 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -23,6 +23,37 @@ wails build -ldflags "-X proxyforward/internal/version.Version=vX.Y.Z -X proxyfo build/bin/proxyforward.exe --version # verify the stamp ``` +## Linux + +The engine and GUI run on Linux; the IPC is a unix socket at +`$XDG_RUNTIME_DIR/proxyforward/ipc.sock`. The GUI needs gtk3 + webkit2gtk-4.1 (dev +packages to build, runtime libraries to run). On an atomic distro (Silverblue/Kinoite) +keep both in a toolbox rather than layering them onto the host — the toolbox shares +`$HOME`, the host network, and `$XDG_RUNTIME_DIR`, so ports bind on the host and the +config lands in the usual `~/.config/proxyforward`: + +``` +toolbox create proxyforward +toolbox run -c proxyforward sudo dnf install -y golang nodejs npm gcc \ + pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel +go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0 # inside the toolbox +wails build -platform linux/amd64 -tags webkit2_41 -s -skipbindings +toolbox run -c proxyforward build/bin/proxyforward # run the GUI +``` + +- `-tags webkit2_41` is required: Wails 2.13 links webkit2gtk-4.0 unless told + otherwise, and current distros ship only 4.1. +- `-skipbindings` keeps `frontend/wailsjs` untouched (CI's stale-bindings check is + the Windows build's job). +- A blank or black window on NVIDIA is WebKitGTK's DMA-BUF renderer: run with + `WEBKIT_DISABLE_DMABUF_RENDERER=1`. +- `wails build -platform windows/amd64` cross-compiles the Windows exe from Linux + (no cgo on that target). +- 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 @@ -48,10 +79,10 @@ Private configs keep your real setup untouched (`docs/agent/reasoning.md` step 0 `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. +- `GOOS=linux` and `GOOS=darwin` builds compile (CI builds both) and Linux runs — + see "Linux" above. 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..ad12305 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, but nothing about it is packaged or polished** — the engine and GUI + work over the unix-socket IPC (`internal/ipc/ipc_unix.go`), but: no release asset + or `.desktop`/`pxf://` registration; Settings → System still offers "Add rule" and + "Install" (both answer `svc.ErrUnsupported`) and the copy says "Windows"; 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/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/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 -} From fbef8f0ff8f57b6d79ebf39aec57f08761ee9427 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:19:27 +1300 Subject: [PATCH 02/16] ci: run go vet and go test on linux The unix-socket IPC and every other !windows path only execute off Windows, and every Go test job ran on windows-latest, so nothing in CI exercised them. test-linux runs vet and go test -short on ubuntu-latest. --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++++--- CLAUDE.md | 6 +++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6418df4..aaa3b21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,10 @@ # runners check out CRLF by default, which makes gofmt reject every file. # # The Go jobs run on Windows because Windows is the only shipped platform -# (named-pipe IPC, service, firewall). The linux/macos build jobs prove the tree -# still compiles elsewhere; their binaries are artifacts, NOT release assets — -# see the Reality check in CLAUDE.md. +# (named-pipe IPC, service, firewall). `test-linux` runs the suite on ubuntu so +# the unix-socket IPC is exercised too; the linux/macos build jobs prove the +# tree still compiles elsewhere; their binaries are artifacts, NOT release +# assets — see the Reality check in CLAUDE.md. name: ci on: @@ -88,6 +89,28 @@ 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: + needs: frontend + 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 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: frontend-dist + path: frontend/dist + - run: go vet ./... + - run: go test -short ./... + race: needs: frontend runs-on: windows-latest diff --git a/CLAUDE.md b/CLAUDE.md index e73b999..09b3540 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,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 From 7a9eeb68aba0c04bd0cfb30c0bdf880e11dbadb4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:33:05 +1300 Subject: [PATCH 03/16] gui: composite the webview on the gpu under linux With no Linux options, Wails sets WebKitGTK's hardware acceleration policy to never, so every frame was painted on the CPU: the glass blurs pinned a core and the window ran at a few fps. Pass WebviewGpuPolicyAlways, and ProgramName so the Wayland app id matches proxyforward.desktop. Once composited on the GPU, NVIDIA's EGL Wayland layer enables explicit sync on the window surface, GTK3 commits without the acquire point, and the compositor ends the session with Wayland error 71 ("explicit sync is used, but no acquire point is set"). linuxGraphicsDefaults sets __NV_DISABLE_EXPLICIT_SYNC=1 before GTK starts unless the user set it. --- docs/agent/commands.md | 9 +++++++-- main.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/agent/commands.md b/docs/agent/commands.md index 89311de..0dadbba 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -45,8 +45,13 @@ toolbox run -c proxyforward build/bin/proxyforward # run the GUI otherwise, and current distros ship only 4.1. - `-skipbindings` keeps `frontend/wailsjs` untouched (CI's stale-bindings check is the Windows build's job). -- A blank or black window on NVIDIA is WebKitGTK's DMA-BUF renderer: run with - `WEBKIT_DISABLE_DMABUF_RENDERER=1`. +- Rendering is GPU-composited because `main.go runGUI` passes Linux options: + without them Wails defaults WebKitGTK to `WebviewGpuPolicyNever` (CPU painting, + a few fps under the glass blurs). On NVIDIA + Wayland, `linuxGraphicsDefaults` + sets `__NV_DISABLE_EXPLICIT_SYNC=1`, or the first GPU frame ends the session with + Wayland error 71 ("explicit sync is used, but no acquire point is set" — confirm + with `WAYLAND_DEBUG=1`). Don't reach for `WEBKIT_DISABLE_DMABUF_RENDERER=1`: it + doesn't fix that and costs the zero-copy path. - `wails build -platform windows/amd64` cross-compiles the Windows exe from Linux (no cgo on that target). - Go newer than the Wails CLI's `golang.org/x/tools` breaks `wails build` with diff --git a/main.go b/main.go index 37242e3..69408e7 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "runtime/debug" "strings" "syscall" @@ -18,6 +19,7 @@ import ( wailslogger "github.com/wailsapp/wails/v2/pkg/logger" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + wailslinux "github.com/wailsapp/wails/v2/pkg/options/linux" wailswin "github.com/wailsapp/wails/v2/pkg/options/windows" "proxyforward/app" @@ -419,6 +421,7 @@ func newFirewallCmd() *cobra.Command { } func runGUI(deepLink string) error { + linuxGraphicsDefaults() configPath := config.DefaultPath(false) cfg, err := config.Load(configPath) if err != nil { @@ -480,6 +483,15 @@ func runGUI(deepLink string) error { Windows: &wailswin.Options{ Theme: wailswin.SystemDefault, }, + // Without Linux options Wails defaults WebKitGTK to + // WebviewGpuPolicyNever: every frame is painted on the CPU, and the + // glass surfaces' backdrop blurs then pin a core at a few fps. Always + // keeps compositing on the GPU. ProgramName becomes the Wayland app + // id, so the desktop shell matches the window to proxyforward.desktop. + Linux: &wailslinux.Options{ + WebviewGpuPolicy: wailslinux.WebviewGpuPolicyAlways, + ProgramName: "proxyforward", + }, OnStartup: a.Startup, OnShutdown: a.Shutdown, Bind: []interface{}{a}, @@ -488,6 +500,22 @@ func runGUI(deepLink string) error { }) } +// linuxGraphicsDefaults works around NVIDIA's EGL Wayland layer turning on +// explicit sync for the window surface: GTK3 then commits frames without the +// acquire point it promised, and the compositor drops the connection ("explicit +// sync is used, but no acquire point is set", Wayland error 71) the moment the +// GPU-composited webview paints. It must run before GTK/EGL initialise, and it +// only fills in an unset variable, so a user's explicit value wins. Drivers +// other than NVIDIA's never read it. +func linuxGraphicsDefaults() { + if runtime.GOOS != "linux" { + return + } + if _, set := os.LookupEnv("__NV_DISABLE_EXPLICIT_SYNC"); !set { + os.Setenv("__NV_DISABLE_EXPLICIT_SYNC", "1") + } +} + // 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. From 385571f0ad3ecfed371095bedd2501134273d806 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:33:05 +1300 Subject: [PATCH 04/16] theme: set the native window background on windows only The pre-paint colour exists for WebView2's resize flash. On Linux, Wails routes WindowSetBackgroundColour into WebKitGTK and GTK CSS on the GTK thread, and switching theme segfaulted the app there. theme.ts now asks the runtime for the platform once and only makes the call on Windows, syncing as soon as the platform is known. The colour's alpha was 1: Wails' Windows backend rounds any non-zero alpha up to 255, but WebKitGTK reads it as a real alpha, which made the Linux webview background almost transparent. Use 255 in both places; Windows behaviour is unchanged. --- frontend/src/devmock.ts | 1 + frontend/src/theme.ts | 37 +++++++++++++++++++++++++++++-------- main.go | 8 +++++--- 3 files changed, 35 insertions(+), 11 deletions(-) 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/theme.ts b/frontend/src/theme.ts index a408789..c671c74 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -1,6 +1,6 @@ import {useEffect, useState} from 'react' import {SetTheme} from '../wailsjs/go/app/App' -import {WindowSetBackgroundColour} from '../wailsjs/runtime/runtime' +import {Environment, WindowSetBackgroundColour} from '../wailsjs/runtime/runtime' import {prefersReduced} from './motion' /** @@ -26,17 +26,38 @@ 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). +let nativeBackground = false +try { + Environment().then(env => { + nativeBackground = env.platform === 'windows' + syncNativeBackground(resolvedTheme()) + }, () => {}) +} catch { + /* plain browser (vite dev) */ +} + +function syncNativeBackground(t: ResolvedTheme) { + if (!nativeBackground) 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/main.go b/main.go index 69408e7..de1ebb7 100644 --- a/main.go +++ b/main.go @@ -518,10 +518,12 @@ func linuxGraphicsDefaults() { // 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. +// resolves in the frontend; dark is the safer first frame for it here. Alpha +// is a real 0-255 alpha on Linux (WebKitGTK), so it must be 255 there; 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: 1} + return &options.RGBA{R: 236, G: 238, B: 246, A: 255} } - return &options.RGBA{R: 11, G: 13, B: 19, A: 1} + return &options.RGBA{R: 11, G: 13, B: 19, A: 255} } From 52662fa91708cd36a34eab72ffc6c7914c4c6412 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:00:41 +1300 Subject: [PATCH 05/16] gui: one large webkit tile per layer under linux WebKit tiles large GPU layers at 512 px. The ambient aurora is a viewport-sized layer drifting by sub-pixel translate and scale; with the 2x buffers GTK3 uses under fractional scaling, every tile edge resampled differently each frame, showing a flickering seam grid on the dark theme. linuxGraphicsDefaults now also sets WEBKIT_LAYERS_TILE_SIZE=4096x4096 unless the user set it, so one tile covers the layer at normal sizes. --- docs/agent/commands.md | 4 +++- main.go | 30 +++++++++++++++++++++--------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/docs/agent/commands.md b/docs/agent/commands.md index 0dadbba..44586a5 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -51,7 +51,9 @@ toolbox run -c proxyforward build/bin/proxyforward # run the GUI sets `__NV_DISABLE_EXPLICIT_SYNC=1`, or the first GPU frame ends the session with Wayland error 71 ("explicit sync is used, but no acquire point is set" — confirm with `WAYLAND_DEBUG=1`). Don't reach for `WEBKIT_DISABLE_DMABUF_RENDERER=1`: it - doesn't fix that and costs the zero-copy path. + doesn't fix that and costs the zero-copy path. It also raises WebKit's layer tile + size (`WEBKIT_LAYERS_TILE_SIZE`): at the default 512 px the drifting aurora layer + shows a flickering grid of tile seams under fractional scaling. - `wails build -platform windows/amd64` cross-compiles the Windows exe from Linux (no cgo on that target). - Go newer than the Wails CLI's `golang.org/x/tools` breaks `wails build` with diff --git a/main.go b/main.go index de1ebb7..706aa51 100644 --- a/main.go +++ b/main.go @@ -500,19 +500,31 @@ func runGUI(deepLink string) error { }) } -// linuxGraphicsDefaults works around NVIDIA's EGL Wayland layer turning on -// explicit sync for the window surface: GTK3 then commits frames without the -// acquire point it promised, and the compositor drops the connection ("explicit -// sync is used, but no acquire point is set", Wayland error 71) the moment the -// GPU-composited webview paints. It must run before GTK/EGL initialise, and it -// only fills in an unset variable, so a user's explicit value wins. Drivers -// other than NVIDIA's never read it. +// linuxGraphicsDefaults sets the environment the GPU-composited webview needs +// on Linux. It must run before GTK/EGL initialise (the web process inherits +// it), and it only fills in unset variables, so a user's explicit value wins. func linuxGraphicsDefaults() { if runtime.GOOS != "linux" { return } - if _, set := os.LookupEnv("__NV_DISABLE_EXPLICIT_SYNC"); !set { - os.Setenv("__NV_DISABLE_EXPLICIT_SYNC", "1") + for _, kv := range [...]struct{ key, val string }{ + // NVIDIA's EGL Wayland layer turns on explicit sync for the window + // surface; GTK3 then commits frames without the acquire point it + // promised, and the compositor drops the connection ("explicit sync + // is used, but no acquire point is set", Wayland error 71) the moment + // the webview paints. Other drivers never read it. + {"__NV_DISABLE_EXPLICIT_SYNC", "1"}, + // WebKit tiles large GPU layers (512 px by default). The ambient + // aurora is a viewport-sized layer drifting by sub-pixel translate and + // scale, and with the 2x buffers GTK3 uses under fractional scaling + // every tile edge resamples differently each frame: a flickering seam + // grid, plain on the dark theme. One tile covers the whole layer at + // normal window sizes. + {"WEBKIT_LAYERS_TILE_SIZE", "4096x4096"}, + } { + if _, set := os.LookupEnv(kv.key); !set { + os.Setenv(kv.key, kv.val) + } } } From 3f6a724e70433c60df870872ac2507b6d5ccdf1d Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:02:21 +1300 Subject: [PATCH 06/16] config: accept an enrollment ticket as the agent's pairing credential The gateway wizard hands out a tkt_ enrollment ticket by default, and SetupAgent routes it to agent.enroll_ticket with agent.token empty. Validate still required agent.token, so every ticket pairing in the GUI failed with "refusing to save invalid config: agent.token: required". Only the legacy shared-token code could pair. An agent is paired when it holds either credential. The ticket stays in the config after enrollment (the gateway authenticates an allowlisted key first and ignores a spent ticket), so the field comment claiming the agent clears it is corrected. TestSetupAgentWithEnrollmentTicket drives the wizard path. --- app/setup_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/config/config.go | 12 ++++++++---- internal/config/config_test.go | 4 ++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/app/setup_test.go b/app/setup_test.go index fc3159e..3f9a0d0 100644 --- a/app/setup_test.go +++ b/app/setup_test.go @@ -11,6 +11,7 @@ import ( "proxyforward/internal/config" "proxyforward/internal/ipc" + "proxyforward/internal/link" "proxyforward/internal/setup" ) @@ -114,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/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 }, From 265bdb9da7f895a0ae38f652d13bed0ba56d0ac4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:31:45 +1300 Subject: [PATCH 07/16] gui: fit the window to the screen it opens on The window opened at a fixed 1360x820 with a 1280x760 minimum, in logical pixels. A 1080p display at 150% is 1280x720 logical (~672 above the taskbar), so the window could not fit: it overflowed every edge, centred, with the title bar off-screen, and the minimum stopped the OS from ever shrinking it. A frameless window's title bar is its only drag handle and caption buttons, so there was no way to move, restore, or leave it. The minimum drops to 1024x640 (the layout already has a narrow step below 1150 px and every screen scrolls). At startup, before the page loads, the window is shrunk and centred to the monitor's size less room for the taskbar, or maximised when even the floor would not fit. fitWindowSize is pure and pinned against common screens in TestFitWindowSize. --- main.go | 13 +++++---- main_test.go | 31 +++++++++++++++++++++ window.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 window.go diff --git a/main.go b/main.go index 706aa51..aaff8b5 100644 --- a/main.go +++ b/main.go @@ -459,10 +459,10 @@ func runGUI(deepLink string) error { 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, + 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. @@ -492,7 +492,10 @@ func runGUI(deepLink string) error { WebviewGpuPolicy: wailslinux.WebviewGpuPolicyAlways, ProgramName: "proxyforward", }, - OnStartup: a.Startup, + OnStartup: func(ctx context.Context) { + fitWindowToScreen(ctx) // before the page loads, so it never shows oversized + a.Startup(ctx) + }, OnShutdown: a.Shutdown, Bind: []interface{}{a}, Logger: wailsLog, diff --git a/main_test.go b/main_test.go index 49b4cad..0ad7d99 100644 --- a/main_test.go +++ b/main_test.go @@ -22,3 +22,34 @@ func TestDeepLinkArg(t *testing.T) { t.Errorf("no arguments is not a deep link, got %q", got) } } + +// 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) + } + }) + } +} diff --git a/window.go b/window.go new file mode 100644 index 0000000..32affcd --- /dev/null +++ b/window.go @@ -0,0 +1,76 @@ +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) + } +} From 79b3e3b4798ec5c2018e3da73e2fd137785181dc Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:50:09 +1300 Subject: [PATCH 08/16] gui: paint with skia's cpu rasteriser on nvidia under linux On NVIDIA's driver, WebKit's web process segfaulted inside libnvidia-eglcore from its Skia GPU painting threads, 3-14 minutes into a session (four core dumps), and a dead web process takes the whole app down with it. When the NVIDIA driver is loaded, linuxGraphicsDefaults now sets WEBKIT_SKIA_ENABLE_CPU_RENDERING=1 unless the user set it: page content is rasterised on the CPU while compositing (transform and opacity animation, backdrop blur) stays on the GPU. Mesa drivers keep GPU painting. --- main.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/main.go b/main.go index aaff8b5..bdf88c1 100644 --- a/main.go +++ b/main.go @@ -529,6 +529,17 @@ func linuxGraphicsDefaults() { os.Setenv(kv.key, kv.val) } } + // On NVIDIA's driver, WebKit's web process segfaults inside + // libnvidia-eglcore from its Skia GPU painting threads, minutes into a + // session — and a dead web process takes the whole app down. Paint page + // content with Skia's CPU rasteriser instead; compositing (transform and + // opacity animation, backdrop blur) stays on the GPU, so motion stays + // smooth. Mesa drivers keep GPU painting. + if _, err := os.Stat("/proc/driver/nvidia/version"); err == nil { + if _, set := os.LookupEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING"); !set { + os.Setenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", "1") + } + } } // startupBackground picks the pre-paint window colour so the first frame (and From 0ccb33df25a3e9337c60d3ccabf102bf06fc0ec9 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:50:09 +1300 Subject: [PATCH 09/16] ui: copy through webkit on linux Every Copy button on Linux silently cleared the clipboard. Wails' ClipboardSetText writes PRIMARY right after CLIPBOARD, and under GTK3 on Wayland that second write empties CLIPBOARD (reproduced with a bare GTK3 probe: CLIPBOARD alone sticks, CLIPBOARD then PRIMARY leaves it empty), yet it reports success, so copyText's fallback never ran. On Linux copyText now copies through WebKit itself (execCommand on a throwaway selection, synchronously inside the click), which writes CLIPBOARD only; navigator.clipboard is the fallback. Windows keeps the Wails runtime path. The host platform moves into platform.ts so the theme's Windows-only background sync and the clipboard read one source. --- frontend/src/components/ui.tsx | 39 +++++++++++++++++++++++++++++++--- frontend/src/platform.ts | 31 +++++++++++++++++++++++++++ frontend/src/theme.ts | 15 ++++--------- 3 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 frontend/src/platform.ts 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/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 c671c74..4d87aa6 100644 --- a/frontend/src/theme.ts +++ b/frontend/src/theme.ts @@ -1,7 +1,8 @@ import {useEffect, useState} from 'react' import {SetTheme} from '../wailsjs/go/app/App' -import {Environment, WindowSetBackgroundColour} from '../wailsjs/runtime/runtime' +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). @@ -32,18 +33,10 @@ export function resolvedTheme(): ResolvedTheme { // 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). -let nativeBackground = false -try { - Environment().then(env => { - nativeBackground = env.platform === 'windows' - syncNativeBackground(resolvedTheme()) - }, () => {}) -} catch { - /* plain browser (vite dev) */ -} +onHostPlatform(() => syncNativeBackground(resolvedTheme())) function syncNativeBackground(t: ResolvedTheme) { - if (!nativeBackground) return + if (hostPlatform() !== 'windows') return try { if (t === 'light') WindowSetBackgroundColour(236, 238, 246, 255) else WindowSetBackgroundColour(11, 13, 19, 255) From 8573d3071a314a17657a9e9ff77419b0dcfac8d4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:53:54 +1300 Subject: [PATCH 10/16] gateway: don't print a shared-token code when that path is off RunStarted always printed the legacy shared-token pairing code on the console. With accept_shared_token off the validator refuses that code, so a headless operator was shown a secret that cannot pair anything. When shared tokens are off it now says agents join with an enrollment ticket instead. --- internal/gateway/gateway.go | 10 ++++++++++ 1 file changed, 10 insertions(+) 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, From 2f36e1f0d14e174e716cbf5e3b0a0ffe816a5714 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:32:30 +1300 Subject: [PATCH 11/16] linux: run headless only, drop the gui The Linux GUI needed per-driver WebKitGTK workarounds just to paint (GPU policy, NVIDIA explicit sync, Skia CPU raster, layer tile size). Linux now runs the gateway/pair/agent commands only, and a bare launch says which to run instead of opening a window. runGUI and the frontend/dist embed move to gui.go (!linux), along with window.go and its test; gui_linux.go answers a bare launch with errNoGUI. A linux build therefore pulls in no Wails and no cgo: one static binary that compiles without the frontend. The Linux webview options and linuxGraphicsDefaults go. Windows and macOS are unchanged. --- CLAUDE.md | 12 +-- README.md | 8 +- docs/agent/commands.md | 54 +++++-------- docs/agent/polish-backlog.md | 12 +-- gui.go | 112 +++++++++++++++++++++++++++ gui_linux.go | 18 +++++ main.go | 146 ----------------------------------- main_test.go | 31 -------- window.go | 2 + window_test.go | 36 +++++++++ 10 files changed, 206 insertions(+), 225 deletions(-) create mode 100644 gui.go create mode 100644 gui_linux.go create mode 100644 window_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 09b3540..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 @@ -204,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 support | The engine and GUI **run** on Linux (unix-socket IPC, `ipc/ipc_unix.go`; build + toolbox recipe in `docs/agent/commands.md`), but there is **no packaged build** — CI artifacts only, never release assets — and the Windows integrations stay Windows-only: `service`/`firewall` commands and the Settings → System buttons answer `svc.ErrUnsupported` (`svc/stub_other.go`), `pxf://` links aren't registered, and the copy still says "Windows Firewall". macOS compiles and should behave alike, but has never been run. | +| 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 @@ -252,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 c2f5c62..779e34c 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ 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 packaged Linux or macOS build. Linux -builds from source and runs both roles, GUI included (recipe in -[`docs/agent/commands.md`](docs/agent/commands.md)); the Windows service and firewall +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 @@ -448,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/docs/agent/commands.md b/docs/agent/commands.md index 44586a5..af35ae2 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -25,37 +25,24 @@ build/bin/proxyforward.exe --version # verify the stamp ## Linux -The engine and GUI run on Linux; the IPC is a unix socket at -`$XDG_RUNTIME_DIR/proxyforward/ipc.sock`. The GUI needs gtk3 + webkit2gtk-4.1 (dev -packages to build, runtime libraries to run). On an atomic distro (Silverblue/Kinoite) -keep both in a toolbox rather than layering them onto the host — the toolbox shares -`$HOME`, the host network, and `$XDG_RUNTIME_DIR`, so ports bind on the host and the -config lands in the usual `~/.config/proxyforward`: +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. ``` -toolbox create proxyforward -toolbox run -c proxyforward sudo dnf install -y golang nodejs npm gcc \ - pkgconf-pkg-config gtk3-devel webkit2gtk4.1-devel -go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0 # inside the toolbox -wails build -platform linux/amd64 -tags webkit2_41 -s -skipbindings -toolbox run -c proxyforward build/bin/proxyforward # run the GUI +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 ``` -- `-tags webkit2_41` is required: Wails 2.13 links webkit2gtk-4.0 unless told - otherwise, and current distros ship only 4.1. -- `-skipbindings` keeps `frontend/wailsjs` untouched (CI's stale-bindings check is - the Windows build's job). -- Rendering is GPU-composited because `main.go runGUI` passes Linux options: - without them Wails defaults WebKitGTK to `WebviewGpuPolicyNever` (CPU painting, - a few fps under the glass blurs). On NVIDIA + Wayland, `linuxGraphicsDefaults` - sets `__NV_DISABLE_EXPLICIT_SYNC=1`, or the first GPU frame ends the session with - Wayland error 71 ("explicit sync is used, but no acquire point is set" — confirm - with `WAYLAND_DEBUG=1`). Don't reach for `WEBKIT_DISABLE_DMABUF_RENDERER=1`: it - doesn't fix that and costs the zero-copy path. It also raises WebKit's layer tile - size (`WEBKIT_LAYERS_TILE_SIZE`): at the default 512 px the drifting aurora layer - shows a flickering grid of tile seams under fractional scaling. -- `wails build -platform windows/amd64` cross-compiles the Windows exe from Linux - (no cgo on that target). +- 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`). - 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 @@ -82,12 +69,13 @@ 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` and `GOOS=darwin` builds compile (CI builds both) and Linux runs — - see "Linux" above. Cross-compiling darwin from Windows still fails on +- **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 diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md index ad12305..c2e2a23 100644 --- a/docs/agent/polish-backlog.md +++ b/docs/agent/polish-backlog.md @@ -159,12 +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 runs, but nothing about it is packaged or polished** — the engine and GUI - work over the unix-socket IPC (`internal/ipc/ipc_unix.go`), but: no release asset - or `.desktop`/`pxf://` registration; Settings → System still offers "Add rule" and - "Install" (both answer `svc.ErrUnsupported`) and the copy says "Windows"; 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. +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/gui.go b/gui.go new file mode 100644 index 0000000..3fb73c1 --- /dev/null +++ b/gui.go @@ -0,0 +1,112 @@ +//go:build !linux + +package main + +import ( + "context" + "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. + +//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) + // 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: func(ctx context.Context) { + fitWindowToScreen(ctx) // before the page loads, so it never shows oversized + a.Startup(ctx) + }, + 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..aa2225c --- /dev/null +++ b/gui_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +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/main.go b/main.go index bdf88c1..e0841e4 100644 --- a/main.go +++ b/main.go @@ -2,27 +2,18 @@ package main import ( "context" - "embed" "fmt" "log/slog" "os" "os/signal" "path/filepath" - "runtime" "runtime/debug" "strings" "syscall" "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" - wailslinux "github.com/wailsapp/wails/v2/pkg/options/linux" - wailswin "github.com/wailsapp/wails/v2/pkg/options/windows" - "proxyforward/app" "proxyforward/internal/config" "proxyforward/internal/engine" "proxyforward/internal/link" @@ -32,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 @@ -419,137 +407,3 @@ func newFirewallCmd() *cobra.Command { } return cmd } - -func runGUI(deepLink string) error { - linuxGraphicsDefaults() - 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: 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, - }, - // Without Linux options Wails defaults WebKitGTK to - // WebviewGpuPolicyNever: every frame is painted on the CPU, and the - // glass surfaces' backdrop blurs then pin a core at a few fps. Always - // keeps compositing on the GPU. ProgramName becomes the Wayland app - // id, so the desktop shell matches the window to proxyforward.desktop. - Linux: &wailslinux.Options{ - WebviewGpuPolicy: wailslinux.WebviewGpuPolicyAlways, - ProgramName: "proxyforward", - }, - OnStartup: func(ctx context.Context) { - fitWindowToScreen(ctx) // before the page loads, so it never shows oversized - a.Startup(ctx) - }, - OnShutdown: a.Shutdown, - Bind: []interface{}{a}, - Logger: wailsLog, - LogLevelProduction: wailslogger.ERROR, - }) -} - -// linuxGraphicsDefaults sets the environment the GPU-composited webview needs -// on Linux. It must run before GTK/EGL initialise (the web process inherits -// it), and it only fills in unset variables, so a user's explicit value wins. -func linuxGraphicsDefaults() { - if runtime.GOOS != "linux" { - return - } - for _, kv := range [...]struct{ key, val string }{ - // NVIDIA's EGL Wayland layer turns on explicit sync for the window - // surface; GTK3 then commits frames without the acquire point it - // promised, and the compositor drops the connection ("explicit sync - // is used, but no acquire point is set", Wayland error 71) the moment - // the webview paints. Other drivers never read it. - {"__NV_DISABLE_EXPLICIT_SYNC", "1"}, - // WebKit tiles large GPU layers (512 px by default). The ambient - // aurora is a viewport-sized layer drifting by sub-pixel translate and - // scale, and with the 2x buffers GTK3 uses under fractional scaling - // every tile edge resamples differently each frame: a flickering seam - // grid, plain on the dark theme. One tile covers the whole layer at - // normal window sizes. - {"WEBKIT_LAYERS_TILE_SIZE", "4096x4096"}, - } { - if _, set := os.LookupEnv(kv.key); !set { - os.Setenv(kv.key, kv.val) - } - } - // On NVIDIA's driver, WebKit's web process segfaults inside - // libnvidia-eglcore from its Skia GPU painting threads, minutes into a - // session — and a dead web process takes the whole app down. Paint page - // content with Skia's CPU rasteriser instead; compositing (transform and - // opacity animation, backdrop blur) stays on the GPU, so motion stays - // smooth. Mesa drivers keep GPU painting. - if _, err := os.Stat("/proc/driver/nvidia/version"); err == nil { - if _, set := os.LookupEnv("WEBKIT_SKIA_ENABLE_CPU_RENDERING"); !set { - os.Setenv("WEBKIT_SKIA_ENABLE_CPU_RENDERING", "1") - } - } -} - -// 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 -// is a real 0-255 alpha on Linux (WebKitGTK), so it must be 255 there; 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/main_test.go b/main_test.go index 0ad7d99..49b4cad 100644 --- a/main_test.go +++ b/main_test.go @@ -22,34 +22,3 @@ func TestDeepLinkArg(t *testing.T) { t.Errorf("no arguments is not a deep link, got %q", got) } } - -// 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) - } - }) - } -} diff --git a/window.go b/window.go index 32affcd..0073b8f 100644 --- a/window.go +++ b/window.go @@ -1,3 +1,5 @@ +//go:build !linux + package main import ( 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) + } + }) + } +} From eedf1a07371000375453c11c7af9fc5d4cf2b6b4 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:34:40 +1300 Subject: [PATCH 12/16] ci: build and test linux headless, without the frontend Linux no longer builds the GUI, so build-linux is a plain static go build (CGO_ENABLED=0) instead of a wails build against gtk/webkit, and neither linux job waits on the frontend job or downloads frontend/dist: gui.go, which holds the embed, is !linux. --- .github/workflows/ci.yml | 43 +++++++++++++++------------------------- 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaa3b21..a7d0709 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +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 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; the linux/macos build jobs prove the -# tree still compiles elsewhere; their binaries are artifacts, NOT release -# assets — see the Reality check in CLAUDE.md. +# 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: @@ -93,7 +95,6 @@ jobs: # 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: - needs: frontend runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -104,10 +105,6 @@ 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 - run: go vet ./... - run: go test -short ./... @@ -325,8 +322,10 @@ jobs: # 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: @@ -337,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 }} From 320c790affc5367c877c1c21e312c3aa05eb5196 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:34:40 +1300 Subject: [PATCH 13/16] security: run govulncheck against the windows build too govulncheck reports by reachability for the GOOS it loads. On the ubuntu runner that is now the headless linux build, which never reaches Wails or the GUI wiring in gui.go, so the shipped Windows build gets its own GOOS=windows pass. --- .github/workflows/security.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 From 61729271a97bc723c178bbfb21c68e7ca034f613 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:35:14 +1300 Subject: [PATCH 14/16] cli: show the platform's real default config path in --help The --config help on agent, gateway and pair hardcoded the Windows %APPDATA% path, which was wrong on Linux, where those commands are the only way to run. It now prints config.DefaultPath. --- main.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index e0841e4..e20bce2 100644 --- a/main.go +++ b/main.go @@ -103,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 { @@ -158,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 @@ -238,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 From 63bb9f2db79310314cb6a919eb222ef6b0a769d7 Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:09:46 +1300 Subject: [PATCH 15/16] gui: keep App.Startup out of the js bindings Fitting the window to the screen wrapped a.Startup in a closure for OnStartup. Wails keeps lifecycle callbacks out of the JS bindings by matching OnStartup's function name against the bound methods, so the closure made Startup a frontend-callable binding and CI's stale-wailsjs check failed. OnStartup is the a.Startup method value again, and the fit runs through a new App.BeforeStartup field at the top of Startup, still before the page loads. Regenerated bindings now match the committed ones. --- app/app.go | 11 +++++++++++ gui.go | 9 ++++----- 2 files changed, 15 insertions(+), 5 deletions(-) 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/gui.go b/gui.go index 3fb73c1..43c0dab 100644 --- a/gui.go +++ b/gui.go @@ -3,7 +3,6 @@ package main import ( - "context" "embed" "path/filepath" @@ -53,6 +52,9 @@ func runGUI(deepLink string) error { } 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 != "" { @@ -88,10 +90,7 @@ func runGUI(deepLink string) error { Windows: &wailswin.Options{ Theme: wailswin.SystemDefault, }, - OnStartup: func(ctx context.Context) { - fitWindowToScreen(ctx) // before the page loads, so it never shows oversized - a.Startup(ctx) - }, + OnStartup: a.Startup, OnShutdown: a.Shutdown, Bind: []interface{}{a}, Logger: wailsLog, From 5713454fe277fd4170d23ce88a62f992127aa6bc Mon Sep 17 00:00:00 2001 From: xeri <109935338+xeri@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:10:11 +1300 Subject: [PATCH 16/16] linux: compile the gui wiring under the bindings tag wails build generates frontend/wailsjs by building the app with -tags bindings for the host and running it. With gui.go excluded on linux, that binary hit errNoGUI, so wails build from a linux machine could not generate bindings. gui.go and window.go now build for !linux || bindings, and gui_linux.go for linux && !bindings. The bindings build opens no window and needs no cgo. commands.md notes the exception and how to check the bindings from linux without the wails CLI. --- docs/agent/commands.md | 5 +++++ gui.go | 5 ++++- gui_linux.go | 2 +- window.go | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/agent/commands.md b/docs/agent/commands.md index af35ae2..39a334f 100644 --- a/docs/agent/commands.md +++ b/docs/agent/commands.md @@ -43,6 +43,11 @@ build/bin/proxyforward agent - `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 diff --git a/gui.go b/gui.go index 43c0dab..f3319f9 100644 --- a/gui.go +++ b/gui.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || bindings package main @@ -19,6 +19,9 @@ import ( // 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 diff --git a/gui_linux.go b/gui_linux.go index aa2225c..9c927f6 100644 --- a/gui_linux.go +++ b/gui_linux.go @@ -1,4 +1,4 @@ -//go:build linux +//go:build linux && !bindings package main diff --git a/window.go b/window.go index 0073b8f..649106a 100644 --- a/window.go +++ b/window.go @@ -1,4 +1,4 @@ -//go:build !linux +//go:build !linux || bindings package main