Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .claude/rules/go-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<tag>")` 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`).
Expand Down
69 changes: 40 additions & 29 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@
#
# Two things are load-bearing here and easy to break:
#
# 1. main.go embeds `all:frontend/dist`, which is .gitignore'd. A go:embed that
# matches zero files is a COMPILE ERROR, so `go vet`/`go test`/`go build` all
# fail on a fresh clone. Every Go job therefore downloads the `frontend-dist`
# artifact built once by the `frontend` job. Never add a Go job without it.
# 1. gui.go embeds `all:frontend/dist`, which is .gitignore'd. A go:embed that
# matches zero files is a COMPILE ERROR, so on Windows/macOS `go vet`/`go test`/
# `go build` all fail on a fresh clone. Every non-linux Go job therefore
# downloads the `frontend-dist` artifact built once by the `frontend` job;
# never add one without it. gui.go is !linux (Linux runs headless), so the
# linux jobs need none.
# 2. gofmt only means anything because of .gitattributes (eol=lf). Windows
# runners check out CRLF by default, which makes gofmt reject every file.
#
# The Go jobs run on Windows because the engine is Windows-only in practice
# (named pipes, service, firewall). The linux/macos build jobs prove the tree
# still compiles elsewhere; their binaries are NOT shippable — see the Reality
# check in CLAUDE.md.
# The Go jobs run on Windows because Windows is the only shipped platform
# (named-pipe IPC, service, firewall). `test-linux` runs the suite on ubuntu so
# the unix-socket IPC is exercised too; `build-linux` builds the headless linux
# binary and `build-macos` proves the GUI still compiles there. Their binaries
# are artifacts, NOT release assets — see the Reality check in CLAUDE.md.
name: ci

on:
Expand Down Expand Up @@ -88,6 +91,23 @@ jobs:
# fuzz seed corpora, and the internal/doccheck citation test.
- run: go test -short ./...

# The unix-socket IPC (internal/ipc/ipc_unix.go) and every other !windows
# path only ever execute on a non-Windows OS; without this job nothing in CI
# would run them, and the Linux port would rot silently.
test-linux:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- run: go vet ./...
- run: go test -short ./...

race:
needs: frontend
runs-on: windows-latest
Expand Down Expand Up @@ -163,9 +183,8 @@ jobs:
}

# Runs on WINDOWS, not ubuntu: build tags make linting GOOS-sensitive. On linux
# the linter sees the !windows build, where Windows-only symbols look dead
# (`unused` flags ipc.go's maxIPCEntries, which server_windows.go uses). Lint
# the platform that ships.
# the linter sees the !windows build, where helpers used only from _windows.go
# files look dead to `unused`. Lint the platform that ships.
lint:
needs: frontend
runs-on: windows-latest
Expand Down Expand Up @@ -300,11 +319,13 @@ jobs:
retention-days: 14
if-no-files-found: error

# Linux and macOS binaries are built to keep the *_other.go stubs honest and to
# make the eventual port a small step. They are NOT shipped: ipc.Serve returns
# ErrUnsupported off Windows, so the engine cannot start. See CLAUDE.md.
# Linux and macOS binaries are built so the non-Windows paths (unix-socket IPC,
# the *_other.go stubs) keep compiling. They run, but are artifacts only, never
# release assets: there is no packaging story yet. See CLAUDE.md.
#
# Linux is headless only (gui_linux.go), so its build is a plain static
# `go build`: no frontend, no cgo, no gtk/webkit, no wails CLI.
build-linux:
needs: frontend
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
Expand All @@ -315,20 +336,10 @@ jobs:
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: frontend-dist
path: frontend/dist
# Ubuntu 24.04 dropped libwebkit2gtk-4.0-dev. Wails v2.13 does NOT
# auto-detect this: -tags webkit2_41 is a compile-time cgo switch that
# selects the 4.1 pkg-config file.
- name: gtk/webkit deps
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev build-essential pkg-config
- run: go install "github.com/wailsapp/wails/v2/cmd/wails@$WAILS_VERSION"
shell: bash
- run: wails build -platform linux/amd64 -clean -s -skipbindings -tags webkit2_41
- name: go build (static, headless)
env:
CGO_ENABLED: "0"
run: go build -trimpath -ldflags "-s -w" -o build/bin/proxyforward .
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: EXPERIMENTAL-linux-amd64-${{ github.sha }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 13 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
34 changes: 21 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -196,7 +204,7 @@ The README and the Settings/Tunnels UI **oversell**. Ground truth at 4a8b0c9:
| UDP tunnels | Not implemented: no UDP socket code, `validateSpec` rejects `type:"udp"`. No longer advertised (the `tunnel-udp` capability was removed); config still accepts `type:"udp"` but the gateway rejects it — a latent gap, not an oversell. |
| MC status polling (MOTD/players) | Only login sniffing (`mcsniff/`); the health probe is a bare TCP dial (`health.go probeOnce`). |
| Tray / minimize-to-tray / autostart | Hidden `tray_spike.go` command only; `MinimizeToTray` / `Autostart` stored, unused. |
| Linux / macOS binaries | CI **builds** them (`.github/workflows/ci.yml`) so the `*_other.go` stubs can't rot, but they **cannot run**: `ipc.Serve` returns `ErrUnsupported` off Windows and every engine must serve the pipe (`engine.go Run`), so the window opens and the engine dies. Unpublished artifacts, never release assets. Fixing this means a real unix-socket IPC port. |
| Linux / macOS support | Linux is **headless only**: the `gateway`/`pair`/`agent` commands over unix-socket IPC (`ipc/ipc_unix.go`), from a static cgo-free `go build` (recipe in `docs/agent/commands.md`); there is **no Linux GUI** — a bare launch refuses (`gui_linux.go runGUI`). **No packaged build** — CI artifacts only, never release assets — and the Windows integrations stay Windows-only: `service`/`firewall` answer `svc.ErrUnsupported` (`svc/stub_other.go`), no systemd unit. macOS still builds the GUI but has never been run. |

Rules: don't document these as working; don't build UI atop them; trust code over the
README everywhere. When you implement one, delete its row **in the same commit** (the
Expand Down Expand Up @@ -228,9 +236,9 @@ Nothing enforces this; it is judgment, and ignoring it has already cost a sessio
## Enforcement — what is a gate, not advice

Blocks a merge → CI. `.github/workflows/ci.yml`: gofmt, vet, `go test -short` (unit +
e2e + goleak + doccheck), `-race` (CI is the **only** place it runs — it needs cgo),
the burst floor (own job, best-of-3 — never lower the floor to go green),
`golangci-lint` (`.golangci.yml`), a TODO/FIXME ban, `actionlint`, the naming gate
e2e + goleak + doccheck; on Windows, and on Linux in `test-linux`), `-race` (CI is the
**only** place it runs — it needs cgo), the burst floor (own job, best-of-3 — never
lower the floor to go green), `golangci-lint` (`.golangci.yml`), a TODO/FIXME ban, `actionlint`, the naming gate
(plan jargon + attribution trailers anywhere in a PR — `docs/agent/naming.md`), and a
stale-`frontend/wailsjs` check. `.github/workflows/security.yml`: CodeQL, govulncheck,
gitleaks, dependency-review, npm audit (gosec/zizmor/Scorecard are advisory → Security
Expand All @@ -244,9 +252,9 @@ file and `docs/agent/reasoning.md`. Before adding a rule here, ask which of thos
homes it belongs in.

Two CI facts that are load-bearing and non-obvious: `.gitattributes` pins `eol=lf`
(Windows runners check out CRLF, which makes gofmt reject **every** file), and every Go
job must materialize `frontend/dist` first — `main.go` embeds it, and a `go:embed`
matching zero files is a compile error.
(Windows runners check out CRLF, which makes gofmt reject **every** file), and every
non-linux Go job must materialize `frontend/dist` first — `gui.go` embeds it, and a
`go:embed` matching zero files is a compile error (linux builds are headless and skip it).

## Maintaining this file

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ machine's router changes; only the gateway forwards a port.

Grab the installer or the portable exe from the
[latest release](https://github.com/xeri/proxyforward/releases/latest). Windows 10/11 x64;
you need it on **both** machines. There is no Linux or macOS build — the engine is
Windows-only (named pipes, service, firewall integration).
you need it on **both** machines. There is no packaged Linux or macOS build. Linux
builds from source and runs both roles **headless** — `proxyforward gateway`, or
`proxyforward pair <code>` then `proxyforward agent`; there is no Linux GUI (recipe in
[`docs/agent/commands.md`](docs/agent/commands.md)). The Windows service and firewall
integration have no Linux equivalent.

The binaries are **not code-signed**, so SmartScreen will warn on first run
("unknown publisher"). Rather than asking you to trust that, every release is built by
Expand Down Expand Up @@ -340,7 +343,8 @@ the Go→JS boundary is coalesced into one `tick` event so the UI never touches
count, so a legitimately flapping agent is never locked out while a brute-forcer is.
- **Connection gates** — 4096 global / 32 per-IP, plus a public-port allowlist.
- **Locked-down IPC** — the GUI attaches to the engine over a named pipe whose ACL admits
only Administrators, SYSTEM, and the interactive user.
only Administrators, SYSTEM, and the interactive user (on Linux, a unix socket in a
private `0700` directory whose peers are checked by uid).
- **Redacted diagnostics** — bundles strip every secret, host, IP, and identity; peer IPs
become stable `sha256` pseudonyms, leak-tested in CI.

Expand Down Expand Up @@ -445,7 +449,8 @@ wails build # produces the single Windows/amd64 proxyforward.exe
```

A fresh clone must build the frontend once before any Go command
(`cd frontend && npm ci && npm run build`) — `main.go` embeds `frontend/dist`.
(`cd frontend && npm ci && npm run build`) — `gui.go` embeds `frontend/dist`. Linux
builds are headless and skip it.

## Development

Expand Down
11 changes: 11 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
38 changes: 36 additions & 2 deletions app/setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package app

import (
"context"
"fmt"
"io"
"log/slog"
"os"
Expand All @@ -12,14 +11,15 @@ import (

"proxyforward/internal/config"
"proxyforward/internal/ipc"
"proxyforward/internal/link"
"proxyforward/internal/setup"
)

// Import starts a real engine, which serves the IPC pipe; give this test
// 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.
Expand Down Expand Up @@ -115,3 +115,37 @@ func TestExportRefusedWithoutRole(t *testing.T) {
t.Fatalf("want no-role refusal, got %v", err)
}
}

// TestSetupAgentWithEnrollmentTicket: the wizard's default pairing code carries a
// tkt_ enrollment ticket and no shared token. SetupAgent must save and start
// that config — Validate once demanded agent.token, so every ticket pairing
// failed with "refusing to save invalid config".
func TestSetupAgentWithEnrollmentTicket(t *testing.T) {
ticket, err := link.NewEnrollTicket()
if err != nil {
t.Fatal(err)
}
code := link.PairingCode{
Host: "127.0.0.1",
Port: 1, // nothing listens; the agent just retries
Token: ticket,
Fingerprint: "sha256:" + strings.Repeat("ab", 32),
}.String()

a := newTestAppDir(t, config.Default()) // fresh install, wizard state
if err := a.SetupAgent(code, "", 0); err != nil {
t.Fatalf("SetupAgent with a ticket: %v", err)
}
defer a.Shutdown(context.Background())

if a.cfg.Agent.EnrollTicket != ticket || a.cfg.Agent.Token != "" {
t.Fatalf("ticket routed wrong: enroll_ticket=%q token=%q", a.cfg.Agent.EnrollTicket, a.cfg.Agent.Token)
}
saved, err := config.Load(a.configPath)
if err != nil {
t.Fatal(err)
}
if saved.Agent.EnrollTicket != ticket {
t.Fatalf("saved enroll_ticket = %q, want the pasted ticket", saved.Agent.EnrollTicket)
}
}
Loading
Loading