diff --git a/.github/workflows/ci-linux.yaml b/.github/workflows/ci-linux.yaml index 38c24dd..db1ae7d 100644 --- a/.github/workflows/ci-linux.yaml +++ b/.github/workflows/ci-linux.yaml @@ -25,13 +25,8 @@ jobs: with: go-version: ${{ matrix.go-version }} - # Only run in this linux based runner - - name: Check Formatting - run: | - if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then - gofmt -l . - exit 1 - fi - - - name: Run tests - run: make test + # The Makefile is the one definition of the gate, so a green local + # `make check` means a green CI. The formatting check that used to be + # inlined here lives in `make lint`, which check depends on. + - name: Check + run: make check diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4321ff0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# aperture-cli + +## Design + +Domain-driven design is the primary design paradigm for this project. New work +that introduces or reshapes a domain concept is modelled before it is written. + +The domain model, the data model and the contracts are one first step, not +three stages. A domain model without its contracts is a description; contracts +without a model have nothing to be complete about. Define them together, before +the code, and let each correct the other. + +- Bounded contexts are sized by language, not by responsibility. Splitting a + context because two halves feel like different jobs is the usual mistake; + if the user experiences one thing, it is one context. +- `domain` is never a package name. Packages and types are named after the + thing they are, by what they do in this program rather than by their + technical role. `Machine`, not `NodeManager`. Where the thing already has a + name in the system it wraps, take that name: a tailnet node registered by + `POST /machine/register` is a `Machine`. +- Every domain object is classified entity, value object or enumeration, and + every field is listed. Behaviour lives on the object. +- Vendor types never appear in domain signatures. `tsnet`, `ipn` and + `ipnstate` are confined to `internal/bridges`, which is the anti-corruption + layer for the tailnet. + +Artifacts, written before the code: + +- `docs/specs/-context-map.md` — ubiquitous language, contexts, + relationships, ambiguous terms. +- `docs/specs/-domain-model.md` — one section per object, with + fields, behaviours, invariants, states and relationships. +- `docs/specs/-contracts.md` — the API, the domain events and the data + model, each to 100% and cross-checked against each other, so every aggregate + transition can be traced through all three. A contract this project does not + have is recorded as absent with its reason, never left blank. +- `docs/adr/NNNN-.md` — the decision and the forcing reason. + +Mermaid diagrams in those files are rendered before the commit that adds them. + +### Writing them + +Progressive disclosure. Every document answers in its first paragraph and +deepens from there, so a reader who stops early still leaves with the decision. +Detail belongs in the spec; the ADR links to it. + +An ADR is one page, Nygard's shape with the reason made explicit: + +``` +# NNNN. Title <- the decision, not the topic +Status / Date +## Why? <- the concrete failure, never the category +## Decision <- numbered, each one testable +## Consequences <- what this costs, not what it wins +## Rejected <- option, then the cost of taking it +## Revisit when <- the condition that reopens this +``` + +An ADR longer than that has a spec trying to get out of it. Both are read by +someone with the code in front of them, so neither restates what the diff +already says. + +Current: [Connection](docs/adr/0001-connection-bounded-context.md). + +## Conventions + +- Commit prefixes match the package touched: `tui:`, `bridges:`, `config:`. +- `make check` is the gate, and it is what CI runs: lint, build, then the suite + under the race detector. `make test` is the fast loop, not the bar. + +## Workflow + +- Never commit to `main`. Every change gets a branch. +- Prove a bug with a failing test first: reproduce it, watch it fail, fix to + green. No harness at that layer means adding the smallest one and wiring it + into `make check`. +- Test through the real path before calling it fixed. Here that is the built + binary in a terminal, not only `go test`. State what was verified and what + could not be. +- Reply to every addressed PR comment with the commit that fixed it: backticked + short hash, linked. No "done" without a hash. +- Commit bodies and ADRs carry the why, because the what is in the diff: the + concrete failure, what the obvious alternative would have cost, and what + would justify revisiting. Same for PR descriptions. +- Security-review any diff touching login links, tailnet identity, credentials + or the bridge state directory before it merges, as a fresh-context + adversarial pass by someone other than the author. Verify each finding; the + build is the arbiter. +- Documents go in content-typed paths (`docs/specs/`, `docs/adr/`), never in a + path named after whatever produced them. + +## Writing the code + +Least code that solves it. Before writing any, in order: does it need to exist +at all (skip it, and say so), is it already here (reuse it), does the stdlib do +it, does a dependency already in `go.mod` do it, can it be one line, and only +then the minimum new code. A new dependency has to be maintained and +license-compatible, no GPL/AGPL; name the one chosen, or why none fit. + +- No abstraction nobody asked for: no interface with one implementation, no + wrapper around a single call, no config for a value that never changes. +- Deletion over addition. Boring over clever. +- Never grow a God object. When the natural home for new state is the struct + everything already hangs off, that is the signal to give it its own type. + Tells: unrelated field clusters, methods that ignore most of the fields, a + name that is a role rather than a thing, tests that cannot construct it + without stubbing the world. +- Self-review each diff for duplication and misplaced logic before committing: + near-identical functions, repeated literals, a second copy of a helper that + already exists, code sitting in the wrong package. +- Nil-check where a value enters: anything off an HTTP response, parsed input, + settings on disk or a function that can return nil is guarded at first + receipt. A check at every use site means the guard is missing at the door. +- Handle errors where recovery is possible. Returning err to `main` moves every + failure to the top with no context and no recovery; propagate only when the + caller owns the decision, otherwise retry, default, wrap or degrade. +- Fix the root cause, not the path the report names. Grep every caller first: + one guard in the shared function is a smaller diff than a guard in each. +- Slow work is a `tea.Cmd`, never inline in `Update`. Back off on retries and + never poll the control plane or the LocalAPI in a tight loop. +- Work consciously skipped is said out loud, never left as a TODO comment or + as speculative code. + +Being lazy about the solution is the goal. Being lazy about understanding it is +not: trace the flow a change touches before picking an approach, because the +smallest change in the wrong place is a second bug. diff --git a/Makefile b/Makefile index 6088d85..7525a73 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build test clean install +.PHONY: build test lint check clean install BUILD_DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_HEIGHT := $(shell git rev-list --count HEAD 2>/dev/null || echo 0) @@ -16,6 +16,20 @@ build: test: go test ./... +# Nothing here needs installing: gofmt and vet ship with the toolchain, so a +# clean checkout can run this. +lint: + @out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt:"; echo "$$out"; exit 1; fi + go vet ./... + go mod tidy -diff + +# The gate. Differs from test in the two ways that matter here: it runs the +# suite under the race detector, and it builds. A bridge is several goroutines +# racing a control plane, so a data race is the failure this project actually +# has, and `go test` will not find one. +check: lint build + go test -race ./... + install: go install -ldflags "$(LDFLAGS)" ./cmd/aperture diff --git a/README.md b/README.md index 9a5e3af..f6ecbf1 100644 --- a/README.md +++ b/README.md @@ -57,19 +57,49 @@ This is useful on machines where installing Tailscale is not practical, where Ap To use bridge mode: -1. Open `Settings`, then open `Aperture Endpoints` and press `a` to add an endpoint. -2. Choose `Bridge`, then select an existing bridge or choose `Add Bridge`. -3. Enter the Aperture URL and follow the Tailscale login prompt for the bridge. +1. Press `c` on the agent menu for `Change connection` (the same screen as `Settings`, then `Aperture Endpoints`). +2. Choose `Add a connection`, then `Bridge`, then an existing bridge or `Add Bridge`. +3. Authorize the bridge. Aperture CLI opens your browser at the Tailscale login link; if it cannot, the link stays on screen to open by hand. No URL is asked for: the bridge looks for Aperture at `http://ai`, the same location a direct connection starts from. 4. Aperture CLI verifies `/v1/models`, makes the endpoint active, and returns to the agent menu. -If verification fails, the endpoint remains configured for retry or editing, and any previous working endpoint remains active. Select a configured endpoint from `Aperture Endpoints` to switch to it. +If your Aperture answers on a different hostname, type it on the connect screen while the default is being tried. That cancels the attempt and connects to what you typed. Esc abandons the attempt and leaves your current endpoint alone. + +### Choosing a connection + +`Change connection` lists everything this launcher can reach: each saved endpoint, and each bridge that has no endpoint yet, labelled with the tailnet it reaches. That is the screen to use when the launcher connected on its own and you wanted the other bridge. + +Selecting a row opens it. From there you can connect to it, change its URL, switch its tailnet, or remove it. + +`http://ai` can answer and still be the wrong Aperture, which is what happens when the bridge joins a tailnet that already has a host called `ai`. `Change URL` points the connection somewhere else; it keeps the bridge it is reached through and reconnects. + +### Switching tailnets + +A bridge is on one tailnet at a time. `Switch tailnet` logs it out, which removes its node from that tailnet, then prints a new login link: open it and pick the tailnet you want. Use a second bridge instead if you want to keep both logins and choose between them. + +If verification fails, the endpoint remains configured for retry or editing, and any previous working endpoint remains active. ### Flags -| Flag | Description | -|------|-------------| -| `-version` | Print build version and exit | -| `-debug` | Print environment variables set before launching the agent | +| Flag | Environment | Description | +|------|-------------|-------------| +| `-version` | | Print build version and exit | +| `-debug` | | Print environment variables set before launching the agent | +| `-endpoint` | `APERTURE_ENDPOINT` | Aperture URL to open on, instead of the saved one | +| `-bridge` | `APERTURE_BRIDGE` | Connect through the bridge with this name, creating it if there is none | + +A flag beats its environment variable, so a one-off run can override whatever +the shell was started with. `-bridge` on its own starts at the well-known +location, the same guess the connection picker makes: + +```sh +aperture -bridge work # http://ai over the "work" bridge +aperture -bridge work -endpoint aperture.example.com # that URL over the "work" bridge +aperture -endpoint aperture.example.com # direct, no bridge +``` + +Neither is made the saved active endpoint until the connection works, so an +unreachable URL passed on the command line does not displace the one that does +work. ## Development diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index d391de6..f37997e 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -3,6 +3,7 @@ package main import ( "flag" "fmt" + "io" "log/slog" "os" "os/exec" @@ -17,7 +18,7 @@ import ( "github.com/tailscale/aperture-cli/internal/profiles" "github.com/tailscale/aperture-cli/internal/tui" - // Side-effect imports register each client with internal/clients. + // Each import registers its client with internal/clients. _ "github.com/tailscale/aperture-cli/internal/clients/claudecode" _ "github.com/tailscale/aperture-cli/internal/clients/codex" _ "github.com/tailscale/aperture-cli/internal/clients/copilot" @@ -29,8 +30,10 @@ import ( ) var ( - flagVersion = flag.Bool("version", false, "print version and exit") - flagDebug = flag.Bool("debug", false, "enable bridge diagnostics and print agent launch environment") + flagVersion = flag.Bool("version", false, "print version and exit") + flagDebug = flag.Bool("debug", false, "enable bridge diagnostics and print agent launch environment") + flagEndpoint = flag.String("endpoint", "", "Aperture URL to open on, instead of the saved one ($APERTURE_ENDPOINT)") + flagBridge = flag.String("bridge", "", "connect through the bridge with this name, creating it if there is none ($APERTURE_BRIDGE)") buildVersion = "B0-dev" buildCommit = "unknown" @@ -51,7 +54,7 @@ func init() { } } - // Only fill in VCS info when ldflags haven't already set these values. + // ldflags may have set these already. Fill them from VCS info only then. if buildCommit != "unknown" { return } @@ -111,6 +114,62 @@ func gitCommitHeightInDir(dir string) string { return height } +// startRunLog points slog at the run log and returns a function that closes +// it. Records are written straight through, so the os.Exit paths that skip +// the close lose nothing. The close is tidiness, not a flush. +// +// A run that cannot open the file still runs. Diagnostics are not worth +// refusing to start over. Such a run discards them rather than writing to +// stderr, because stderr is the TUI's screen. +// +// verbose only raises the level. The log is on for every run. The run worth +// reading back is the one that went wrong, and nobody knows to pass -debug +// before it does. +func startRunLog(verbose bool) func() { + f, err := config.OpenRunLog() + if err != nil { + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) + return func() {} + } + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug + } + slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level}))) + slog.Info("aperture starting", "version", buildVersion, "commit", buildCommit, "pid", os.Getpid()) + return func() { + slog.Info("aperture exiting") + f.Close() + } +} + +// reportFailure prints err and the run log path to stderr. Every diagnostic +// goes to the run log, which is right for a running TUI and wrong for a run +// that just died. Without this, a failed launch prints nothing and exits 1. +// +// stderr is safe at both call sites. The TUI either never started or has +// already given the terminal back. +func reportFailure(err error) { + fmt.Fprintln(os.Stderr, "aperture:", err) + if path, pathErr := config.RunLogPath(); pathErr == nil { + fmt.Fprintln(os.Stderr, "details:", path) + } +} + +// flagOrEnv returns flag name when it was passed, even empty, and otherwise +// the environment variable key. The variable lets a dotfile, container or +// systemd unit make the same selection a typed invocation can. The flag wins +// so a one-off run can override the shell it started in: -bridge= means no +// bridge. +func flagOrEnv(fs *flag.FlagSet, name, key string) string { + passed := false + fs.Visit(func(f *flag.Flag) { passed = passed || f.Name == name }) + if passed { + return fs.Lookup(name).Value.String() + } + return os.Getenv(key) +} + func main() { flag.Parse() @@ -123,9 +182,16 @@ func main() { os.Exit(0) } + // Start the log before anything that logs. slog's default handler writes + // to stderr, and on a TUI that owns the terminal that paints a line over + // the screen. Until this runs every diagnostic is either damage or lost. + closeLog := startRunLog(*flagDebug) + defer closeLog() + g, err := config.Load() if err != nil { slog.Error("loading launcher config", "err", err) + reportFailure(err) os.Exit(1) } g.Debug = *flagDebug @@ -133,16 +199,28 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - bridgeManager := bridges.NewManager(g.Debug) - p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager)) + // Resolve the flags before the TUI takes the terminal. A URL we cannot use + // then exits non-zero instead of painting an error the script that passed + // it will never see. + start, err := config.EndpointFromFlags(g, flagOrEnv(flag.CommandLine, "endpoint", "APERTURE_ENDPOINT"), flagOrEnv(flag.CommandLine, "bridge", "APERTURE_BRIDGE")) + if err != nil { + slog.Error("resolving the endpoint to open on", "err", err) + reportFailure(err) + os.Exit(1) + } + + machines := bridges.NewMachines(g.Debug) + p := tea.NewProgram(tui.NewModel(g, buildVersion, machines, start)) var exitCode int if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) + reportFailure(err) exitCode = 1 } - if err := bridgeManager.Close(); err != nil { + if err := machines.Close(); err != nil { slog.Error("shutting down bridges", "err", err) + reportFailure(err) exitCode = 1 } if exitCode != 0 { diff --git a/cmd/aperture/main_test.go b/cmd/aperture/main_test.go new file mode 100644 index 0000000..7412875 --- /dev/null +++ b/cmd/aperture/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "flag" + "testing" +) + +// -bridge= has to beat APERTURE_BRIDGE: the documented precedence is flag over +// environment, and an empty flag is the only way a shell with the variable set +// can ask for the saved endpoint. +func TestFlagOrEnv(t *testing.T) { + t.Setenv("APERTURE_BRIDGE", "work") + cases := []struct { + name string + args []string + want string + }{ + {"absent flag reads the environment", nil, "work"}, + {"flag wins", []string{"-bridge=home"}, "home"}, + {"explicitly empty flag wins", []string{"-bridge="}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fs := flag.NewFlagSet("aperture", flag.ContinueOnError) + fs.String("bridge", "", "") + if err := fs.Parse(tc.args); err != nil { + t.Fatal(err) + } + if got := flagOrEnv(fs, "bridge", "APERTURE_BRIDGE"); got != tc.want { + t.Errorf("flagOrEnv = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/docs/adr/0001-connection-bounded-context.md b/docs/adr/0001-connection-bounded-context.md new file mode 100644 index 0000000..ace6cc2 --- /dev/null +++ b/docs/adr/0001-connection-bounded-context.md @@ -0,0 +1,87 @@ +# 0001. Connection is one bounded context, and it publishes events, not log lines + +Status: proposed +Date: 2026-09-17 +Change size: large. Touches `internal/bridges`, the activation half of +`internal/tui`, and the `ApertureHost` boundary into `internal/clients`. + +## Why? + +A bridge that had never been connected took 29 seconds to come up and the +screen showed nothing but tsnet's `NeedsLogin`. The goroutine dump puts it +inside the first `POST /machine/register` (`controlclient/direct.go:853`) with +no follow-up URL, so no login link existed yet. That is a distinct thing to be +waiting on and it is indistinguishable on screen from waiting for the user to +finish in the browser, because both are `ipn.NeedsLogin`. + +Three changes have already been aimed at that wait without the information to +aim: `5ebdb59` opens the browser at the link, `80d679f` reads the link off the +IPN bus, `82fab61` resolves the target against the node's peer map. Each is +correct and none could have shortened this wait, because none is in the phase +the wait was in. Four mechanisms carry progress (a `func(string)` sink, a +`chan bridgeLine`, tsnet's own prose, an `*ipnstate.Status` return) and not one +of them names what the attempt is waiting on, so a fourth fix would be aimed +the same way. + +Two defects fall out of the same shape. The TUI recovers the login link by +matching a phrase inside tsnet's log text (`"or go to: "`, `browser.go:16`), so +a reworded upstream line silently stops the browser opening. And the link +travels on a 32-slot channel that drops on overflow (`tui.go:557`), shared +under `--debug` with the tsnet backend logger, so chatter can discard the one +line the user cannot proceed without. + +Model, language and the anti-corruption layer: +[context map](../specs/connection-context-map.md), +[domain model](../specs/connection-domain-model.md), +[contracts](../specs/connection-contracts.md). + +## Decision + +1. Connection is one bounded context spanning bridge bring-up and the model + fetch. The user experiences one wait. +2. The boundary out of `internal/bridges` becomes a typed `Event` stream. + Delete `AuthLogPrefix`, `tsnetAuthURLMarker` and the marker scraping in + `authURLFromLog`; keep its URL rules as `ParseLoginLink`. +3. The port stops returning `*ipnstate.Status`. `internal/bridges` is the only + package importing tsnet and friends. +4. Own the IPN bus watch. Stop calling `tsnet.Server.Up`, absorb its + `TailscaleIPs` check, skip `resetServeStateOnce`, silence `UserLogf`. +5. `Gateway` replaces `ApertureHost` at the boundary into `internal/clients` + and `internal/profiles`. +6. `ConnectionAttempt` and `Machine` are their own types. `Manager` does not + grow fields; `Machine` takes the node, its routes and its tailnet. + [ADR 0005](0005-machine-owns-its-operations.md) retires `Manager`. + +## Consequences + +A wait has a name and a duration, so the next slow connection is diagnosable +from the screen rather than from a `SIGQUIT` dump. The browser opens because a +`LoginRequired` event arrived, the link cannot be dropped by debug chatter, and +one watcher runs inside `LocalBackend`'s lock instead of two. + +The cost is that we own the bring-up loop, including `Notify.ErrMessage` and +the `TailscaleIPs` check, so a step added to `Up` upstream will not reach us. +Phase detection reads `Notify` fields whose ordering is upstream behaviour and +not contract. `printAuthURLLoop` still runs and still calls +`StatusWithoutPeers` every five seconds while a login is outstanding, and +nothing outside tsnet can stop it. Every `g.ApertureHost` reader changes. + +## Rejected + +- **Timestamp the log lines and stop there.** Already shipped (`53f2148`) and + it is what made the phases visible enough to name. The TUI still parses prose + to decide to open a browser, and an elapsed time against an unnamed line + still does not say which `NeedsLogin` wait you are in. +- **Keep `tsnet.Server.Up` and add phases from the existing second watcher.** + Smaller, and it keeps two watchers on a backend that assumes one. A lagging + watcher is evicted with a terminal `ErrMessage` that `Up` reports as + `IPN bus consumer fell behind`: a bridge failure unrelated to anything the + user did. +- **Patch tsnet upstream to publish phases.** The right long-term answer and + worth raising. It does not unblock this, and the ACL is what makes adopting + it later a change in one package. + +## Revisit when + +Upstream exposes bring-up phases directly, or the CLI serves anything over a +bridge, which puts `resetServeStateOnce` back in scope. diff --git a/docs/adr/0002-bridge-removal-destroys-the-machine.md b/docs/adr/0002-bridge-removal-destroys-the-machine.md new file mode 100644 index 0000000..d29974d --- /dev/null +++ b/docs/adr/0002-bridge-removal-destroys-the-machine.md @@ -0,0 +1,73 @@ +# 0002. Removing a bridge destroys its Machine + +Status: accepted +Date: 2026-09-18 + +## Why? + +There are two dead `aperture-cli-bridge-*` machines in the maintainer's +tailnet. Deleting a bridge drops its settings entry (`RemoveBridge`, +`global.go:219`) and leaves the registered device and the tsnet state +directory behind. `SwitchTailnet` (`manager.go:528`) already worked out that +closing a node without logging out orphans the device; removal never got that +reasoning. + +`b2a6bc3` made it ordinary. Before it, orphaning took a deliberate second `d` +on a bridge row. Now deleting an endpoint cascades into it, so the common path +silently abandons a machine on the user's tailnet. + +Detail, including all six removal sites and the constraints: +[bridge resource lifecycle](../specs/bridge-resource-lifecycle.md). + +## Decision + +Destroying the last Bridge reference destroys its Machine. + +1. `Machine` grows `destroy`: `Logout`, `Close`, then discard the state + directory, which is the Machine's own persistence. `Manager.Destroy` is the + way in, because the turn that serialises work on one Machine and the cache + entry holding it are both `Manager` state, and a destroy that took neither + could open the state directory a running attempt is still writing. `Manager` + grows a method, not a field (ADR 0001, decision 6). +2. New invariant: a Bridge with no Endpoint has no Machine. +3. Settings last. It is the only record the device exists, so dropping it + before a failed logout leaves a machine the CLI can no longer name. +4. A Bridge with no state directory never registered. It has no Machine to + destroy and must not start one to find out. Not `Bridge.Tailnet`: that is a + display hint, written after verification and cleared before a switch, so it + is empty for machines that do exist. +5. Destruction confirms, naming the device and the tailnet. +6. The wait is bounded. On timeout the local records stay, the removal is + reported as failed and the user is told which device to look for. Removing + the connection again retries the logout. Dropping the records on timeout + was the first version; it orphaned the login on disk with no bridge left to + retry through. + +## Consequences + +Delete stops being instant and infallible: logout is a control-plane round +trip, and that round trip was hanging past 90s on 2026-09-17. Point 6 is the +concession: a removal can fail and need a second try. + +Removal is irreversible from the CLI, and ACL rules naming the old device stop +matching. Leftover devices are unpublished behaviour someone may depend on, +which is the argument for confirming loudly rather than for leaking quietly. + +The six removal sites need a `context.Context` and an event sink they do not +have today. That is most of the work. + +## Rejected + +- **Leave it.** Invisible where it happens, visible later in a different + product, and no longer rare. +- **Revert `b2a6bc3`.** Brings back deleting one row twice, and still leaks. +- **Ephemeral nodes.** Closes the leak completely and costs a new device and a + new login every run, which is what persistent bridges exist to avoid. +- **Skip the logout, delete the rest.** Keeps delete fast, leaves the exact + orphan `SwitchTailnet` exists to prevent, and destroys the credentials that + would let us clean up later. + +## Revisit when + +tsnet can deregister a node without bringing it up, which is the only reason +`Destroy` is slow. diff --git a/docs/adr/0003-preserve-verified-connections.md b/docs/adr/0003-preserve-verified-connections.md new file mode 100644 index 0000000..c7e57c4 --- /dev/null +++ b/docs/adr/0003-preserve-verified-connections.md @@ -0,0 +1,45 @@ +# 0003. Commit endpoint edits after verification and serialize each Machine + +Status: accepted +Date: 2026-09-18 + +## Why? + +Editing the active URL rewrote settings before the model check; Escape returned +to the agent menu with the replacement host and the previous host's providers. +An inline override could also reuse a node while the cancelled attempt was +closing it. Tailnet switching left shared endpoints launchable after closing +their proxies and required authorization before it could log out. + +## Decision + +1. Keep the original endpoint through an edit, retry and override. Commit the + verified replacement and removal of the original in one settings write. +2. A Machine grants one cancellable operation at a time, including startup and + cleanup. Its cache entry is not evidence of readiness. +3. Switching the active bridge invalidates its runtime before dispatch. Only + successful verification restores launch actions, including after Escape. +4. Logout initializes the LocalAPI without awaiting `ipn.Running`. + +Fields, states and contracts: [model](../specs/connection-domain-model.md#lifecycle-correction-implemented-in-this-pass) +and [contracts](../specs/connection-contracts.md#lifecycle-correction-contracts). + +## Consequences + +A failed edit retains its candidate alongside the working endpoint for retry. +An overlapping activation waits for the previous node's cleanup. Cancelling +a switch before logout starts may still require reconnecting: it is not proof +that the old gateway survived. + +## Rejected + +- Restore settings after failure: leaves an unverified host active during the + attempt and requires a second fallible write to undo it. +- Lock the entire manager through startup: one login would block other bridges + and prevent shutdown from cancelling the wait. +- Await authorization before logout: requires joining the network being left. + +## Revisit when + +Machines gain a persistent event stream or multiple independent subscribers; +then move runtime invalidation from switch intent to a durable lifecycle event. diff --git a/docs/adr/0004-contain-connection-authority.md b/docs/adr/0004-contain-connection-authority.md new file mode 100644 index 0000000..6ed4430 --- /dev/null +++ b/docs/adr/0004-contain-connection-authority.md @@ -0,0 +1,50 @@ +# 0004. Scope peer names, keep login links out of shells and logs, and join shutdown + +Status: accepted +Date: 2026-09-18 + +## Why? + +A shared-in peer named `ai` could receive requests intended for the selected +tailnet. A control-plane URL could become a Windows shell command, while the +normal diagnostic log retained machine-authorization links. A second quit +could also report shutdown complete while the first was still closing nodes. + +## Decision + +1. Match bare peer names only within the current Machine's MagicDNS suffix; + retain explicit FQDN access to shared peers. +2. Open Windows login URLs through `ShellExecuteW`, using the existing + `golang.org/x/sys/windows` dependency. Never pass them through `cmd.exe`. +3. Persist the login-required fact, not its URL, in Aperture's run log. Remove + inputs from link-parse errors and redact raw bridge diagnostic URLs entering + that log at every level. +4. Use `sync.OnceValue` so every shutdown caller joins the same cleanup and + receives the same result. + +The [model](../specs/connection-domain-model.md#security-correction) and +[contracts](../specs/connection-contracts.md#security-correction-contracts) +define the boundaries, failure cases and unchanged persistence model. + +## Consequences + +Unknown tailnet suffixes cannot supply short-name aliases. Diagnostics lose URL +detail; older run logs and the SDK's separate logtail still require care. +Repeated quit requests wait for cleanup instead of bypassing it. Native Windows +execution needs a Windows +test environment; cross-building alone cannot establish desktop behavior. + +## Rejected + +- Escape shell punctuation: maintains a second command-language parser where + the operating system already accepts a URL directly. +- Keep links in owner-only logs: permissions do not follow diagnostics when + users share them for support. +- Ignore repeated quit in the TUI: leaves other callers of `Close` with the + same incorrect completion contract. + +## Revisit when + +Diagnostic URLs become necessary for support, or shutdown needs an explicit +force-exit operation. Define those permissions separately from normal logging +and successful cleanup. diff --git a/docs/adr/0005-machine-owns-its-operations.md b/docs/adr/0005-machine-owns-its-operations.md new file mode 100644 index 0000000..2dd3818 --- /dev/null +++ b/docs/adr/0005-machine-owns-its-operations.md @@ -0,0 +1,88 @@ +# 0005. Machine owns its operations, Machines holds them, Attempt owns its transitions + +Status: accepted +Date: 2026-09-21 + +## Why? + +`Manager` grew from four fields on `main` to eight on this branch the day +after [ADR 0001](0001-connection-bounded-context.md) decision 6 said it would +not grow. Every behaviour the domain model gives `Machine` (`Open`, `RouteTo`, +`LeaveTailnet`, `Close`) was a `Manager` method taking `rt *Machine` as an +argument, and `Machine` as built shared no field with `Machine` as modelled. +The model document described two different objects under one name. The lock +around a Machine's operations was named `acquire` and documented as granting +"a cancellable turn", words in neither the code's nor the model's vocabulary, +and the phrase had reached ADR 0003 and the contracts before anyone asked +what it meant. + +Meanwhile the TUI decided domain rules it should only have displayed: whether +a removal destroys a device, when the Bridge record goes, when the tailnet a +Machine joined is recorded on its Bridge, when an endpoint edit commits, which +candidate an abandoned attempt takes back out. Twenty-eight sites, two of +which the model already listed as "the TUI orchestrates but should not +decide". + +## Decision + +1. `Machine` has its modelled behaviours: `Open`, `RouteTo`, `LeaveTailnet`, + `Destroy`, `Close`, `Tailnet`. The one-operation-at-a-time rule is its + private detail; nothing outside it takes a lock. +2. `Machines` is a collection: creates a Machine per Bridge on first use, + closes them all once. It does no network work. +3. `Attempt` (the model's ConnectionAttempt) lives in `internal/bridges` and + owns its transitions: `BeginAttempt`, `Run`, `Commit`, `Abandon`, + `Retarget`. Commit rather than Succeed, because it persists a result Run + already produced and decides nothing. The TUI's `activation` holds + presentation state only. +4. Removing a Bridge is the one transition no aggregate owns, and it gets + functions named for the nouns it acts on rather than a process object: + `CheckRemovable`, `WillDestroyMachine`, `Machines.Destroy`, + `RemoveFromSettings`. No service type. + The first attempt at this was a `Bridging` service and a `Removal` value, + both names for activities rather than things, and both went in review. +5. Every operation that waits on the network is split from the one that + writes settings. `Run` and `Machines.Destroy` may run anywhere and write + nothing; `BeginAttempt`, `Commit`, `Abandon`, `CheckRemovable`, + `WillDestroyMachine` and `RemoveFromSettings` run on the update loop. +6. `Manager` is deleted. No compatibility wrapper. + +## Consequences + +The two-phase API is imposed by bubbletea, not chosen: commands run off the +update loop and `config.Global` has no lock, so a settings write in `Run` +would race every view that reads it. The race detector is the gate here +(`make check`), so the split is what keeps the gate honest. It costs each +caller two calls where it made one. + +A superseded attempt could commit if its result arrives before the newer +attempt's; the TUI's id check still discards it, and `Commit` runs only for +the attempt on screen. Unchanged from before. + +`Bridge.Tailnet` is still written by the service on verification rather than +on join, because writing on join would happen in `Run`. `Machines.Tailnet` +covers the gap by preferring what the running Machine reports. + +The two rules the model still leaves unowned stay in the TUI as a display +flag: whether the active destination is verified. `InvalidatesActive` and +`TargetsActive` on the Attempt are the rule's answer; the TUI keeps the bit. Naming the object that owns "the +current Gateway and whether it is verified" is the next modelling step. + +## Rejected + +- **Rename `acquire` and move on.** Fixes the word, not the shape. The + operations would still live on a role-named object with the entity passed + in as an argument. +- **Leave it for APT-330, which deletes `Manager` anyway.** APT-330 stacks on + this branch and its spec warns that `Sessions` "must remain a collection, + not a renamed manager". Landing a larger manager for it to remove makes its + rebase bigger and leaves the model wrong on `main` in the meantime. +- **Write settings from the goroutine and lock `Global`.** Every read in the + view would need the lock too; `Global` is read on nearly every frame. + +## Revisit when + +APT-330 lands: `Machines` and `Machine` become one launcher's view of a shared +helper, and `Attempt` should survive that with its signatures. Or an object +owning the current Gateway exists, at which point `connected` leaves the TUI +and `InvalidatesActive` and `TargetsActive` lose their reason to exist. diff --git a/docs/specs/bridge-resource-lifecycle.md b/docs/specs/bridge-resource-lifecycle.md new file mode 100644 index 0000000..db1ef46 --- /dev/null +++ b/docs/specs/bridge-resource-lifecycle.md @@ -0,0 +1,97 @@ +# Bridge resource lifecycle + +Creating a bridge produces three things. Removing one destroys one of them. +The survivors are a device in the user's admin console and a directory on +their disk. Decision: [ADR 0002](../adr/0002-bridge-removal-destroys-the-machine.md). + +## What a bridge creates + +| Resource | Created by | First exists | Removed by | +|---|---|---|---| +| Machine `aperture-cli-` | `tsnet.Server` registering | first successful `Activate` | nothing | +| `$UserConfigDir/aperture/bridges/` | tsnet, from `Server.Dir` | first `Activate`, successful or not | nothing | +| `config.Bridge` | `AddBridge` (`global.go:178`) | the moment a name is typed | `RemoveBridge` (`global.go:219`) | + +The device outlives the process because `newTSNetNode` (`node.go`) sets no +`Ephemeral`, which is the point: the same bridge reconnects next run without a +login. The directory is the other half of that, and tsnet mkdirs it lazily, so +a bridge that never connected has none. `RemoveBridge` writes settings and +nothing else; `os.RemoveAll` appears four times in the repo, all of it client +installer cleanup. + +`Machine.LeaveTailnet` and `Machine.Destroy` are the only callers of `Logout`, and its +comment already names the failure mode: a close without a logout leaves the +device orphaned rather than removed. + +## Where a bridge can be removed + +Six sites, all in `internal/tui`. Five of them now describe the delete as a +the Bridge and Endpoint pair and hand it to `remove` (`removal.go`), which is the only place +that decides whether a machine has to be destroyed first. + +| Site | Removes | +|---|---| +| `bridgesMenu` hidden `d` | the bridge, from a second delete UI parallel to the picker's | +| `removeRow` bridge arm | the bridge, for a row with no endpoint | +| `removeRow` endpoint arm | the endpoint, then cascades | +| `dropOrphanBridge` | the bridge, once its last endpoint goes (`b2a6bc3`) | +| setup guide "Remove endpoint" | the endpoint, then cascades | +| `discardActivation` (`tui.go`) | the ephemeral endpoint, leaving the bridge | + +The last is the exception and is a cause rather than a symptom: abandoning the +first connection to a new bridge is what leaves a bare "Connect via" row with +no endpoint. So the row the second site deletes is usually the residue of a +login nobody finished, and is the one case with no device to clean up. + +## What destroying it needs + +`Machine.destroy`, on the aggregate that owns the node +([domain model](connection-domain-model.md#machine)): `Logout`, `Close`, then +discard the state directory, which is the Machine's own persistence. +`Machine.Destroy` is the entry point, reached through `Machines.Destroy`, because +destruction has to hold the Machine like every other +operation on that node. + +The state directory goes last and only when the logout succeeded: it holds the +node key, which is what a later attempt would need to deregister the device. + +Order matters and is not the obvious one. Settings goes last, after `Destroy` +returns, because settings is the only record that the device exists: dropping +it first and then failing the logout leaves a registered machine the CLI can no +longer name. + +## Constraints + +**Logout needs an initialized LocalAPI, not an authorized node.** `Logout` +goes through `server.LocalClient()`, which calls `Start` without waiting for +`Running`. `SwitchTailnet` now uses this path; see +[ADR 0003](../adr/0003-preserve-verified-connections.md). A closed node keeps +its credentials, but requiring `Up` before logout would unnecessarily demand +authorization of the identity being left. + +**Initialization can begin registration; `Up` waits for it.** Destruction must +not require an interactive login to remove a bridge, so it initializes the node +and never brings it up. `Bridge.Tailnet` is a display hint, not proof that no +machine exists when empty: it is saved only after endpoint verification and +cleared before a switch. The state directory is the durable evidence, which is +what `bridges.HasMachine` reads. + +**Destruction is slow and failable.** `/machine/register` was hanging past 90 +seconds on 2026-09-17 and logout is a round trip to the same place. The escape +has to name the surviving device, not just report a timeout. + +**No removal site had a context or an event sink.** They return `menu.Result` +synchronously, so the wait goes where every other slow bridge operation goes: +`destroyBridgeCmd` reuses `stepPreflight`, the bridge log tail and +`bridgeRemovedMsg`, in the shape of the post-launch recheck. The attempt keeps +no cancel handle, so Esc cannot abandon a logout half way through and leave the +record disagreeing with the device. + +**No removal path confirmed.** `removeBridgeMenu` follows `switchTailnetMenu`, +the house confirm shape. + +## Out of scope + +The `b2a6bc3` cascade rule stands: a bridge two endpoints reach through is not +an orphan. Deduplicating the six sites is not required to fix the leak, though +whatever lands should not make it seven. diff --git a/docs/specs/connection-context-map.md b/docs/specs/connection-context-map.md new file mode 100644 index 0000000..4112122 --- /dev/null +++ b/docs/specs/connection-context-map.md @@ -0,0 +1,143 @@ +# Connection context map + +Connection owns the wait between choosing an endpoint and giving a client a +verified destination. Settings commits a URL edit only after verification; +logging out a Machine invalidates every destination reached through it. The +lifecycle corrections are recorded in [ADR 0003](../adr/0003-preserve-verified-connections.md). +Short names belong to the Machine's own MagicDNS suffix; visibility of a shared +peer does not give it a local alias. Login links cross into the desktop as URLs, +never shell commands, and do not cross into persistent diagnostics. See +[ADR 0004](../adr/0004-contain-connection-authority.md). + +The context boundaries below also describe the proposed broader event refactor. + +## Ubiquitous language + +| Term | Means | Does not mean | +|---|---|---| +| Connection Attempt | One try at reaching an Aperture from one Endpoint. Has identity, a phase, a recorded progress trail, and exactly one outcome. | The TCP connection. The persisted endpoint list. | +| Endpoint | The remote Aperture the user chose and the way to it. Two kinds and no third: a DirectEndpoint the host reaches itself, a BridgeEndpoint reached through a Bridge's Machine. The kind is the type, never an empty field. | The local proxy address. Anything the CLI listens on. | +| Gateway | The address a client is finally told to send requests to. The Endpoint URL when no Bridge is involved, the Route's local end when one is. | The Endpoint. Only equal to it in the direct case. | +| Route | The local door to one Endpoint through one Machine: a `127.0.0.1:0` listener reverse-proxying over the Machine. | A tailnet route or subnet route. | +| Bridge | The thing the user configures and sees in the picker: id, display name, last tailnet joined. Persisted. | The running tsnet node. | +| Machine | What this program runs on the user's tailnet for one Bridge: registers, may need a login, gets an address, carries dials, and shows up under Machines in their admin console. Outlives any one Attempt. | The Bridge record. The proxy. The computer aperture is running on. | +| Machines | The process's Machines, one per Bridge. Where a Machine is created and where they are all closed. | A manager. It does no network work of its own. | +| Login Link | The URL that authorizes a Machine. `https` only, no whitespace, opened in a browser or copied. | Any URL in a log line. | +| Phase | What the Attempt is waiting on right now, named for what the user is waiting for. | `ipn.State`. | +| Progress | The trail of phases an Attempt passed through and how long each took. The thing that was missing when a 29s wait could not be attributed. | The scrolling log. | +| Tailnet | The network a Machine joined. Recorded on the Bridge so the picker can name it before the Machine exists. | | +| Provider | A model provider read from the Aperture's `/v1/models`. | | + +`Machine` is not our coinage. It is the word the thing already has in the system +it lives in: registration is `POST /machine/register` +(`controlclient/direct.go:839`), the identity is a `MachineKey`, the state we +wait on is `ipn.NeedsMachineAuth`, and the admin console lists it under +Machines. Taking the existing name means the user, the control plane and this +code all say the same word. `Node` was the alternative and is worse: tsnet uses +it for our node and for every peer in the netmap at once. + +## Contexts + +| Context | Subdomain | Owns | Lives in | +|---|---|---|---| +| Connection | Core | Connection Attempt, Phase, Progress, Login Link, Gateway, Route, Machine, Machines | `internal/bridges`. `internal/tui` presents and dispatches, and decides nothing. | +| Settings | Supporting | Endpoint, Bridge, persistence | `internal/config` | +| Client Launch | Supporting | Per-client config and env, written from a Gateway | `internal/clients/*`, `internal/profiles` | +| Tailnet | Generic, external | Nodes, login, netmap, dialing | `tsnet`, `ipn`, `ipnstate`, `client/local` | +| Aperture | Generic, external | `/v1/models` | the remote service | + +Connection is deliberately one context and not three. It spans bridge bring-up +*and* the model fetch that follows, because a user waiting 29 seconds does not +know or care which half they are in, and splitting them is exactly what left +nobody owning the question "what is this attempt waiting on". + +```mermaid +flowchart LR + User([User]) + subgraph Core + Connection[Connection
attempt, phase, progress
machine, route, gateway] + end + Settings[Settings
endpoints, bridges] + Launch[Client Launch
opencode, claude, gemini, codex] + Tailnet[[Tailnet
tsnet / ipn]] + Aperture[[Aperture
/v1/models]] + + User -->|picks an Endpoint| Connection + Settings -->|Endpoint, Bridge| Connection + Connection -->|tailnet joined| Settings + Connection -->|Gateway| Launch + Connection -->|ACL| Tailnet + Connection -->|providers| Aperture + Launch -->|requests| Aperture +``` + +## Relationships + +| Upstream | Downstream | Pattern | Note | +|---|---|---|---| +| Settings | Connection | Shared kernel | `Endpoint` and `Bridge` are already value/entity types Connection uses unchanged. No translation needed and none wanted. | +| Connection | Client Launch | Published language | Launch receives a `Gateway` and nothing else about how it was obtained. Today it receives `g.ApertureHost`, which is the same field for two different things. | +| Tailnet | Connection | Anti-corruption layer | `internal/bridges` is the only importer of `tsnet`/`ipn`/`ipnstate`. The port must stop returning `*ipnstate.Status`. | +| Aperture | Connection | Conformist | We take `/v1/models` as given; `config.ParseProviders` is the only translation. | + +## Anti-corruption layer + +`internal/bridges` is the ACL and the only importer of `tsnet`, `ipn`, +`ipnstate` and `client/local`. The existing `tailnetNode` port leaks +`*ipnstate.Status`; the replacement speaks Connection's own types and publishes +`Event`. + +Owning the IPN bus watch means not calling `tsnet.Server.Up`, so we take on +what `Up` does beyond waiting for `ipn.Running` (`tsnet/tsnet.go:533`): + +| What `Up` does | How we do it | +|---|---| +| `s.LocalClient()`, which triggers `Start` | unchanged, we already call it | +| its own `lc.WatchIPNBus(NotifyInitialState)` | ours becomes the only one | +| fails on any `Notify.ErrMessage` | same, surfaced as `Failed` | +| `lc.Status` and a non-empty `TailscaleIPs` check | same call, already on the port | +| `resetServeStateOnce` | skipped | + +Skipping `resetServeStateOnce` is deliberate. It clears serve config and +service advertisements left by an earlier run of a differently configured +program, and we call neither `SetServeConfig` nor set `AdvertiseServices`, so +it has nothing of ours to clear. Both halves are reachable from exported API if +that changes: `lc.SetServeConfig`, and `EditPrefs` with `AdvertiseServicesSet`. + +`printAuthURLLoop` cannot be switched off. `go s.printAuthURLLoop()` is +unconditional in `start()` and no field or envknob guards it, so a no-op +`Server.UserLogf` is the only way to stop its prose reaching us. With a typed +`LoginRequired` event its output is not a source any more. Watchers go three to +two while a login is outstanding, then to one: it exits when the state leaves +`NeedsLogin`. + +## Ambiguous terms, resolved + +| Word | Meaning A | Meaning B | Resolution | +|---|---|---|---| +| `ApertureHost` | the remote Aperture URL (direct endpoint) | the localhost proxy address (bridged endpoint) | Split. `Endpoint` is always the remote. `Gateway` is always what a client uses. `config/global.go:63` already carries a comment apologising for the overload. | +| `host` on `endpointActivationResult` | `ep.URL` on failure | the Route's local URL on success | Becomes `Gateway`, set only on success. A failed Attempt has no Gateway. | +| Bridge | the persisted record | the running tsnet node | Split into `Bridge` and `Machine`. "The bridge is not logged in" currently cannot be read unambiguously. | +| connected | `model.connected`, meaning the last fetch succeeded | `ipn.Running` | Keep `connected` for the former only. The latter is a Phase, never surfaced by that word. | +| Status | `*ipnstate.Status` | what phase an Attempt is in | `Status` leaves the vocabulary. Phase is the only word for the second. | +| machine | the Machine we run on the user's tailnet | the computer aperture is running on, as in `browser.go:69` on which machine's clipboard an SSH session writes to | The domain object takes the word. The computer is the host. That comment needs rewording when its file is touched. | + +## Stored, derived, transient + +| Fact | Where it lives | +|---|---| +| Endpoint list, active endpoint | stored, `settings.json` | +| Bridge id, name, last tailnet | stored, `settings.json` | +| Machine tailnet credentials | stored by tsnet under the bridge state dir, never by us | +| Machine, Route | transient, process lifetime, keyed by bridge id | +| Connection Attempt, Phase, Progress | transient, attempt lifetime | +| Gateway | transient, overwritten per successful Attempt | +| Providers | derived from the Aperture, cached on `Global` | + +## Still open + +- Whether `Phase` should survive a Machine being reused. A second Attempt over + an already-open Machine skips five of the seven phases; today it silently + reports nothing at all. +- Whether the Aperture context deserves an ACL. `ParseProviders` is the whole + surface, so conformist is honest for now. diff --git a/docs/specs/connection-contracts.md b/docs/specs/connection-contracts.md new file mode 100644 index 0000000..d833410 --- /dev/null +++ b/docs/specs/connection-contracts.md @@ -0,0 +1,211 @@ +# Connection contracts + +Connection commits an edited endpoint only after verification and gives each +Machine exclusive ownership of startup, logout and cleanup. These corrections +are specified together with their data model in +[the domain model](connection-domain-model.md#lifecycle-correction-implemented-in-this-pass). + +## Security correction contracts + +`peerAddr` may map a bare hostname only to `.`; +it accepts explicit peer FQDNs across suffixes. Unknown suffixes produce no +bare-name match. Case folding and trailing-dot normalization do not relax the +suffix boundary. Non-peer fallback stays in tsnet, not in a first-label alias. + +`ParseLoginLink` keeps its accepted HTTPS URL contract, including valid query +separators; its errors never echo the input. The desktop adapter must treat the +entire link as a URL argument, not shell syntax. Native opener errors propagate +to the existing manual-login fallback. On Windows this is `ShellExecuteW`, with +no command-line parameters and the URL as its file argument. + +`LoginRequired` still delivers the full link to the interactive consumer. Its +representation in Aperture's run log is only the event fact. Raw diagnostic URL +values are replaced before emission to that log at normal and debug levels, including +rejected links, health warnings, backend chatter and startup/watch errors. +Existing log files are not rewritten; they can still contain earlier links and +must be treated as sensitive. This does not intercept the SDK's separate logtail +pipeline, which receives diagnostics before Aperture's callbacks. + +Every concurrent or subsequent `Machines.Close` joins the same cleanup and +returns the same result. No caller may report completion while another is +still tearing down a Machine. New activations are rejected once closing begins. +These are internal API/logging contracts: external APIs, domain events and the +persistence schema are otherwise unchanged. See [ADR 0004](../adr/0004-contain-connection-authority.md). + +## Lifecycle correction contracts + +`Global.SetActiveEndpoint(ep Endpoint, replacing Endpoint) error` atomically +persists `ep` first, removes duplicate `ep` entries and the original when +`replacing` is not nil, then updates in-memory settings. On a write error both +settings and the runtime host stay unchanged. Normal selection passes nil. The +existing JSON schema is unchanged; `activation.replaces` is a transient value, +never persisted. + +`Machine.Open`, `Machine.RouteTo`, `Machine.LeaveTailnet` and `Machine.Destroy` +each hold the Machine for their whole duration; a caller waiting for it can be +cancelled through its context. No Route may be created before the node is up, +and no new node may use its state directory before the prior node finishes +closing. `LeaveTailnet` calls the node's `Logout`, whose LocalAPI +initialization does not wait for `Running`. `Machines.Close` cancels the +operation each Machine is running, waits for cleanup and refuses new members. +`Attempt.Run` composes these for a connection and `Machines.Destroy` for a removal; see the domain model. + +No new domain event or external API is introduced. A pending edit is committed +by the existing successful `endpointActivationResult`; failed and stale results +do not commit. Switch intent invalidates the active runtime synchronously by +Bridge ID before the logout command is dispatched, so cancellation cannot drop +an invalidation event. Verification is the only transition back to launchable. +There is no new persistence schema or migration. + +The tables below describe the broader pass-1 event proposal, including the +explicitly deferred events. + +Two of the three contracts are deliberately absent, with reasons, rather than +left blank: + +| Contract | Status | Reason | +|---|---|---| +| API | Absent | The CLI exposes no service surface. It has no callers, so there are no caller classes to enumerate, no authentication and no authorization rule. Its outbound calls are `GET /v1/models` on an Aperture we do not own and the in-process tsnet LocalAPI, both other people's contracts. The nearest thing we define is the `Machine` port, which is covered by the domain model's behaviours. | +| DDL | Absent | No relational store. Persistence is `settings.json`, a document rewritten whole. See "Persisted facts" below for the one place the DDL rules still bite. | +| Domain events | Defined below, 100% | The refactor's whole point is replacing a `func(string)` log sink with typed events, so these are the contract that exists. | + +## Domain events + +Delivery is the same for all six and stated once rather than repeated per row: +a single in-process buffered channel per Connection Attempt, consumed by the +bubbletea update loop. One producer, one consumer, ordered, no replay, no +persistence, closed when the Attempt ends. At-least-once does not arise; the +risk here is loss, not duplication, and the rule is in the per-event rows. + +| Name | Emitting aggregate | Emitting transition | Payload | Consumers | Delivery | Boundary | Domain service | +|---|---|---|---|---|---|---|---| +| `PhaseEntered` | ConnectionAttempt | every `Enter(Phase)`, including into terminal phases | `Phase Phase`, `Progress Progress` | connect screen (label, elapsed, spinner) | never dropped; a lost phase breaks the `Trail` gap-free invariant | internal to Connection | none; `ConnectionAttempt.Enter` is the whole reaction | +| `LoginRequired` | Machine | `Starting → NeedsLogin`, on the first `Notify.BrowseToURL` | `Link LoginLink` | ConnectionAttempt (`Authorize`), connect screen (footer, ctrl+y copy, browser open) | never dropped; this is the event whose loss strands the user | internal to Connection | none; `ConnectionAttempt.Authorize` is a single aggregate method | +| `TailnetJoined` | Machine | `Joining → Open`, when the netmap carries a tailnet name | `Tailnet string` | ConnectionAttempt, Settings (`Bridge.Tailnet`) | never dropped; losing it silently un-labels the bridge in the picker | published, crosses into Settings | **missing.** See below. | +| `Noted` | Machine | none; not a transition | `Text string` | connect screen log pane only | droppable. The only droppable event, and the reason the others can state that they are not | internal to Connection | none | +| `Failed` | ConnectionAttempt | any phase `→ Failed` | `Err error` | connect screen, endpoint menu | never dropped; terminal | internal to Connection | none | +| `Ready` | ConnectionAttempt | `AskingForModels → Ready` | `Gateway Gateway`, `Providers []config.ProviderInfo` | Client Launch, connect screen, Settings (active endpoint) | never dropped; terminal | published, crosses into Client Launch | **missing.** See below. | + +### Two events whose reaction has no home + +The domain service column is the anti-anemia check, and it found two holes. +Both are cross-aggregate rules currently living in the TUI, which is an +application service and does not count. + +`TailnetJoined` spans Machine and Bridge: "the Bridge records the tailnet its +Machine joined, so the picker can name it before the Machine exists again". +Today that is `model.recordBridgeTailnet` (`tui.go:421`), which reaches into +`Machine.Tailnet` and then `g.SetBridgeTailnet`, now inside `Attempt.Commit`. Before that the TUI was loading, +calling and committing, which is orchestration, but it is also deciding the +rule, which is not. + +`Ready` spans ConnectionAttempt and whatever Client Launch reads: "a client +launched after a successful Attempt uses that Attempt's Gateway". Today that +is the assignment `m.g.ApertureHost = msg.host` (`tui.go:681`) into a shared +mutable global that five client packages read whenever they happen to run. +There is no object that owns "which Gateway is current", which is why the field +could come to mean two things without anyone deciding that it should. + +Both need a home before the events are implemented. Naming them is out of scope +for this pass; that they are unowned is the finding. + +### What shipped, and what the table is still describing + +Three of the six are in `internal/connection` (`event.go`). The other three are +not, and the gap is deliberate rather than unfinished: + +| Event | State | Why | +|---|---|---| +| `PhaseEntered` | Built, payload reduced to `Phase` | `Progress` is derivable: the connect screen already stamps every line with elapsed time from the Attempt's start, so carrying a duration in the event would be a second copy of the same clock, computed earlier and able to disagree. Add it when something off-screen needs the number. | +| `LoginRequired` | Built as specified | | +| `Noted` | Built as specified | | +| `TailnetJoined` | Not built | `Attempt.Commit` carries the fact as a field of `Gateway`; no event yet. | +| `Ready`, `Failed` | Not built | Both already travel as `endpointActivationResult` on the same channel, typed, with the same single consumer. Converting them buys nothing until the Gateway owner exists, and `Ready`'s payload is that owner's to define. | + +Six `Phase` values are built, not nine. `Ready`, `Failed` and `Cancelled` are +in the domain model because they are real states of an Attempt, but nothing +emits a `PhaseEntered` for them today, and a constant no producer writes is a +constant a reader has to go and check. They arrive with the Attempt aggregate. + +## Persisted facts + +No DDL, but the schema rules still apply to `settings.json` and one of them +bites. + +`Bridge.Tailnet` is empty until a Machine joins one, which is the JSON-document +form of a nullable `joined_at` on the primary row: a field about something that +has not happened, sitting empty on every bridge the user has created and not +yet connected. Under the rule it should be its own fact, keyed by bridge id, +with presence meaning joined. + +Recorded exception, and the reason, next to the thing it applies to: the store +is a single document rewritten whole, there is exactly one tailnet per bridge +at a time, and nothing wants the history. Splitting it would add a collection +to a settings file to model an absence that the picker already renders as +"tailnet not known yet". Revisit if a Bridge ever needs to remember more than +its current tailnet, at which point it needs a real store anyway. + +Everything else persisted is unconditional: `Endpoint.URL`, `Endpoint.BridgeID` +(empty means direct, which is a real value and not an absence), `Bridge.ID`, +`Bridge.Name`. + +## Cross-check + +With no API and no DDL, the cross-check reduces to: every aggregate transition +emits an event, or is recorded here as deliberately silent. + +| Transition | Event | Note | +|---|---|---| +| ConnectionAttempt → `StartingMachine` | `PhaseEntered` | | +| → `AwaitingLoginLink` | `PhaseEntered` | the phase that did not exist | +| → `AwaitingAuthorization` | `PhaseEntered`, preceded by `LoginRequired` | | +| → `JoiningTailnet` | `PhaseEntered` | | +| → `FindingEndpoint` | `PhaseEntered` | | +| → `AskingForModels` | `PhaseEntered` | | +| → `Ready` | `PhaseEntered`, `Ready` | | +| → `Failed` | `PhaseEntered`, `Failed` | | +| → `Cancelled` | `PhaseEntered` only | Deliberately silent beyond the phase. Cancellation is initiated by the consumer, so an event telling it what it just did carries nothing. The `Trail` still records it, which is what a later "why was this slow" question needs. | +| Machine `Starting → NeedsLogin` | `LoginRequired` | | +| Machine `Starting → Joining` | none | Deliberately silent. The credentials-on-disk path has nothing to tell the user and no cross-aggregate reaction; the Attempt's own `PhaseEntered` covers the screen. | +| Machine `NeedsLogin → Joining` | none | Same. The authorization that caused it is already on screen. | +| Machine `Joining → Open` | `TailnetJoined` | | +| Machine `→ Closed` via `Close` | none | Deliberately silent. Process teardown; there is no consumer left to react. | +| Machine `→ Closed` via `LeaveTailnet` | `Noted` | Weak, and knowingly so. The user asked to switch tailnets and wants to see it happen, but nothing reacts to it, so it does not earn a typed event yet. Promote it if Settings ever needs to clear `Bridge.Tailnet` on logout, which it arguably already does. | + +Two invariants from the model have no enforcement point outside application +code, which the skill flags and no constraint layer here can fix: + +- "Exactly one terminal outcome" and "phase only moves forward" are enforced by + `ConnectionAttempt`'s own methods and nothing below them. With no database + there is no check constraint to back them, so the constructor and the + unexported fields are the whole guarantee. That makes "no exported fields, no + setters" load-bearing rather than stylistic. +- "At most one Machine per Bridge" is enforced by a map keyed on bridge id + under a mutex. Same situation. + +## Deferred plumbing + +[ADR 0001](../adr/0001-connection-bounded-context.md) decision 2 changed what +travels between `internal/bridges` and `internal/tui` from a string to a typed +`Event`. It did not change the plumbing underneath, which has three knots that +the Machine owning a long-lived stream removes as one change, each Attempt +subscribing to it for its own lifetime. + +| Knot | Where | What it costs | +|---|---|---| +| Two identity mechanisms for "is this message from the current attempt" | `bridgeLogMsg` compares channel pointers (`tui.go:732`); everything else compares `act.id` | `bridgeLogDoneMsg` exists only to unwire the pointer one. Same question, two answers. | +| One goroutine per log line | `waitBridgeLog` receives one value and re-arms through the event loop | A `--debug` burst is a spawn per line. It is the documented bubbletea idiom for a channel, which is the argument for a subscription instead. | +| The bus watch runs only while the node is being brought up | `BringUp` owns it, and `runningNode` returns early for a cached node | A re-login on an existing Machine reports no phases and surfaces no link. `ev.enter(FindingEndpoint)` papers over the common case and nothing covers the rest. | + +A fourth was a live defect and is fixed: `runningNode` closed the node's +`UserLogf`/`DebugLogf` over the first Attempt's sink, and `startProxy` did the +same for `transport.DialContext` and `proxy.ErrorHandler`. Nodes and proxies +live for the process; Attempts do not. From the second Attempt onward every +dial diagnostic and every `Bridge proxy error` went to a cancelled channel, +which is exactly the output wanted when a bridge breaks mid-session. + +## Next pass + +Triggered by either of the two unowned reactions finding a home, or by the +model changing under implementation. Contract work is expected to loop back +into the model; this pass sent two findings there. diff --git a/docs/specs/connection-domain-model.md b/docs/specs/connection-domain-model.md new file mode 100644 index 0000000..267e47d --- /dev/null +++ b/docs/specs/connection-domain-model.md @@ -0,0 +1,445 @@ +# Connection domain model + +Connection attempts may propose an endpoint edit but cannot replace a verified +destination until success. A Machine serializes activation, logout and closure; +its presence in the cache alone does not prove it is open. Language is fixed by +[the context map](connection-context-map.md). + +## Security correction + +The existing objects gain stricter invariants, not new domain objects or stored +fields. An Endpoint's bare hostname resolves against the Machine's current +MagicDNS suffix; a shared peer needs its explicit FQDN. Missing suffix information +does not authorize a first-label match. Existing tsnet fallback for non-peer +destinations remains unchanged. + +LoginLink remains a value object with its sole `url string` field. Its value is +available to the interactive UI, browser and clipboard; parse errors contain +only rejection reasons, not the input. Windows passes it to the native URL +opener, never to a command interpreter. Aperture's run log retains the +login-required fact but omits the link. Raw bridge diagnostic URLs are redacted +before entering that log, including backend debug output and login errors; +this deliberately loses URL detail. The SDK's separate logtail pipeline is +upstream of these callbacks and is not changed by this correction. + +Shutting the Machines down is one session-lifetime operation. `Machines.Close` +uses the standard library's once-result primitive to join concurrent callers +and retain the same error. The first caller refuses new members and closes +each Machine, which cancels the operation it is running; every caller waits +until all Machines have finished closing. No new domain event, JSON field, or +migration is introduced. + +## Lifecycle correction implemented in this pass + +The following is the concrete model for [ADR 0003](../adr/0003-preserve-verified-connections.md). +The later sections retain the wider proposed event model. + +`bridges.Attempt` is the ConnectionAttempt entity as built. Its fields are +`Endpoint config.Endpoint`, `InvalidatesActive bool`, `TargetsActive bool`, +`bridge config.Bridge`, `ephemeral bool`, `replaces config.Endpoint` and +`switchTailnet bool`. +`replaces` is the original endpoint value, optional for a URL edit. Retry and +inline override retain it; success commits the new endpoint and removes the +original in one settings write. Failure leaves the original and the candidate; +cancellation removes only a candidate this attempt added. Neither outcome +changes a verified runtime destination or its providers. The TUI's +`activation` is the presentation of one Attempt: `id`, `label`, `started`, +`cancel`, the log tail, the phase shown, the login link and the inline +override editor. It holds no domain fields. + +`Machine` is an entity, identified by its Bridge and held in `Machines`. It +owns `tailnet string`, `routes map[string]*Route`, `node tailnetNode`, `ev +*liveEvents`, the turn it grants one operation at a time and the `cancel` that +lets `Close` interrupt that operation. Its behaviours are `Open`, `RouteTo`, +`LeaveTailnet`, `Destroy`, `Close` and `Tailnet`. The adapter fields stay in +`internal/bridges`; no vendor type enters a public signature. + +States are idle (no node), starting, open, and closing. `Open` holds the +Machine through startup; `RouteTo` through proxy creation and requires an open +Machine. A failed startup closes the node before releasing the Machine. +`LeaveTailnet` initializes the LocalAPI without waiting for authorization, +logs out, then closes the node and all Routes; the Machine returns to idle and +may create a new node on the next `Open`. `Destroy` is `LeaveTailnet` plus the +state directory. `Machines.Close` closes each Machine, which cancels the +operation it is running, waits for it, and rejects new operations. An +operation waiting its turn can be cancelled without affecting the one running. + +`BeginAttempt` marks the attempt as invalidating the active destination when +a tailnet switch is on the Bridge the active endpoint uses; the TUI shows that +as unverified before dispatch. It also records whether the attempt targets the +active endpoint, so a failure can leave that unverified too. This is conservative when cancellation beats +logout, since cancellation cannot prove logout did not start. Failure, Escape +and removal must not re-enable launches; only verification does. A switch on a +different bridge leaves the active destination usable. + +## ConnectionAttempt + +Entity, aggregate root. One try at reaching an Aperture. Created when the user +picks an Endpoint, ends in exactly one outcome, and is superseded by any newer +Attempt. + +### Fields + +| Field | Type | Note | +|---|---|---| +| `ID` | `int` | Monotonic per process. Identity: a result carrying a stale ID is discarded, which is how cancellation works today (`tui.go:652`). | +| `Endpoint` | `config.Endpoint` | The remote Aperture and, optionally, the Bridge that reaches it. | +| `Started` | `time.Time` | Origin for every elapsed time in `Trail`. | +| `Phase` | `Phase` | What it is waiting on now. | +| `Trail` | `[]Progress` | Every phase entered, in order. Never rewritten. | +| `Link` | `*LoginLink` | Set once, when a Machine asks for authorization. Nil for a direct Endpoint or a Machine already logged in. | +| `Gateway` | `*Gateway` | Set once, on reaching `Ready`. Nil otherwise. | +| `Err` | `error` | Set once, on reaching `Failed`. | +| `Ephemeral` | `bool` | The Endpoint was written to settings on the user's behalf, so cancelling takes it back out. | + +### Behaviors + +- `Enter(Phase) Progress` — advance, appending to `Trail`. Rejects a backwards move. +- `Authorize(LoginLink)` — record the link and enter `AwaitingAuthorization`. +- `Run(ctx, machines, emit) (Gateway, error)` — the attempt happening: leave the tailnet if asked, open the Machine, route, ask the Aperture for models. Writes nothing, so it runs off the update loop. +- `Commit(settings, Gateway) error` — persist a successful attempt: one settings write for the edit, the tailnet recorded on the Bridge, the Gateway and providers clients launch against. +- `Abandon(settings) error` — remove the candidate this attempt added, never the active endpoint. Failure is not abandonment: a failed attempt keeps its candidate for retry and edit. +- `Retarget(settings, next)` / `Retry()` — a new URL for the same edit, or the same attempt again without repeating a tailnet switch. +- Constructors `BeginAttempt(settings, endpoint, switchTailnet, replacing)` and `EditAttempt(settings, current, endpoint, next)`. Begin writes an unsaved Endpoint as the candidate and clears the Bridge's recorded tailnet before a switch. +- `Slowest() Progress` — the phase that consumed the most wall clock. This is the question a 29 second wait asks and that nothing could answer. +- `Supersedes(other ConnectionAttempt) bool` — `a.ID > other.ID`. + +### Invariants + +- Exactly one terminal outcome. After `Ready`, `Failed` or `Cancelled`, no field changes. +- `Gateway` is non-nil if and only if `Phase == Ready`. +- `Err` is non-nil if and only if `Phase == Failed`. +- Phase only moves forward through the order below, except to a terminal phase, which is reachable from anywhere. +- `Trail` covers `Started` to now with no gaps: every phase transition appends, so summing `Trail` accounts for the whole wait. This is the invariant the current code lacks, and its absence is why three fixes were aimed at an unattributed 29 seconds. +- A direct Endpoint (`BridgeID == ""`) never enters a Machine phase. + +### States + +```mermaid +stateDiagram-v2 + [*] --> AskingForModels: direct endpoint + [*] --> StartingMachine: bridged endpoint + + StartingMachine --> AwaitingLoginLink: machine needs login + StartingMachine --> JoiningTailnet: credentials already on disk + AwaitingLoginLink --> AwaitingAuthorization: control plane answered + AwaitingAuthorization --> JoiningTailnet: user authorized + JoiningTailnet --> FindingEndpoint: tailnet joined + FindingEndpoint --> AskingForModels: route open + AskingForModels --> Ready: providers parsed + + StartingMachine --> Failed + AwaitingLoginLink --> Failed + AwaitingAuthorization --> Failed + JoiningTailnet --> Failed + FindingEndpoint --> Failed + AskingForModels --> Failed + + StartingMachine --> Cancelled + AwaitingLoginLink --> Cancelled + AwaitingAuthorization --> Cancelled + JoiningTailnet --> Cancelled + FindingEndpoint --> Cancelled + AskingForModels --> Cancelled + + Ready --> [*] + Failed --> [*] + Cancelled --> [*] +``` + +### Relationships + +- 1 ConnectionAttempt → 1 Endpoint. +- 1 ConnectionAttempt → 0..1 Machine, by bridge id, not by ownership. The Machine outlives the Attempt. +- 1 ConnectionAttempt → 0..n Progress, ordered. +- 1 ConnectionAttempt → 0..1 LoginLink, 0..1 Gateway. + +## Phase + +Enumeration. Named for what the user is waiting for, not for `ipn.State`. + +| Phase | The user is waiting for | Signal it is entered | +|---|---|---| +| `StartingMachine` | the bridge to start | `tsnet` init returns a local client | +| `AwaitingLoginLink` | the control plane to hand back a login link | `ipn.NeedsLogin` with no `BrowseToURL` yet | +| `AwaitingAuthorization` | themselves, in a browser | `Notify.BrowseToURL` | +| `JoiningTailnet` | the tailnet to accept the node | `ipn.Starting`, which is the one notification that covers login finishing and the netmap landing | +| `FindingEndpoint` | the far side to appear and accept a dial | `ipn.Running`, then the peer-map wait in `waitForPeerAddr` | +| `AskingForModels` | Aperture to answer `/v1/models` | the fetch starts | +| `Ready` | nothing | providers parsed | +| `Failed` | nothing | any error | +| `Cancelled` | nothing | the user pressed Esc, or a newer Attempt started | + +`AwaitingLoginLink` is the phase that did not exist. The goroutine dump behind +this work was parked in the control plane's first `POST /machine/register` +with no follow-up URL yet, which is precisely this phase, and the screen said +only `NeedsLogin`. Distinguishing it from `AwaitingAuthorization` is the whole +point of naming phases for the waiting rather than for the backend state: +those two are the same `ipn.State` and completely different problems. + +## Progress + +Value object. One phase and what it cost. + +| Field | Type | +|---|---| +| `Phase` | `Phase` | +| `Entered` | `time.Duration` since the Attempt started | +| `Took` | `time.Duration`, zero while current | + +Behaviors: `String()` renders `+12.5s Waiting for a login link`, the format +the connect screen already uses. + +Invariants: `Entered` is monotonic across an Attempt's `Trail`. `Took` is set +exactly once, when the next phase is entered. + +## LoginLink + +Value object. The URL that authorizes a Machine. + +| Field | Type | +|---|---| +| `URL` | `string` | + +Behaviors: `ParseLoginLink(string) (LoginLink, error)`, the only constructor. +`String()`. + +Invariants: `https` scheme, no whitespace, non-empty host. These are not +cosmetic: the value is handed to a desktop opener. The checks exist today +inside `authURLFromLog` (`browser.go:35`), downstream of a `strings.Cut` on +prose, which is the wrong place for them. Tailscale applies the same rules +upstream in `validPopBrowserURLLocked`; ours is the second gate, not the first. + +## Gateway + +Value object, `bridges.Gateway`. What a successful Attempt produced and what +`Commit` makes current. + +| Field | Type | | +|---|---|---| +| `URL` | `string` | Where a client sends requests: the Endpoint's URL, or a Route's `127.0.0.1:` listener. | +| `Tailnet` | `string` | The tailnet the Machine joined. Empty for a direct Endpoint. | +| `Providers` | `[]config.ProviderInfo` | What the Aperture answered `/v1/models` with. | + +Invariants: `URL` is a non-empty absolute URL with scheme and host. + +## Machine + +Entity, aggregate root. What this program runs on the user's tailnet for one +Bridge, and what their admin console lists under Machines. Separate aggregate +from ConnectionAttempt because it is held by Bridge in `Machines` and reused +across Attempts, so it cannot be owned by any one of them. + +| Field | Type | Note | +|---|---|---| +| `bridge` | `config.Bridge` | Identity is its ID. At most one Machine per Bridge. | +| `tailnet` | `string` | The network joined, empty until the netmap lands and after leaving. | +| `routes` | `map[string]*Route` | Keyed by target URL. | + +Behaviors: `Open(ctx, emit) error`, `RouteTo(ctx, url, emit) (*Route, error)`, +`LeaveTailnet(ctx, emit) error`, `Destroy(ctx, emit) error`, `Close() error`, +`Tailnet() string`. Each reports what it waits on to `emit`. + +Invariants: +- A Route can only be created through an open Machine. `RouteTo` fails rather than starts a node. +- One operation at a time, cleanup included. Two Machines for one Bridge would open the same state directory, so only `Machines` creates them. +- `LeaveTailnet` logs out before closing: credentials live behind the node's own LocalAPI, so a close without a logout silently reuses them next time. `Destroy` also discards the state directory, last and only on success, because it holds the key a later attempt needs to deregister. +- Closing closes every Route first. +- Exactly one IPN bus watch per Machine. + +### States + +```mermaid +stateDiagram-v2 + [*] --> Starting: Open + Starting --> NeedsLogin: no credentials + Starting --> Joining: credentials on disk + NeedsLogin --> Joining: authorized + Joining --> Open: netmap received + Open --> Closed: Close + Open --> Closed: LeaveTailnet + Starting --> Closed: error + NeedsLogin --> Closed: cancelled + Joining --> Closed: error + Closed --> [*] +``` + +## Route + +Entity, inside the Machine aggregate. The local door to one Endpoint. + +| Field | Type | +|---|---| +| `LocalURL` | `string`, a `127.0.0.1:` listener. The Gateway a client uses. | + +Behaviors: `close() error`, reached only through its Machine. + +Invariants: belongs to exactly one Machine and one Endpoint. Its listener is +bound to loopback only. Resolves the target against the Machine's own peer map +before dialing, never the host resolver, because the host may itself be on a +tailnet with a same-named node. + +## Machines + +Collection. The process's Machines, one per Bridge, and the only place a +Machine is created. Getting a member does no network work. + +Behaviors: `For(Bridge) (*Machine, error)`, which creates an idle member on +first use and refuses after `Close`; `Close() error`, which closes every +member and lets concurrent callers share one result. + +Invariants: at most one Machine per Bridge ID. A Bridge ID that is not the +generated `bridge-` shape is refused before it can become a hostname. + +## Removing a Bridge + +The one transition no single aggregate owns: a Bridge record and the Machine +registered for it go together, Machine first (ADR 0002). Four functions in +`internal/bridges` and a Settings rule. + +| Operation | Runs on | Does | +|---|---|---| +| `CheckRemovable(settings, bridge, endpoint)` | update loop | Returns an error when the endpoint, or the bare bridge, cannot be removed: the active endpoint, or a bridge an endpoint still connects through. | +| `WillDestroyMachine(settings, bridge, endpoint)` | update loop | Reports whether removing the endpoint, or the bare bridge, logs a device out of a tailnet: the bridge started a Machine and no other endpoint connects through it. | +| `Machines.Destroy(ctx, bridge, emit)` | any goroutine | The bounded logout (ADR 0002 decision 6). Returns an error when the tailnet refuses or does not answer in time. Writes nothing. | +| `RemoveFromSettings(settings, bridge, endpoint)` | update loop | Deletes the endpoint, then the bridge when nothing connects through it. Called only after `Destroy` returned nil, or when nothing needs destroying. | +| `Machines.Tailnet(bridge)` | update loop | The tailnet the running Machine reports, else the one saved on the bridge. | + +Any failure keeps the records. They are the only thing naming the device, and +removing the connection again retries the logout. The failure message names +the device so the user can delete it in the admin console if the retry finds +nothing to log out. + +The split between the goroutine half and the update-loop half is not +stylistic, here or on ConnectionAttempt. Nothing serializes access to +`config.Global`; the bubbletea update loop is the only place settings are +read, so it is the only place they may be written. + +## Event + +Value object. What the Connection context publishes as an Attempt proceeds. +This replaces the `func(string)` log sink and the `chan bridgeLine`. + +| Event | Carries | Meaning | +|---|---|---| +| `PhaseEntered` | `Phase`, `Progress` | The Attempt advanced. | +| `LoginRequired` | `LoginLink` | Authorization is needed at this link. | +| `TailnetJoined` | `string` | The Machine is on this network. | +| `Noted` | `string` | Diagnostics with no domain meaning: tsnet backend chatter, dial detail. | +| `Failed` | `error` | Terminal. | +| `Ready` | `Gateway` | Terminal. | + +Invariants: +- `Noted` is the only droppable event. Everything else must be delivered even + under a full buffer. The current sink drops on a full channel + (`bridgeLogSink`, `tui.go:557`, a `select` with `default`) and with `--debug` + the tsnet backend logger shares that 32-slot channel, so the login link can + be discarded by a burst of chatter. A typed stream makes that a rule instead + of an accident. +- `Ready` and `Failed` are mutually exclusive and each occurs at most once. +- No event carries a vendor type. `*ipnstate.Status` never crosses this line. + +## Everything at once + +```mermaid +erDiagram + ConnectionAttempt ||--|| Endpoint : "targets" + ConnectionAttempt ||--o{ Progress : "records" + ConnectionAttempt ||--o| LoginLink : "shows" + ConnectionAttempt ||--o| Gateway : "yields" + ConnectionAttempt ||--o{ Event : "publishes" + ConnectionAttempt }o--o| Machine : "uses" + Endpoint }o--o| Bridge : "reached through" + Bridge ||--o| Machine : "runs as" + Machine ||--o{ Route : "carries" + Route ||--|| Endpoint : "fronts" + Route ||--|| Gateway : "is reached as" +``` + +```mermaid +classDiagram + class ConnectionAttempt { + +int ID + +Endpoint Endpoint + +time.Time Started + +Phase Phase + +[]Progress Trail + +*LoginLink Link + +*Gateway Gateway + +error Err + +bool Ephemeral + +Enter(Phase) Progress + +Authorize(LoginLink) + +Run(ctx, machines, emit) Gateway + +Commit(settings, Gateway) + +Abandon(settings) + +Slowest() Progress + +Supersedes(ConnectionAttempt) bool + } + class Phase { + <> + StartingMachine + AwaitingLoginLink + AwaitingAuthorization + JoiningTailnet + FindingEndpoint + AskingForModels + Ready + Failed + Cancelled + } + class Progress { + +Phase Phase + +Duration Entered + +Duration Took + +String() string + } + class LoginLink { + +string URL + +String() string + } + class Gateway { + +string URL + +bool ViaBridge + +String() string + } + class Machine { + +string BridgeID + +string Tailnet + +Open(ctx) chan Event + +RouteTo(Endpoint) Route + +LeaveTailnet(ctx) error + +Close() error + } + class Route { + +string LocalURL + +Endpoint Target + +Gateway() Gateway + +Close() error + } + ConnectionAttempt --> Phase + ConnectionAttempt --> Progress + ConnectionAttempt --> LoginLink + ConnectionAttempt --> Gateway + ConnectionAttempt ..> Machine + Machine --> Route + Route --> Gateway +``` + +## Open, not assumed + +- Which Gateway is current for the next client launch is now `Attempt.Commit` + writing `Global.ApertureHost`, and recording the tailnet a Machine joined is + the same commit. Whether that Gateway is still verified is the TUI's + `connected` flag, set from `InvalidatesActive` and `TargetsActive`. No object owns + "the current Gateway and whether it is verified"; `Global` holds the URL and + the TUI holds the bit. +- Whether a reused Machine should replay its phases to a second Attempt or + report a single `FindingEndpoint`. Today it reports nothing, which looks like + a hang for as long as the peer wait takes. +- Whether `Trail` should be surfaced to the user at all, or only on failure and + under `--debug`. Timing every phase is worth doing regardless; showing it + always is a separate question. +- Whether `Route` deserves a lifecycle of its own. It is currently created once + and closed with its Machine, so it has no interesting states, and a state + machine for it would be invented rather than observed. diff --git a/go.mod b/go.mod index b81f9b8..3a697de 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/tailscale/aperture-cli go 1.26.6 require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.11.6 + golang.org/x/sys v0.47.0 tailscale.com v1.102.3 ) @@ -13,7 +15,6 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/akutz/memconn v0.1.0 // indirect github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -62,7 +63,6 @@ require ( golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/internal/bridges/attempt.go b/internal/bridges/attempt.go new file mode 100644 index 0000000..afccc59 --- /dev/null +++ b/internal/bridges/attempt.go @@ -0,0 +1,254 @@ +package bridges + +import ( + "context" + "fmt" + "log/slog" + "slices" + "sync/atomic" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +// Attempt is one try at reaching an Aperture from one Endpoint, the +// ConnectionAttempt of the domain model. It remembers what it wrote to +// settings on the user's behalf, so Abandon can take that back out. It also +// remembers which Endpoint it is an edit of, so Commit can replace the +// original in the same write (ADR 0003). +// +// Run waits on the network and writes nothing, so it may run on any +// goroutine. Every other method reads or writes settings and must run where +// settings are read. For the TUI that is its update loop: nothing else +// serializes access to config.Global. +type Attempt struct { + Endpoint config.Endpoint + // InvalidatesActive is true when starting this attempt leaves the active + // destination unverified: the attempt logs out the Machine the active + // endpoint connects through, and cancellation cannot prove the logout did + // not run (ADR 0003). + InvalidatesActive bool + // TargetsActive is true when this attempt targets the active Endpoint, so + // its failure leaves the active destination unverified too. + TargetsActive bool + + bridge config.Bridge + // ephemeral is true when BeginAttempt wrote Endpoint into settings so the + // failure screen has something to name, retry and edit. Abandon removes + // that record; failure keeps it. + ephemeral bool + replaces config.Endpoint + switchTailnet bool + // tailnetLeft is set by Run once the logout a switch asked for has + // succeeded. Run sets it on its own goroutine; Retry and Retarget read it + // on the update loop. + tailnetLeft atomic.Bool +} + +// BeginAttempt prepares an attempt at ep. An Endpoint not yet in settings is +// written there first, so the failure screen has something to name, retry and +// edit, and the attempt remembers it did that. replacing is the original of a +// URL edit, kept until the edit verifies. switchTailnet logs the Bridge out on +// the way and clears the tailnet recorded on it now, because an abandoned +// login would otherwise leave the picker naming a tailnet the bridge has +// already left. +func BeginAttempt(g *config.Global, ep config.Endpoint, switchTailnet bool, replacing config.Endpoint) (*Attempt, error) { + if ep == nil { + return nil, fmt.Errorf("no endpoint to connect to") + } + a := &Attempt{Endpoint: ep, replaces: replacing, TargetsActive: ep == g.ActiveEndpoint()} + if bridged, ok := ep.(config.BridgeEndpoint); ok { + bridge, found := g.Bridge(bridged.BridgeID()) + if !found { + return nil, fmt.Errorf("bridge %s is not configured", bridged.BridgeID()) + } + a.bridge = bridge + if switchTailnet { + if err := g.SetBridgeTailnet(bridge.ID, ""); err != nil { + return nil, err + } + a.switchTailnet = true + active, _ := g.ActiveEndpoint().(config.BridgeEndpoint) + a.InvalidatesActive = active.BridgeID() == bridge.ID + } + } + if !slices.Contains(g.Settings.Endpoints, ep) { + if err := g.UpsertEndpoint(ep); err != nil { + return nil, err + } + a.ephemeral = true + } + return a, nil +} + +// EditAttempt starts verifying next as a replacement for ep, keeping ep until +// next verifies (ADR 0003). When current is already an edit of ep, the new +// URL retargets it and the original stays the original. Otherwise a new +// attempt replaces ep. +func EditAttempt(g *config.Global, current *Attempt, ep, next config.Endpoint) (*Attempt, error) { + if current != nil && current.Endpoint == ep && current.replaces != nil { + return current.Retarget(g, next) + } + return BeginAttempt(g, next, false, ep) +} + +// Retarget returns an attempt at next, the URL the user typed, keeping the +// original of a pending edit. A candidate this attempt added is replaced +// rather than left behind: it was never reachable and nobody asked for it. +// A pending tailnet switch on the same Bridge carries over until its logout +// has succeeded. The same Endpoint again is a retry. +func (a *Attempt) Retarget(g *config.Global, next config.Endpoint) (*Attempt, error) { + if next == a.Endpoint { + return a.Retry(), nil + } + ephemeral := !slices.Contains(g.Settings.Endpoints, next) + switch { + case a.ephemeral: + if err := g.ReplaceEndpoint(a.Endpoint, next); err != nil { + return nil, err + } + case ephemeral: + if err := g.UpsertEndpoint(next); err != nil { + return nil, err + } + } + n := &Attempt{Endpoint: next, replaces: a.replaces, ephemeral: ephemeral, TargetsActive: next == g.ActiveEndpoint()} + if bridged, ok := next.(config.BridgeEndpoint); ok { + bridge, found := g.Bridge(bridged.BridgeID()) + if !found { + return nil, fmt.Errorf("bridge %s is not configured", bridged.BridgeID()) + } + n.bridge = bridge + if bridge.ID == a.bridge.ID && a.switchPending() { + n.switchTailnet = true + active, _ := g.ActiveEndpoint().(config.BridgeEndpoint) + n.InvalidatesActive = active.BridgeID() == bridge.ID + } + } + return n, nil +} + +// Retry returns the same attempt again. A tailnet switch whose logout already +// succeeded is not repeated; one whose logout failed or never ran still is, +// since retrying without it would open the credentials still on disk and +// reconnect to the tailnet the user asked to leave. +func (a *Attempt) Retry() *Attempt { + return &Attempt{ + Endpoint: a.Endpoint, + TargetsActive: a.TargetsActive, + bridge: a.bridge, + ephemeral: a.ephemeral, + replaces: a.replaces, + switchTailnet: a.switchPending(), + } +} + +// switchPending reports whether this attempt still owes its Bridge a logout. +func (a *Attempt) switchPending() bool { + return a.switchTailnet && !a.tailnetLeft.Load() +} + +// Bridge returns the Bridge this attempt connects through, or a zero Bridge +// for a direct Endpoint. +func (a *Attempt) Bridge() config.Bridge { return a.bridge } + +// SwitchesTailnet reports whether the attempt logs its Bridge out before +// connecting. +func (a *Attempt) SwitchesTailnet() bool { return a.switchTailnet } + +// Ephemeral reports whether this attempt wrote its Endpoint into settings. +func (a *Attempt) Ephemeral() bool { return a.ephemeral } + +// Gateway is where a client sends requests once an attempt has succeeded: +// the URL, the providers the Aperture answered with and, through a Bridge, +// the tailnet the Machine joined. +type Gateway struct { + URL string + Tailnet string + Providers []config.ProviderInfo +} + +// Run carries the attempt to a Gateway or an error, reporting each wait on +// emit. It writes nothing. Commit writes, once the caller knows the result is +// still wanted. +func (a *Attempt) Run(ctx context.Context, machines *Machines, emit func(connection.Event)) (Gateway, error) { + bridged, ok := a.Endpoint.(config.BridgeEndpoint) + if !ok { + provs, err := fetchProviders(ctx, a.Endpoint.URL(), providerFetchTimeout) + if err != nil { + return Gateway{}, err + } + return Gateway{URL: a.Endpoint.URL(), Providers: provs}, nil + } + // Stamp the moment the user committed. Without it the first bridge line + // is the earliest thing in the log and the gap in front of it reads as + // startup cost rather than someone reading the menu. + slog.Info("activating endpoint", "url", redactURL(bridged.URL()), "bridge", a.bridge.ID, "switchTailnet", a.switchTailnet) + mc, err := machines.For(a.bridge) + if err != nil { + return Gateway{}, err + } + // The switch shares the attempt's cancellation and event sink. The new + // login link is what the user needs on screen, and Esc has to reach a + // logout that stalls on the old tailnet. + if a.switchTailnet { + if err := mc.LeaveTailnet(ctx, emit); err != nil { + return Gateway{}, err + } + a.tailnetLeft.Store(true) + } + if err := mc.Open(ctx, emit); err != nil { + return Gateway{}, err + } + route, err := mc.RouteTo(ctx, bridged.URL(), emit) + if err != nil { + return Gateway{}, err + } + // The longest silent stretch of the attempt. The bridge is up, so tsnet + // has stopped logging and nothing else names the host being waited on. + sink(emit).enter(connection.AskingForModels) + provs, err := fetchProviders(ctx, route.LocalURL, bridgeProviderFetchTimeout) + if err != nil { + return Gateway{}, fmt.Errorf("bridge %s could not reach %s: %w", a.bridge.Name, bridged.URL(), err) + } + return Gateway{URL: route.LocalURL, Tailnet: mc.Tailnet(), Providers: provs}, nil +} + +// Commit makes a successful attempt the active connection. The Endpoint +// moves to the front of settings and a pending edit's original goes in the +// same write (ADR 0003). The tailnet joined is recorded on the Bridge so the +// picker can name it before the Machine exists again. The Gateway becomes +// what clients launch against. +func (a *Attempt) Commit(g *config.Global, gw Gateway) error { + if g.ActiveEndpoint() != a.Endpoint || a.replaces != nil { + if err := g.SetActiveEndpoint(a.Endpoint, a.replaces); err != nil { + return fmt.Errorf("could not save active endpoint: %w", err) + } + } + a.replaces = nil + a.ephemeral = false + if a.bridge.ID != "" && gw.Tailnet != "" { + // A failed write is not worth interrupting a connection that worked. + if err := g.SetBridgeTailnet(a.bridge.ID, gw.Tailnet); err != nil { + slog.Warn("could not record the bridge's tailnet", "bridge", a.bridge.ID, "err", err) + } + } + g.ApertureHost = gw.URL + g.Providers = gw.Providers + return nil +} + +// Abandon removes the candidate this attempt added to settings, so nothing +// the user did not choose is left behind. Call it when the user gives up on +// the attempt. A failed attempt is not abandoned: its candidate stays for +// retry and edit. +func (a *Attempt) Abandon(g *config.Global) error { + if a == nil || !a.ephemeral { + return nil + } + if err := g.RemoveEndpoint(a.Endpoint); err != nil { + return err + } + a.ephemeral = false + return nil +} diff --git a/internal/bridges/attempt_test.go b/internal/bridges/attempt_test.go new file mode 100644 index 0000000..976fb65 --- /dev/null +++ b/internal/bridges/attempt_test.go @@ -0,0 +1,111 @@ +package bridges + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// settingsGlobal is a Global whose settings are on disk under a throwaway +// config directory, so the writes the Attempt makes can be checked and broken. +func settingsGlobal(t *testing.T, s config.Settings) (*config.Global, string) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + if err := config.SaveSettings(s); err != nil { + t.Fatal(err) + } + return &config.Global{Settings: s}, filepath.Join(dir, "aperture") +} + +// A failed RemoveEndpoint has to leave the attempt still ephemeral: otherwise +// the candidate it wrote stays in settings and nothing will ever take it out. +func TestAbandonStaysEphemeralWhenTheDropFails(t *testing.T) { + g, settingsDir := settingsGlobal(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}) + a, err := BeginAttempt(g, config.Direct("http://new"), false, nil) + if err != nil { + t.Fatal(err) + } + if !a.Ephemeral() { + t.Fatal("an endpoint not in settings did not make the attempt ephemeral") + } + + if err := os.Chmod(settingsDir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(settingsDir, 0o700) }) + if err := a.Abandon(g); err == nil { + t.Fatal("Abandon wrote to a read-only settings directory") + } + if !a.Ephemeral() { + t.Fatal("attempt forgot its candidate after a drop that did not happen") + } + + if err := os.Chmod(settingsDir, 0o700); err != nil { + t.Fatal(err) + } + if err := a.Abandon(g); err != nil { + t.Fatalf("Abandon after the directory came back: %v", err) + } + if len(g.Settings.Endpoints) != 1 { + t.Errorf("endpoints = %+v, want the candidate gone", g.Settings.Endpoints) + } +} + +// switchingAttempt is an attempt through Work that has been asked to leave its +// tailnet, on a collection whose node answers Logout with logoutErr and then +// refuses to come up, so Run ends right after the switch. +func switchingAttempt(t *testing.T, logoutErr error) (*Attempt, *config.Global, *Machines) { + t.Helper() + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work", Tailnet: "corp.example.com"} + ep := config.Bridged("http://ai", bridge.ID) + g, _ := settingsGlobal(t, config.Settings{Bridges: []config.Bridge{bridge}, Endpoints: []config.Endpoint{ep}}) + a, err := BeginAttempt(g, ep, true, nil) + if err != nil { + t.Fatal(err) + } + m := NewMachines(false) + t.Cleanup(func() { m.Close() }) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { + return &fakeNode{logoutErr: logoutErr, upErr: errors.New("no login yet")} + } + return a, g, m +} + +// A logout the control plane refused has not happened. Retrying the attempt +// with the switch dropped would open the credentials still on disk and land +// back on the tailnet the user asked to leave. +func TestRetryKeepsTheSwitchUntilLogoutSucceeds(t *testing.T) { + a, _, m := switchingAttempt(t, errors.New("control plane said no")) + if _, err := a.Run(context.Background(), m, nil); err == nil { + t.Fatal("Run succeeded with a logout the tailnet refused") + } + if !a.Retry().SwitchesTailnet() { + t.Error("Retry dropped a switch whose logout never ran") + } + + a, _, m = switchingAttempt(t, nil) + if _, err := a.Run(context.Background(), m, nil); err == nil { + t.Fatal("Run succeeded past a node that refuses to come up") + } + if a.Retry().SwitchesTailnet() { + t.Error("Retry repeats a logout that already succeeded") + } +} + +// Typing a URL over a switch that has not logged out yet must not turn it +// into a plain reconnect to the old tailnet. +func TestRetargetKeepsAPendingSwitch(t *testing.T) { + a, g, _ := switchingAttempt(t, nil) + next, err := a.Retarget(g, config.Bridged("http://aperture.example.com", a.Bridge().ID)) + if err != nil { + t.Fatal(err) + } + if !next.SwitchesTailnet() { + t.Error("Retarget dropped the switch before its logout ran") + } +} diff --git a/internal/bridges/bridging_test.go b/internal/bridges/bridging_test.go new file mode 100644 index 0000000..bd70d4f --- /dev/null +++ b/internal/bridges/bridging_test.go @@ -0,0 +1,108 @@ +package bridges + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/tailscale/aperture-cli/internal/config" +) + +func TestFetchProvidersIncludesErrorResponseBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "bridge proxy error: lookup aperture", http.StatusBadGateway) + })) + defer srv.Close() + + _, err := fetchProviders(context.Background(), srv.URL, time.Minute) + if err == nil || !strings.Contains(err.Error(), "lookup aperture") { + t.Fatalf("fetchProviders error = %v, want response detail", err) + } +} + +func TestFetchProvidersUsesModelsEndpoint(t *testing.T) { + srv := modelsServerWithHandler(t, func(r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) + } + if r.URL.Path != "/v1/models" { + t.Errorf("path = %q, want /v1/models", r.URL.Path) + } + if got := r.Header.Get("User-Agent"); got != "aperture-cli" { + t.Errorf("User-Agent = %q, want aperture-cli", got) + } + }) + + got, err := fetchProviders(context.Background(), srv.URL+"/", time.Minute) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].ID != "anthropic" || !got[0].SupportsEndpoint(config.EndpointAnthropicMessages) { + t.Fatalf("fetchProviders() = %#v, want Anthropic Messages provider", got) + } +} + +func TestFetchProvidersHonorsCancellation(t *testing.T) { + requestStarted := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requestStarted) + <-r.Context().Done() + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + go func() { + _, err := fetchProviders(ctx, srv.URL, time.Minute) + result <- err + }() + <-requestStarted + cancel() + + if err := <-result; !errors.Is(err, context.Canceled) { + t.Fatalf("fetchProviders error = %v, want context canceled", err) + } +} + +func modelsServerWithHandler(t *testing.T, check func(*http.Request)) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if check != nil { + check(r) + } + _, _ = w.Write([]byte(`{ + "object":"list", + "data":[{ + "id":"claude-opus-5", + "supported_endpoints":["/v1/messages"], + "metadata":{"provider":{ + "id":"anthropic","name":"Anthropic","description":"", + "requires_client_auth":false,"upstream":"anthropic" + }} + }] + }`)) + })) + t.Cleanup(srv.Close) + return srv +} + +// ParseEndpointURL accepts a query, so a saved endpoint can carry one. The +// models path has to go on the path, not after the query. +func TestFetchProvidersKeepsTheEndpointQuery(t *testing.T) { + srv := modelsServerWithHandler(t, func(r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("path = %q, want /v1/models", r.URL.Path) + } + if got := r.URL.Query().Get("token"); got != "x" { + t.Errorf("token = %q, want the endpoint's query kept", got) + } + }) + + if _, err := fetchProviders(context.Background(), srv.URL+"?token=x", time.Minute); err != nil { + t.Fatal(err) + } +} diff --git a/internal/bridges/bringup_test.go b/internal/bridges/bringup_test.go new file mode 100644 index 0000000..174c8fd --- /dev/null +++ b/internal/bridges/bringup_test.go @@ -0,0 +1,103 @@ +package bridges + +import ( + "context" + "errors" + "net/netip" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/connection" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" +) + +// notifies replays a recorded bus. The second return of Next is what a closed +// watch gives the caller, so a sequence that never reaches Running ends the +// loop rather than hanging it. +type notifies struct { + seq []*ipn.Notify + next int +} + +func (n *notifies) Next() (ipn.Notify, error) { + if n.next >= len(n.seq) { + return ipn.Notify{}, errors.New("bus closed") + } + notify := n.seq[n.next] + n.next++ + return *notify, nil +} + +func addressed(addr string) *ipnstate.Status { + return &ipnstate.Status{TailscaleIPs: []netip.Addr{netip.MustParseAddr(addr)}} +} + +// TestBringUpReportsFromTheWatchItWaitsOn is ADR 0001 decision 4: one watch +// both names the wait and decides when it is over. Two watchers on a backend +// that assumes one is how a lagging consumer is evicted mid-login and reported +// as an unrelated bring-up failure. +func TestBringUpReportsFromTheWatchItWaitsOn(t *testing.T) { + const url = "https://login.tailscale.com/a/28ba393017981" + var got []string + want := addressed("100.64.0.2") + + status, err := bringUp( + context.Background(), + ¬ifies{seq: []*ipn.Notify{ + state(ipn.NoState), + browse(url), + state(ipn.Starting), + state(ipn.Running), + }}, + func(context.Context) (*ipnstate.Status, error) { return want, nil }, + collect(&got), + ) + if err != nil || status != want { + t.Fatalf("bringUp = %v, %v; want the running status", status, err) + } + reported := strings.Join(got, "\n") + for _, line := range []string{ + connection.AwaitingLoginLink.String(), + connection.LoginRequired(mustLink(t, url)).String(), + connection.JoiningTailnet.String(), + } { + if !strings.Contains(reported, line) { + t.Errorf("bring-up never reported %q:\n%s", line, reported) + } + } +} + +// TestBringUpFailsOnABackendError keeps what tsnet.Up did with ErrMessage: it +// is terminal, and a bring-up that kept waiting on it would sit on its last +// phase for as long as the user let it. +func TestBringUpFailsOnABackendError(t *testing.T) { + msg := "IPN bus consumer fell behind" + _, err := bringUp( + context.Background(), + ¬ifies{seq: []*ipn.Notify{{ErrMessage: &msg}, state(ipn.Running)}}, + func(context.Context) (*ipnstate.Status, error) { + t.Error("status fetched after a backend error") + return nil, nil + }, + sink(nil), + ) + if err == nil || !strings.Contains(err.Error(), msg) { + t.Fatalf("bringUp error = %v, want the backend message", err) + } +} + +// TestBringUpRefusesARunningNodeWithNoAddress is tsnet.Up's own check, and it +// has to survive the move: Running with no address dials nothing, and failing +// here names the node instead of the endpoint. +func TestBringUpRefusesARunningNodeWithNoAddress(t *testing.T) { + _, err := bringUp( + context.Background(), + ¬ifies{seq: []*ipn.Notify{state(ipn.Running)}}, + func(context.Context) (*ipnstate.Status, error) { return &ipnstate.Status{}, nil }, + sink(nil), + ) + if err == nil || !strings.Contains(err.Error(), "address") { + t.Fatalf("bringUp error = %v, want a running node with no address refused", err) + } +} diff --git a/internal/bridges/events.go b/internal/bridges/events.go new file mode 100644 index 0000000..789a5f5 --- /dev/null +++ b/internal/bridges/events.go @@ -0,0 +1,98 @@ +package bridges + +import ( + "log/slog" + "net/url" + "regexp" + "sync" + + "github.com/tailscale/aperture-cli/internal/connection" +) + +// eventRelay forwards a Machine's events to the attempt using the Machine +// now. The node and its proxies outlive the attempt that built them, and a +// closure that captured the first attempt's sink kept writing to a channel +// nobody read, losing every later dial failure and proxy error. +// +// Nothing clears the relay when an attempt ends. A finished sink discards what +// it is given, and clearing would need a lifecycle hook only the Attempt +// could own. +type eventRelay struct { + mu sync.Mutex + to events +} + +// forwardTo makes ev the current recipient. +func (r *eventRelay) forwardTo(ev events) { + r.mu.Lock() + defer r.mu.Unlock() + r.to = ev +} + +// emit has the events signature, so events(r.emit) gives callers note and +// notef. +func (r *eventRelay) emit(e connection.Event) { + r.mu.Lock() + ev := r.to + r.mu.Unlock() + if ev != nil { + ev(e) + } +} + +// events receives what a bridge reports: the phase it entered, the login +// link it needs visited, or a note for the user. This package translates +// tsnet's vocabulary into connection.Event and publishes nothing else, so no +// caller has to match log lines from a vendored package. +type events func(connection.Event) + +// sink wraps emit, which may be nil, as an events that also writes to the +// run log. +func sink(emit func(connection.Event)) events { + return func(e connection.Event) { + logEvent(e) + if emit != nil { + emit(e) + } + } +} + +// logEvent writes an event to the run log. The connect screen dies with the +// process, and the run anyone wants to read back is the one that was killed +// halfway through. Notes log at debug: under -debug they carry tsnet's +// backend logger, and a phase is worth reading without wading through that. +func logEvent(e connection.Event) { + switch { + case e.Phase != 0: + slog.Info("bridge phase", "phase", e.Phase) + case e.Link != nil: + slog.Info("bridge needs login") + default: + slog.Debug("bridge note", "text", redactDiagnostic(e.Note)) + } +} + +// Backend diagnostics can repeat the login link, which authorizes a device. +// The link stays in the interactive event only: even debug logs get shared +// for support. +var diagnosticURL = regexp.MustCompile(`(?i)https?://\S+`) + +func redactDiagnostic(text string) string { + return diagnosticURL.ReplaceAllString(text, "[redacted URL]") +} + +func (e events) note(text string) { e(connection.Note(text)) } +func (e events) notef(format string, args ...any) { e(connection.Notef(format, args...)) } +func (e events) enter(p connection.Phase) { e(connection.Entered(p)) } +func (e events) loginRequired(link connection.LoginLink) { e(connection.LoginRequired(link)) } + +// redactURL keeps only the scheme and host. ParseEndpointURL accepts userinfo +// and a query, and the run log is the file people share when asking for +// help. +func redactURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "[redacted URL]" + } + return u.Scheme + "://" + u.Host +} diff --git a/internal/bridges/fetch.go b/internal/bridges/fetch.go new file mode 100644 index 0000000..677c8f9 --- /dev/null +++ b/internal/bridges/fetch.go @@ -0,0 +1,61 @@ +package bridges + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const ( + providerFetchTimeout = 10 * time.Second + bridgeProviderFetchTimeout = 30 * time.Second +) + +// fetchProviders asks the Aperture at host what it serves. This request is +// the attempt's AskingForModels phase, and its success is what verifies the +// connection. +func fetchProviders(ctx context.Context, host string, timeout time.Duration) ([]config.ProviderInfo, error) { + client := &http.Client{Timeout: timeout} + // Append to the path, not the string. ParseEndpointURL accepts a query, + // and appending to "host?token=x" put the models path inside the query. + base, err := url.Parse(host) + if err != nil { + return nil, err + } + target := base.JoinPath("v1", "models").String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + // Aperture intentionally filters model results for Claude Code user agents. + // Discovery needs the full grant-filtered model list for every harness. + req.Header.Set("User-Agent", "aperture-cli") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + detail := strings.TrimSpace(string(body)) + if detail != "" { + return nil, fmt.Errorf("unexpected status %d from %s: %s", resp.StatusCode, target, detail) + } + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, target) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + provs, err := config.ParseProviders(body) + if err != nil { + return nil, fmt.Errorf("could not parse models response: %w", err) + } + return provs, nil +} diff --git a/internal/bridges/helpers_test.go b/internal/bridges/helpers_test.go new file mode 100644 index 0000000..28bae43 --- /dev/null +++ b/internal/bridges/helpers_test.go @@ -0,0 +1,61 @@ +package bridges + +import ( + "context" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +// activateMachine is the bridged half of Bridging.Run without settings: open +// the bridge's Machine and route to remoteURL. Most tests here want exactly +// that shape. +func activateMachine(ms *Machines, ctx context.Context, bridge config.Bridge, remoteURL string, emit func(connection.Event)) (string, error) { + mc, err := ms.For(bridge) + if err != nil { + return "", err + } + if err := mc.Open(ctx, emit); err != nil { + return "", err + } + route, err := mc.RouteTo(ctx, remoteURL, emit) + if err != nil { + return "", err + } + return route.LocalURL, nil +} + +func switchTailnet(ms *Machines, ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { + mc, err := ms.For(bridge) + if err != nil { + return err + } + return mc.LeaveTailnet(ctx, emit) +} + +func destroyMachine(ms *Machines, ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { + mc, err := ms.For(bridge) + if err != nil { + return err + } + return mc.Destroy(ctx, emit) +} + +func tailnetOf(ms *Machines, bridgeID string) string { + mc := ms.lookup(bridgeID) + if mc == nil { + return "" + } + return mc.Tailnet() +} + +// mustLink is a LoginLink for a test that already knows the URL is valid. +func mustLink(t *testing.T, raw string) connection.LoginLink { + t.Helper() + link, err := connection.ParseLoginLink(raw) + if err != nil { + t.Fatal(err) + } + return link +} diff --git a/internal/bridges/lifecycle_test.go b/internal/bridges/lifecycle_test.go new file mode 100644 index 0000000..b6d7b64 --- /dev/null +++ b/internal/bridges/lifecycle_test.go @@ -0,0 +1,365 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "tailscale.com/ipn/ipnstate" +) + +type pendingNode struct { + *fakeNode + started, cancelling, releaseUp, closing, releaseClose chan struct{} +} + +func (n *pendingNode) BringUp(ctx context.Context, _ events) (*ipnstate.Status, error) { + close(n.started) + <-ctx.Done() + close(n.cancelling) + <-n.releaseUp + return nil, ctx.Err() +} + +func (n *pendingNode) Close() error { + close(n.closing) + <-n.releaseClose + return n.fakeNode.Close() +} + +func TestActivateWaitsForCancelledNodeCleanup(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + n := &pendingNode{ + fakeNode: &fakeNode{}, started: make(chan struct{}), cancelling: make(chan struct{}), + releaseUp: make(chan struct{}), closing: make(chan struct{}), releaseClose: make(chan struct{}), + } + var upOnce, closeOnce sync.Once + releaseUp := func() { upOnce.Do(func() { close(n.releaseUp) }) } + releaseClose := func() { closeOnce.Do(func() { close(n.releaseClose) }) } + defer releaseUp() + defer releaseClose() + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) + defer backend.Close() + replacement := &fakeNode{backendAddr: backend.Listener.Addr().String()} + m := NewMachines(false) + calls := 0 + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { + calls++ + if calls == 1 { + return n + } + return replacement + } + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + first := make(chan error, 1) + go func() { + _, err := activateMachine(m, ctx, bridge, "http://ai", nil) + first <- err + }() + <-n.started + cancel() + <-n.cancelling + // A replacement must wait for both Up and Close, without holding the + // manager's map lock or ignoring its own cancellation. + checkWaiting := func(stage string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + url, err := activateMachine(m, ctx, bridge, "http://100.64.0.2", nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("during %s, replacement = %q, %v; want cancellable wait", stage, url, err) + } + } + checkWaiting("Up cancellation") + releaseUp() + <-n.closing + checkWaiting("Close") + releaseClose() + if err := <-first; !errors.Is(err, context.Canceled) { + t.Errorf("first activation: %v", err) + } + url, err := activateMachine(m, context.Background(), bridge, "http://100.64.0.2", nil) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if replacement.up != 1 || calls != 2 { + t.Errorf("new node not brought up once: calls=%d up=%d", calls, replacement.up) + } + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("replacement proxy status=%d", resp.StatusCode) + } +} + +type needsLoginNode struct{ *fakeNode } + +func (n *needsLoginNode) BringUp(ctx context.Context, _ events) (*ipnstate.Status, error) { + n.up++ + <-ctx.Done() + return nil, ctx.Err() +} + +func TestSwitchTailnetDoesNotRequireAuthorization(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + n := &needsLoginNode{fakeNode: &fakeNode{}} + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + defer m.Close() + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + err := switchTailnet(m, ctx, config.Bridge{ID: "bridge-abcdef", Name: "Work"}, nil) + if err != nil || n.loggedOut != 1 || n.up != 0 || !n.closed { + t.Fatalf("switch = %v, up=%d logout=%d closed=%v; want logout without authorization", err, n.up, n.loggedOut, n.closed) + } +} + +func TestCloseCancelsStartupBeforeClosingNode(t *testing.T) { + n := &pendingNode{ + fakeNode: &fakeNode{}, started: make(chan struct{}), cancelling: make(chan struct{}), + releaseUp: make(chan struct{}), closing: make(chan struct{}), releaseClose: make(chan struct{}), + } + close(n.releaseUp) + close(n.releaseClose) + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + activationDone := make(chan error, 1) + go func() { + _, err := activateMachine(m, ctx, bridge, "http://ai", nil) + activationDone <- err + }() + <-n.started + closed := make(chan error, 1) + go func() { closed <- m.Close() }() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("Close did not cancel the authorization wait") + } + if err := <-activationDone; !errors.Is(err, context.Canceled) { + t.Errorf("activation = %v", err) + } + if !n.closed { + t.Error("Close returned with a live node") + } + if _, err := activateMachine(m, context.Background(), bridge, "http://ai", nil); !errors.Is(err, net.ErrClosed) { + t.Errorf("activation after shutdown = %v, want net.ErrClosed", err) + } +} + +type closingNode struct { + *fakeNode + closing, release chan struct{} + err error +} + +func (n *closingNode) Close() error { + close(n.closing) + <-n.release + _ = n.fakeNode.Close() + return n.err +} + +func TestConcurrentCloseSharesCompletionAndError(t *testing.T) { + for _, closeErr := range []error{nil, errors.New("node shutdown failed")} { + t.Run(fmt.Sprint(closeErr), func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + node := &closingNode{fakeNode: &fakeNode{}, closing: make(chan struct{}), release: make(chan struct{}), err: closeErr} + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return node } + if _, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://100.64.0.2", nil); err != nil { + t.Fatal(err) + } + release := sync.OnceFunc(func() { close(node.release) }) + defer release() + results := make(chan error, 2) + go func() { results <- m.Close() }() + <-node.closing + if _, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://ai", nil); !errors.Is(err, net.ErrClosed) { + t.Errorf("activation during shutdown = %v", err) + } + go func() { results <- m.Close() }() + var got []error + select { + case err := <-results: + got = append(got, err) + t.Errorf("Close returned %v before node cleanup finished", err) + case <-time.After(30 * time.Millisecond): + } + release() + for len(got) < 2 { + got = append(got, <-results) + } + got = append(got, m.Close()) + for _, err := range got { + if !errors.Is(err, closeErr) || err != got[0] { + t.Errorf("Close results = %v; want one shared result wrapping %v", got, closeErr) + } + } + }) + } +} + +func TestCloseEmptyManager(t *testing.T) { + for _, m := range []*Machines{nil, new(Machines), NewMachines(false)} { + if err := m.Close(); err != nil { + t.Errorf("closing an unused manager: %v", err) + } + } +} + +// stateDir is the directory tsnet would have created for a bridge that has +// started once, holding the node key that names the registered device. +func stateDir(t *testing.T, bridgeID string) string { + t.Helper() + dir, err := config.BridgeStateDir(bridgeID) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "tailscaled.state"), []byte("node key"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func TestDestroyLeavesNoMachineBehind(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + dir := stateDir(t, bridge.ID) + if !HasMachine(bridge.ID) { + t.Fatal("a started bridge reports no machine") + } + n := &fakeNode{} + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + defer m.Close() + + if err := destroyMachine(m, context.Background(), bridge, nil); err != nil { + t.Fatalf("Destroy: %v", err) + } + if n.loggedOut != 1 || !n.closed || n.up != 0 { + t.Errorf("logout=%d closed=%v up=%d; want a logout and close without authorization", n.loggedOut, n.closed, n.up) + } + if _, err := os.Stat(dir); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("state directory survived the machine: %v", err) + } + if HasMachine(bridge.ID) { + t.Error("destroyed bridge still reports a machine") + } +} + +// A failed logout keeps the local records: they are the only thing naming the +// device, so the caller must be able to leave settings alone and say so. +func TestDestroyKeepsStateWhenTheTailnetRefuses(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + dir := stateDir(t, bridge.ID) + n := &fakeNode{logoutErr: errors.New("control plane said no")} + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return n } + defer m.Close() + + if err := destroyMachine(m, context.Background(), bridge, nil); err == nil || !strings.Contains(err.Error(), "control plane said no") { + t.Fatalf("Destroy = %v, want the logout failure", err) + } + if _, err := os.Stat(dir); err != nil { + t.Errorf("state discarded after a failed logout: %v", err) + } +} + +// A bridge nobody finished a login for has no device to deregister, and +// starting a node to discover that would demand the login it never had. +func TestDestroySkipsABridgeThatNeverStarted(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + if HasMachine(bridge.ID) { + t.Fatal("an unstarted bridge reports a machine") + } + m := NewMachines(false) + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { + t.Error("started a node to remove a bridge that never had one") + return &fakeNode{} + } + defer m.Close() + + if err := destroyMachine(m, context.Background(), bridge, nil); err != nil { + t.Fatalf("Destroy: %v", err) + } +} + +// The removal's wait has to bound cleanup too. Logout takes the context, but +// a node.Close that hangs after it would hold the removal past its deadline +// and the TUI with it. The Machine stays held until cleanup finishes, so the +// next operation on it waits rather than opening the state directory under a +// close still running. +func TestDestroyReturnsAtTheDeadlineWhileCloseHangs(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + stateDir(t, bridge.ID) + n := &pendingNode{fakeNode: &fakeNode{}, closing: make(chan struct{}), releaseClose: make(chan struct{})} + m := NewMachines(false) + nodes := 0 + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { + // The hanging node once; the reopen after it gets an ordinary one. + nodes++ + if nodes == 1 { + return n + } + return &fakeNode{status: tailnetStatus("ai.example.ts.net.", "100.64.0.2")} + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan error, 1) + go func() { done <- destroyMachine(m, ctx, bridge, nil) }() + select { + case err := <-done: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Destroy = %v, want the deadline", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Destroy did not return at its deadline while Close hung") + } + + // Still held: a reopen waits for the close to finish. + opened := make(chan error, 1) + go func() { _, err := activateMachine(m, context.Background(), bridge, "http://ai", nil); opened <- err }() + select { + case err := <-opened: + t.Fatalf("Open ran while the destroy's close was still hanging: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(n.releaseClose) + select { + case <-opened: + case <-time.After(2 * time.Second): + t.Fatal("Open never ran after the close finished") + } + m.Close() +} diff --git a/internal/bridges/machine.go b/internal/bridges/machine.go new file mode 100644 index 0000000..79b82ba --- /dev/null +++ b/internal/bridges/machine.go @@ -0,0 +1,389 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "io/fs" + "log/slog" + "net" + "os" + "sync" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +// Machine is the device this program runs on the user's tailnet for one +// Bridge. It registers, may need a login, gets an address, carries dials and +// shows up under Machines in the admin console. A Machine outlives any one +// connection attempt and owns its Routes. +// +// A Machine runs one operation at a time. Open, RouteTo, LeaveTailnet, +// Destroy and Close each hold the Machine for their whole duration, cleanup +// included, so a second node can never open the state directory while a +// first one is still closing. An operation waiting its turn can be cancelled +// through its context without disturbing the running one. Close cancels the +// running one. +type Machine struct { + bridge config.Bridge + // machines is the collection this Machine belongs to. The collection holds the + // node factory and dial tuning shared by every member. + machines *Machines + + // turn is held by the operation running on this Machine. It is a one-slot + // channel rather than a mutex so that waiting for it can be cancelled. + turn chan struct{} + // mu guards the fields another goroutine reads or sets while an operation + // holds the turn: cancel, closed and tailnet. + mu sync.Mutex + cancel context.CancelFunc + closed bool + tailnet string + + // The operation holding the turn owns these. A Machine in the collection + // may have no node, either idle after a logout or never started. + node tailnetNode + routes map[string]*Route + ev *eventRelay +} + +func newMachine(bridge config.Bridge, ms *Machines) *Machine { + return &Machine{ + bridge: bridge, + machines: ms, + turn: make(chan struct{}, 1), + routes: make(map[string]*Route), + ev: &eventRelay{}, + } +} + +// MachineName returns the hostname the bridge's node registers under, which +// is the device name the tailnet shows. Removal has to name the same thing +// the admin console does, or a user told to delete it by hand cannot find it. +func MachineName(bridgeID string) string { return "aperture-cli-" + bridgeID } + +// HasMachine reports whether the bridge ever started a node. tsnet creates +// the state directory on first use, so a missing directory is the only +// durable evidence that nothing was ever registered. Bridge.Tailnet cannot +// serve: it is a display hint, saved after verification and cleared before a +// switch. +func HasMachine(bridgeID string) bool { + dir, err := config.BridgeStateDir(bridgeID) + if err != nil { + return false + } + _, err = os.Stat(dir) + return !errors.Is(err, fs.ErrNotExist) +} + +// begin takes the Machine for one operation and returns the context the +// operation runs under. Close can cancel that context. +func (mc *Machine) begin(ctx context.Context) (context.Context, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + mc.mu.Lock() + closed := mc.closed + mc.mu.Unlock() + if closed { + return nil, net.ErrClosed + } + select { + case mc.turn <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + mc.mu.Lock() + defer mc.mu.Unlock() + if mc.closed { + <-mc.turn + return nil, net.ErrClosed + } + if err := ctx.Err(); err != nil { + <-mc.turn + return nil, err + } + ctx, mc.cancel = context.WithCancel(ctx) + return ctx, nil +} + +func (mc *Machine) end() { + mc.mu.Lock() + if mc.cancel != nil { + mc.cancel() + mc.cancel = nil + } + mc.mu.Unlock() + <-mc.turn +} + +// Tailnet returns the name of the tailnet the Machine joined. It is empty +// before the Machine joins one and after it leaves. +func (mc *Machine) Tailnet() string { + mc.mu.Lock() + defer mc.mu.Unlock() + return mc.tailnet +} + +func (mc *Machine) setTailnet(name string) { + mc.mu.Lock() + defer mc.mu.Unlock() + mc.tailnet = name +} + +// Open brings the node up, logging in if it has to, and reports each wait on +// emit. A Machine that is already open returns at once and points its +// reporting at emit. A failed start closes the node before returning, so the +// next Open starts clean rather than reusing a node another attempt was +// tearing down. +func (mc *Machine) Open(ctx context.Context, emit func(connection.Event)) error { + ev := sink(emit) + ctx, err := mc.begin(ctx) + if err != nil { + return err + } + defer mc.end() + if mc.node != nil { + mc.ev.forwardTo(ev) + return nil + } + if err := mc.initNode(ev); err != nil { + return err + } + + ev.enter(connection.StartingMachine) + + // BringUp blocks until the node is Running. For a bridge that has never + // logged in, that means blocking until the user visits a link nothing has + // shown them yet. BringUp reports the wait from the watch it waits on. + // + // The wait is timed because every "it just sat there" report is about + // this wait, and the number tells a slow control plane apart from a login + // link the user never saw. + start := time.Now() + status, err := mc.node.BringUp(ctx, ev) + if err != nil { + slog.Error("bridge node did not come up", "bridge", mc.bridge.ID, "after", time.Since(start), "err", redactDiagnostic(err.Error())) + return errors.Join(err, mc.shutdownNode()) + } + slog.Info("bridge node up", "bridge", mc.bridge.ID, "after", time.Since(start)) + + // The status returned by BringUp already names the tailnet, so no extra + // call is needed. The connection picker shows the name on rows not + // connected to yet. + if status != nil && status.CurrentTailnet != nil && status.CurrentTailnet.Name != "" { + mc.setTailnet(status.CurrentTailnet.Name) + } + return nil +} + +// RouteTo returns the Route to remoteURL through this Machine, opening one on +// first use. The Machine must be open. RouteTo never starts a node to satisfy +// a Route. +func (mc *Machine) RouteTo(ctx context.Context, remoteURL string, emit func(connection.Event)) (*Route, error) { + target, err := parseTarget(remoteURL) + if err != nil { + return nil, err + } + ev := sink(emit) + ctx, err = mc.begin(ctx) + if err != nil { + return nil, err + } + defer mc.end() + if mc.node == nil { + return nil, fmt.Errorf("bridge %s is not open", mc.bridge.Name) + } + mc.ev.forwardTo(ev) + + // Reported here so a reused Machine says it too. Otherwise a reused + // Machine says nothing while the first dial waits for the target to + // appear in its peer map. + ev.enter(connection.FindingEndpoint) + if mc.machines.debug { + // Full status rather than the login status: it lets debug output tell + // a DNS problem from a target absent from this node's netmap, on + // reuse too, since the endpoint may have changed. + status, err := mc.node.Status(ctx) + if err != nil { + ev.note("Could not read bridge network status: " + err.Error()) + } + logBridgeStatus(ev, status, target) + } + + if err := ctx.Err(); err != nil { + return nil, err + } + key := target.String() + if route := mc.routes[key]; route != nil { + return route, nil + } + route, err := mc.openRoute(target) + if err != nil { + return nil, err + } + mc.routes[key] = route + ev.note("Listening on " + route.LocalURL) + return route, nil +} + +// LeaveTailnet logs the Machine out of its tailnet and closes its node, so +// the next Open asks for a login. Logout needs only an initialized LocalAPI. +// Waiting for Running first would demand authorization of an expired or +// unapproved identity just to leave it. +func (mc *Machine) LeaveTailnet(ctx context.Context, emit func(connection.Event)) error { + ev := sink(emit) + ctx, err := mc.begin(ctx) + if err != nil { + return err + } + defer mc.end() + if err := mc.initNode(ev); err != nil { + return err + } + + ev.note("Logging bridge " + mc.bridge.Name + " out of its tailnet ...") + logoutErr := mc.node.Logout(ctx) + closeErr := mc.shutdownNode() + mc.setTailnet("") + if err := errors.Join(logoutErr, closeErr); err != nil { + return err + } + ev.note("Bridge logged out. Log in to the tailnet you want next.") + return nil +} + +// Destroy logs the Machine out of its tailnet and deletes the state directory +// holding its login. Destroy touches no settings. The Bridge record is the +// only thing naming the device, so the caller removes it after Destroy +// returns nil and keeps it otherwise (ADR 0002). +// +// A Machine that never started has no device and must not start one to find +// out. Bring-up would demand the interactive login that is being removed. +// +// The state directory goes last and only on success. It holds the node key, +// which a later attempt needs to deregister the device. +// +// Destroy returns when the work is done or ctx ends, whichever is first. +// Logout takes ctx but the node's Close does not, and a close that hangs must +// not hold the caller past its deadline. The Machine stays held until the +// work finishes, so the next operation waits rather than opening the state +// directory under a close still running. +func (mc *Machine) Destroy(ctx context.Context, emit func(connection.Event)) error { + stateDir, err := config.BridgeStateDir(mc.bridge.ID) + if err != nil { + return err + } + ev := sink(emit) + ctx, err = mc.begin(ctx) + if err != nil { + return err + } + done := make(chan error, 1) + go func() { + defer mc.end() + done <- mc.destroyHeld(ctx, stateDir, ev) + }() + select { + case err := <-done: + return err + case <-ctx.Done(): + // end cancels ctx right after the result is sent, so a result that + // is already there wins over the cancellation it caused. + select { + case err := <-done: + return err + default: + return ctx.Err() + } + } +} + +// destroyHeld does Destroy's work. The caller holds the Machine. +func (mc *Machine) destroyHeld(ctx context.Context, stateDir string, ev events) error { + if mc.node == nil && !HasMachine(mc.bridge.ID) { + mc.setTailnet("") + return nil + } + if err := mc.initNode(ev); err != nil { + return err + } + + ev.note("Removing bridge " + mc.bridge.Name + " from its tailnet ...") + logoutErr := mc.node.Logout(ctx) + closeErr := mc.shutdownNode() + mc.setTailnet("") + if err := errors.Join(logoutErr, closeErr); err != nil { + return err + } + if err := os.RemoveAll(stateDir); err != nil { + return err + } + ev.note("Bridge " + mc.bridge.Name + " is no longer a device on that tailnet.") + return nil +} + +// Close ends the Machine for the process. It interrupts the running +// operation, waits for that operation to finish cleaning up, closes the node +// and every Route, and refuses further operations. Close is safe to call more +// than once. +func (mc *Machine) Close() error { + mc.mu.Lock() + mc.closed = true + if mc.cancel != nil { + mc.cancel() + } + mc.mu.Unlock() + mc.turn <- struct{}{} + defer func() { <-mc.turn }() + return mc.shutdownNode() +} + +// initNode constructs a node without waiting for login. The caller holds the +// turn. Only Open follows initNode with BringUp. +func (mc *Machine) initNode(ev events) error { + mc.ev.forwardTo(ev) + if mc.node != nil { + return nil + } + if mc.machines.newNode == nil { + return fmt.Errorf("bridge node is not configured") + } + stateDir, err := config.BridgeStateDir(mc.bridge.ID) + if err != nil { + return err + } + // Both of tsnet's loggers are diagnostics now. Everything the attempt + // waits on comes off the IPN bus, and UserLogf is mostly printAuthURLLoop + // reprinting a link the footer already shows. The logger is a no-op + // rather than nil, because tsnet falls back to log.Printf, which writes + // over the TUI. + logNotes := func(format string, args ...any) { + if mc.machines.debug { + events(mc.ev.emit).notef(format, args...) + } + } + mc.node = mc.machines.newNode(mc.bridge, stateDir, logNotes, logNotes) + if mc.node == nil { + return fmt.Errorf("bridge node is not configured") + } + return nil +} + +// shutdownNode closes every Route and the node, leaving the Machine idle. The +// caller holds the turn. shutdownNode must finish before a new node can open +// the same state directory. +func (mc *Machine) shutdownNode() error { + var errs []error + for key, route := range mc.routes { + errs = append(errs, route.close()) + delete(mc.routes, key) + } + if mc.node != nil { + errs = append(errs, mc.node.Close()) + mc.node = nil + } + return errors.Join(errs...) +} diff --git a/internal/bridges/machine_test.go b/internal/bridges/machine_test.go new file mode 100644 index 0000000..2fbd110 --- /dev/null +++ b/internal/bridges/machine_test.go @@ -0,0 +1,1051 @@ +package bridges + +import ( + "bytes" + "context" + "errors" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" + "tailscale.com/health" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/key" +) + +type fakeNode struct { + backendAddr string + status *ipnstate.Status + statusFn func() (*ipnstate.Status, error) + watchFn func(ev events) + upFn func() + upErr error + statusErr error + dialErr error + dialFn bridgeDialFunc + logoutErr error + up int + loggedOut int + closed bool + + mu sync.Mutex + dialed []string +} + +// BringUp stands in for the one watch the real node waits on: watchFn is what +// a test wants the bus to report before the node is usable, and upFn is the +// wait itself. +func (n *fakeNode) BringUp(_ context.Context, ev events) (*ipnstate.Status, error) { + n.up++ + if n.watchFn != nil { + n.watchFn(ev) + } + if n.upFn != nil { + n.upFn() + } + return n.status, n.upErr +} + +func (n *fakeNode) Status(context.Context) (*ipnstate.Status, error) { + if n.statusFn != nil { + return n.statusFn() + } + return n.status, n.statusErr +} + +func (n *fakeNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + n.mu.Lock() + n.dialed = append(n.dialed, address) + n.mu.Unlock() + if n.dialFn != nil { + return n.dialFn(ctx, network, n.backendAddr) + } + if n.dialErr != nil { + return nil, n.dialErr + } + var d net.Dialer + return d.DialContext(ctx, network, n.backendAddr) +} + +// collect records what a bridge reported, rendered the way the connect screen +// renders it, so an assertion reads like the line the user would have seen. +func collect(lines *[]string) func(connection.Event) { + return func(ev connection.Event) { *lines = append(*lines, ev.String()) } +} + +// collectLocked is collect for the tests whose events arrive off a watch +// goroutine. +func collectLocked(mu *sync.Mutex, lines *[]string) func(connection.Event) { + return func(ev connection.Event) { + mu.Lock() + defer mu.Unlock() + *lines = append(*lines, ev.String()) + } +} + +func (n *fakeNode) dialedAddrs() []string { + n.mu.Lock() + defer n.mu.Unlock() + return append([]string(nil), n.dialed...) +} + +// tailnetStatus is a node status that knows one peer, the shape every dial +// through a bridge depends on. +func tailnetStatus(dnsName string, addrs ...string) *ipnstate.Status { + _, suffix, _ := strings.Cut(strings.TrimSuffix(dnsName, "."), ".") + ips := make([]netip.Addr, 0, len(addrs)) + for _, addr := range addrs { + ips = append(ips, netip.MustParseAddr(addr)) + } + return &ipnstate.Status{ + BackendState: "Running", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + CurrentTailnet: &ipnstate.TailnetStatus{MagicDNSSuffix: suffix}, + Peer: map[key.NodePublic]*ipnstate.PeerStatus{ + key.NewNode().Public(): {DNSName: dnsName, TailscaleIPs: ips}, + }, + } +} + +func TestActivateDebugDiagnostics(t *testing.T) { + status := &ipnstate.Status{ + BackendState: "Running", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + Self: &ipnstate.PeerStatus{DNSName: "aperture-cli.example.ts.net."}, + CurrentTailnet: &ipnstate.TailnetStatus{ + Name: "example.com", + MagicDNSSuffix: "example.ts.net", + MagicDNSEnabled: true, + }, + } + node := &fakeNode{status: status, dialErr: errors.New("lookup aperture on 127.0.0.53:53: no such host")} + m := NewMachines(true) + m.peerWait, m.peerWaitInterval = 5*time.Millisecond, time.Millisecond + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + var logs []string + localURL, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture", + collect(&logs), + ) + if err != nil { + t.Fatal(err) + } + + resp, err := http.Get(localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadGateway) + } + if !strings.Contains(string(body), "not a node on this bridge's tailnet") { + t.Errorf("response body = %q, want the short name refused", body) + } + if dialed := node.dialedAddrs(); len(dialed) != 0 { + t.Errorf("short name handed to tsnet: dialed %v", dialed) + } + + got := strings.Join(logs, "\n") + for _, want := range []string{ + `tailnet="example.com"`, + `dns_suffix="example.ts.net"`, + `target is not present among visible peers`, + `Bridge dial failed`, + `not a node on this bridge's tailnet`, + } { + if !strings.Contains(got, want) { + t.Errorf("logs missing %q:\n%s", want, got) + } + } +} + +func TestActivateClosesNodeWhenUpFails(t *testing.T) { + node := &fakeNode{upErr: errors.New("login failed")} + m := NewMachines(false) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + + _, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://ai", + nil, + ) + if !errors.Is(err, node.upErr) { + t.Fatalf("Activate error = %v, want %v", err, node.upErr) + } + if !node.closed { + t.Error("node was not closed after Up failure") + } +} + +func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { + status := &ipnstate.Status{ + BackendState: "Running", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, + Self: &ipnstate.PeerStatus{DNSName: "aperture-cli.example.ts.net."}, + Peer: map[key.NodePublic]*ipnstate.PeerStatus{ + key.NewNode().Public(): { + DNSName: "aperture.example.ts.net.", + TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.2")}, + }, + }, + CurrentTailnet: &ipnstate.TailnetStatus{ + Name: "example.com", + MagicDNSSuffix: "example.ts.net", + MagicDNSEnabled: true, + }, + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + backendAddr := strings.TrimPrefix(backend.URL, "http://") + node := &fakeNode{status: status, backendAddr: backendAddr} + m := NewMachines(false) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + var logs []string + localURL, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture", + collect(&logs), + ) + if err != nil { + t.Fatal(err) + } + resp, err := http.Get(localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + got := strings.Join(logs, "\n") + for _, unwanted := range []string{"Bridge network:", "Bridge health:", "Bridge target ", "Bridge dialing", "Bridge dial connected:"} { + if strings.Contains(got, unwanted) { + t.Errorf("normal logs contain debug diagnostic %q:\n%s", unwanted, got) + } + } +} + +// TestActivateWaitsForPeerMapBeforeDialing covers the first-connection hang: a +// node reports Running before its peer map lands, and dialing the target's name +// in that window escapes to the host resolver. +func TestActivateWaitsForPeerMapBeforeDialing(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer backend.Close() + + var polls atomic.Int32 + node := &fakeNode{backendAddr: strings.TrimPrefix(backend.URL, "http://")} + node.statusFn = func() (*ipnstate.Status, error) { + if polls.Add(1) < 3 { + return &ipnstate.Status{BackendState: "Running"}, nil + } + return tailnetStatus("ai.example.ts.net.", "100.64.0.2"), nil + } + + m := NewMachines(true) + m.peerWaitInterval = time.Millisecond + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + var logs []string + localURL, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://ai", + collect(&logs), + ) + if err != nil { + t.Fatal(err) + } + + resp, err := http.Get(localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "100.64.0.2:80" { + t.Fatalf("dialed %v, want one dial to 100.64.0.2:80", got) + } + if got := strings.Join(logs, "\n"); !strings.Contains(got, "remote=") { + t.Fatalf("logs missing the connected dial:\n%s", got) + } +} + +// TestActivateLogsTheLoginLinkBeforeItIsUsable covers the bridge that looked +// hung: a node that has never logged in waits for someone to visit a link, so +// the link has to reach the log during that wait rather than once it is over. +func TestActivateLogsTheLoginLinkBeforeItIsUsable(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + const url = "https://login.tailscale.com/a/28ba393017981" + node := &fakeNode{ + backendAddr: backend.Listener.Addr().String(), + status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), + } + node.watchFn = func(ev events) { + link, err := connection.ParseLoginLink(url) + if err != nil { + t.Error(err) + return + } + ev.loginRequired(link) + } + + m := NewMachines(false) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return node + } + defer m.Close() + + var mu sync.Mutex + var logs []string + if _, err := activateMachine(m, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://ai", + collectLocked(&mu, &logs), + ); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + for _, line := range logs { + switch { + case strings.Contains(line, url): + return + case strings.Contains(line, "Listening on"): + t.Fatalf("the proxy was up before the link was reported:\n%s", strings.Join(logs, "\n")) + } + } + t.Errorf("login link never reached the activation log:\n%s", strings.Join(logs, "\n")) +} + +func TestDialViaNode(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + backendAddr := backend.Listener.Addr().String() + discard := events(func(connection.Event) {}) + + t.Run("dials the address the node's peer map gives", func(t *testing.T) { + node := &fakeNode{backendAddr: backendAddr, status: tailnetStatus("ai.example.ts.net.", "100.64.0.2")} + + conn, attempts, err := dialViaNode( + context.Background(), node, "tcp", "ai:80", discard, time.Second, time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + conn.Close() + if attempts != 1 { + t.Errorf("status polls = %d, want 1", attempts) + } + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "100.64.0.2:80" { + t.Errorf("dialed %v, want [100.64.0.2:80]", got) + } + }) + + t.Run("waits for the peer map to arrive", func(t *testing.T) { + var polls int + node := &fakeNode{backendAddr: backendAddr} + node.statusFn = func() (*ipnstate.Status, error) { + polls++ + if polls < 3 { + return &ipnstate.Status{BackendState: "Running"}, nil + } + return tailnetStatus("ai.example.ts.net.", "100.64.0.2"), nil + } + + conn, attempts, err := dialViaNode( + context.Background(), node, "tcp", "ai:80", discard, time.Second, time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + conn.Close() + if attempts != 3 { + t.Errorf("status polls = %d, want 3", attempts) + } + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "100.64.0.2:80" { + t.Errorf("dialed %v, want [100.64.0.2:80], never the bare name", got) + } + }) + + t.Run("falls back to a qualified name when the target is not a peer", func(t *testing.T) { + node := &fakeNode{backendAddr: backendAddr, status: tailnetStatus("other.example.ts.net.", "100.64.0.3")} + var logs []string + + conn, _, err := dialViaNode( + context.Background(), node, "tcp", "db.internal.example:5432", + collect(&logs), + 5*time.Millisecond, time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + conn.Close() + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "db.internal.example:5432" { + t.Errorf("dialed %v, want [db.internal.example:5432]", got) + } + if got := strings.Join(logs, "\n"); !strings.Contains(got, "not a node on this bridge's tailnet") { + t.Errorf("logs do not say the target left the tailnet's DNS:\n%s", got) + } + }) + + t.Run("dials an IP target without asking for status", func(t *testing.T) { + node := &fakeNode{backendAddr: backendAddr, statusFn: func() (*ipnstate.Status, error) { + t.Error("status polled for an address that needs no resolving") + return nil, nil + }} + + conn, _, err := dialViaNode( + context.Background(), node, "tcp", "100.64.0.2:80", discard, time.Second, time.Millisecond, + ) + if err != nil { + t.Fatal(err) + } + conn.Close() + if got := node.dialedAddrs(); len(got) != 1 || got[0] != "100.64.0.2:80" { + t.Errorf("dialed %v, want [100.64.0.2:80]", got) + } + }) + + t.Run("stops when activation is canceled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + node := &fakeNode{backendAddr: backendAddr} + node.statusFn = func() (*ipnstate.Status, error) { + cancel() + return &ipnstate.Status{BackendState: "Running"}, nil + } + + _, attempts, err := dialViaNode(ctx, node, "tcp", "ai:80", discard, time.Second, time.Millisecond) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context canceled", err) + } + if attempts != 1 { + t.Errorf("status polls = %d, want 1", attempts) + } + if got := node.dialedAddrs(); len(got) != 0 { + t.Errorf("dialed %v after cancellation, want nothing", got) + } + }) +} + +func TestPeerAddr(t *testing.T) { + tests := []struct { + name string + status *ipnstate.Status + host string + want string + wantOK bool + }{ + { + name: "short name matches the first label", + status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), + host: "ai", + want: "100.64.0.2", + wantOK: true, + }, + { + name: "full MagicDNS name matches", + status: tailnetStatus("ai.example.ts.net.", "100.64.0.2"), + host: "AI.example.ts.net", + want: "100.64.0.2", + wantOK: true, + }, + { + name: "IPv4 wins over IPv6", + status: tailnetStatus("ai.example.ts.net.", "fd7a:115c:a1e0::2", "100.64.0.2"), + host: "ai", + want: "100.64.0.2", + wantOK: true, + }, + { + name: "IPv6-only peer still resolves", + status: tailnetStatus("ai.example.ts.net.", "fd7a:115c:a1e0::2"), + host: "ai", + want: "fd7a:115c:a1e0::2", + wantOK: true, + }, + { + name: "another tailnet's node is not a match", + status: tailnetStatus("other.example.ts.net.", "100.64.0.3"), + host: "ai", + }, + { + name: "no status at all", + host: "ai", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := peerAddr(tt.status, tt.host) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if ok && got.String() != tt.want { + t.Errorf("addr = %s, want %s", got, tt.want) + } + }) + } +} + +func TestActivateRecordsTailnet(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + m := NewMachines(false) + m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + return &fakeNode{ + backendAddr: backend.Listener.Addr().String(), + status: &ipnstate.Status{CurrentTailnet: &ipnstate.TailnetStatus{Name: "corp.example.com"}}, + } + } + defer m.Close() + + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + if _, err := activateMachine(m, context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { + t.Fatal(err) + } + if got := tailnetOf(m, bridge.ID); got != "corp.example.com" { + t.Errorf("Tailnet = %q, want corp.example.com", got) + } +} + +// TestSwitchTailnet covers what makes a switch a switch: the node is logged out +// rather than just restarted, so the next connection has to ask for a login. +func TestSwitchTailnet(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + f.manager.lookup(bridge.ID).setTailnet("corp.example.com") + first := f.node + + if err := switchTailnet(f.manager, context.Background(), bridge, nil); err != nil { + t.Fatal(err) + } + if first.loggedOut != 1 { + t.Errorf("logouts = %d, want 1", first.loggedOut) + } + if !first.closed { + t.Error("node was not closed") + } + if got := tailnetOf(f.manager, bridge.ID); got != "" { + t.Errorf("Tailnet = %q, want empty after a switch", got) + } + if _, err := http.Get(f.localURL + "/"); err == nil { + t.Error("proxy still serving after the bridge was logged out") + } + + var replacement *fakeNode + f.manager.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + replacement = &fakeNode{backendAddr: backend.Listener.Addr().String()} + return replacement + } + if _, err := activateMachine(f.manager, context.Background(), bridge, "http://aperture.tailnet", nil); err != nil { + t.Fatal(err) + } + if replacement == nil { + t.Fatal("Activate reused the logged-out node") + } + if replacement.up != 1 { + t.Errorf("replacement node Up calls = %d, want 1", replacement.up) + } +} + +func TestSwitchTailnetReportsLogoutFailure(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + f.node.logoutErr = errors.New("not logged in") + + err := switchTailnet(f.manager, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, nil) + if err == nil || !strings.Contains(err.Error(), "not logged in") { + t.Fatalf("err = %v, want it to name the logout failure", err) + } +} + +func (n *fakeNode) Logout(context.Context) error { + n.loggedOut++ + return n.logoutErr +} + +func (n *fakeNode) Close() error { + n.closed = true + return nil +} + +// activatedManager creates a Manager with a fake node wired to backend, +// activates the bridge once, and returns everything tests need. +type activatedFixture struct { + manager *Machines + node *fakeNode + localURL string + logs []string +} + +func activate(t *testing.T, backend *httptest.Server) activatedFixture { + t.Helper() + var f activatedFixture + f.manager = NewMachines(false) + f.manager.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + f.node = &fakeNode{ + backendAddr: backend.Listener.Addr().String(), + status: tailnetStatus("aperture.tailnet.", "100.64.0.2"), + } + return f.node + } + + var err error + f.localURL, err = activateMachine(f.manager, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture.tailnet", + collect(&f.logs), + ) + if err != nil { + t.Fatal(err) + } + return f +} + +func TestActivate(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "proxies requests to backend", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got := string(body); got != `[{"id":"anthropic"}]` { + t.Errorf("body = %s, want %s", got, `[{"id":"anthropic"}]`) + } + }, + }, + { + name: "rewrites Host header to target", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "aperture.tailnet" { + t.Errorf("Host = %q, want aperture.tailnet", r.Host) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "forwards request path", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("path = %q, want /v1/models", r.URL.Path) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/v1/models") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "returns localhost URL and calls Up once", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + if !strings.HasPrefix(f.localURL, "http://127.0.0.1:") { + t.Fatalf("localURL = %q, want http://127.0.0.1:... prefix", f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times, want 1", f.node.up) + } + if len(f.logs) == 0 { + t.Error("expected activation logs") + } + }, + }, + { + name: "reuses existing bridge without calling Up again", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + localURL2, err := activateMachine(f.manager, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture.tailnet", + nil, + ) + if err != nil { + t.Fatal(err) + } + if localURL2 != f.localURL { + t.Errorf("reused localURL = %q, want %q", localURL2, f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times after reuse, want 1", f.node.up) + } + }, + }, + { + name: "Close shuts down node", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + + if err := f.manager.Close(); err != nil { + t.Fatal(err) + } + if !f.node.closed { + t.Error("node was not closed") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, tc.run) + } +} + +// state and browse are the two notifications the bus sends that this package +// translates. Named here rather than inline so the tests read as the sequence +// a real login produces. +func state(s ipn.State) *ipn.Notify { return &ipn.Notify{State: &s} } +func browse(u string) *ipn.Notify { return &ipn.Notify{BrowseToURL: &u} } + +// TestLoginReporterSplitsTheTwoNeedsLoginWaits is the diagnosis this change +// came from. A 29 second bridge said only that it needed a login, so there was +// no telling the control plane not having answered from a user who had not +// finished in the browser. Both are ipn.NeedsLogin; here they are two phases. +func TestLoginReporterSplitsTheTwoNeedsLoginWaits(t *testing.T) { + const url = "https://login.tailscale.com/a/28ba393017981" + var got []string + r := &bringUpProgress{ev: collect(&got)} + + r.notify(state(ipn.NeedsLogin)) + r.notify(state(ipn.NeedsLogin)) // the bus repeats itself + r.notify(browse(url)) + r.notify(state(ipn.NeedsLogin)) // still NeedsLogin, but no longer that wait + r.notify(browse(url)) // and the same link again + r.notify(state(ipn.Starting)) + r.notify(state(ipn.Running)) + + want := []string{ + connection.AwaitingLoginLink.String(), + connection.AwaitingAuthorization.String(), + connection.LoginRequired(mustLink(t, url)).String(), + connection.LoginRequired(mustLink(t, url)).String(), + connection.JoiningTailnet.String(), + connection.FindingEndpoint.String(), + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("reported:\n%s\n\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestLoginReporterRejectsAnUnusableLink(t *testing.T) { + var got []string + r := &bringUpProgress{ev: collect(&got)} + + // http, not https. The value is handed to a desktop opener, so this is the + // one thing that must not pass through untouched. + r.notify(browse("http://evil.example.com/a/x")) + + if len(got) != 1 || !strings.Contains(got[0], "unusable login link") { + t.Fatalf("reported %q, want one line saying the link was ignored", got) + } + if strings.Contains(got[0], "LoginRequired(") { + t.Errorf("an http link was offered to the browser: %q", got[0]) + } +} + +// TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers is the state the +// first version missed. A bridge that never logged in sits in ipn.NoState for +// the whole of POST /machine/register, so NoState is the wait this exists to +// name. Untranslated it emits nothing and the screen goes silent. +func TestLoginReporterNamesTheWaitBeforeTheControlPlaneAnswers(t *testing.T) { + var lines []string + reporter := bringUpProgress{ev: collect(&lines)} + + // NoState alone, which is all the user gets for the length of the + // register. NeedsLogin arrives only once control has answered, so a test + // that ends on it would pass on the NeedsLogin case and prove nothing. + reporter.notify(state(ipn.NoState)) + want := []string{connection.AwaitingLoginLink.String()} + if !slices.Equal(lines, want) { + t.Fatalf("reported %q, want %q", lines, want) + } + + // And the NeedsLogin that follows it is the same wait, not a second one. + reporter.notify(state(ipn.NoState)) + reporter.notify(state(ipn.NeedsLogin)) + if !slices.Equal(lines, want) { + t.Errorf("reported %q, want the wait named once", lines) + } +} + +// TestAProxyReportsToTheAttemptUsingItNow covers a defect the string logger had +// too: proxies are cached for the life of the process, attempts are not, and +// startProxy's closures captured whichever attempt created the proxy. Every +// later dial failure went to a channel nobody had read since. +func TestAProxyReportsToTheAttemptUsingItNow(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + // A second attempt on the same bridge and target, which reuses both the + // node and the proxy the first one built. + var mu sync.Mutex + var second []string + if _, err := activateMachine(f.manager, + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture.tailnet", + collectLocked(&mu, &second), + ); err != nil { + t.Fatal(err) + } + + backend.Close() // the bridge breaking under a connection that already worked + // The proxy answers 502 rather than failing the request, so the status is + // what says the dial underneath it did not happen. + res, err := http.Get(f.localURL + "/") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want %d with no backend behind the proxy", res.StatusCode, http.StatusBadGateway) + } + + mu.Lock() + defer mu.Unlock() + if !slices.ContainsFunc(second, func(s string) bool { return strings.Contains(s, "dial failed") }) { + t.Errorf("the attempt using the proxy was told nothing; it saw %q", second) + } + if slices.ContainsFunc(f.logs, func(s string) bool { return strings.Contains(s, "dial failed") }) { + t.Errorf("the finished attempt was still being written to: %q", f.logs) + } +} + +// TestSinkLogsEveryEvent covers the case the run log exists for: a connect +// attempt that gets killed. Whatever the screen was showing is gone with the +// process, so every event has to reach the file on its way to the screen, +// including on the paths that pass no screen sink at all. +func TestSinkLogsEveryEvent(t *testing.T) { + var buf bytes.Buffer + orig := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(orig) }) + + link, err := connection.ParseLoginLink("https://login.tailscale.com/a/17bceb7b0129ba") + if err != nil { + t.Fatal(err) + } + + var seen []connection.Event + ev := sink(func(e connection.Event) { seen = append(seen, e) }) + ev.enter(connection.StartingMachine) + ev.loginRequired(link) + ev.note("dialing") + if len(seen) != 3 { + t.Errorf("screen saw %d events, want the tee to forward all 3", len(seen)) + } + + // The nil sink is the reuse path, which still has to leave a record. + sink(nil).enter(connection.FindingEndpoint) + + logged := buf.String() + for _, want := range []string{ + connection.StartingMachine.String(), + "bridge needs login", + "dialing", + connection.FindingEndpoint.String(), + } { + if !strings.Contains(logged, want) { + t.Errorf("run log = %q, want it to record %q", logged, want) + } + } + if strings.Contains(logged, link.String()) { + t.Error("run log contains the authorization capability") + } +} + +// TestNotifyLogsWhatTheScreenCollapses is the 43 second kill: the connect +// screen reported "Waiting for a login link" and then nothing, which is the +// same picture whether the register is slow, the link was thrown away, or the +// watch died. The screen merges those on purpose. The run log must not. +func TestNotifyLogsWhatTheScreenCollapses(t *testing.T) { + var buf bytes.Buffer + orig := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))) + t.Cleanup(func() { slog.SetDefault(orig) }) + + var lines []string + r := &bringUpProgress{ev: collect(&lines)} + r.notify(state(ipn.NoState)) + r.notify(state(ipn.NeedsLogin)) + r.notify(browse("http://evil.example.com/a/x")) + + logged := buf.String() + // Both states, though the screen shows one phase for the pair: which one + // the attempt is stuck in is the difference between waiting on control and + // waiting on the user. + for _, want := range []string{ipn.NoState.String(), ipn.NeedsLogin.String()} { + if !strings.Contains(logged, want) { + t.Errorf("run log = %q, want the raw state %q", logged, want) + } + } + // At Info, not behind -debug: the note this pairs with is a debug note, so + // without this a discarded link is invisible on the run that hit it. + if !strings.Contains(logged, "login link is not https") { + t.Errorf("run log = %q, want the rejection reason", logged) + } + if strings.Contains(logged, "http://evil.example.com/a/x") { + t.Error("run log contains the rejected authorization URL") + } +} + +func unhealthyLogin(text string) *ipn.Notify { + return &ipn.Notify{Health: &health.State{ + Warnings: map[health.WarnableCode]health.UnhealthyState{ + health.LoginStateWarnable.Code: {WarnableCode: health.LoginStateWarnable.Code, Text: text}, + }, + }} +} + +// TestLoginReporterReportsALoginThatIsFailing is the 502 register loop. The +// node stays in NeedsLogin and never sends a BrowseToURL, so the attempt sits +// on "Waiting for a login link" while tsnet retries. The error is not a +// vizerror, so ErrMessage stays nil and health state is the only place it is. +func TestLoginReporterReportsALoginThatIsFailing(t *testing.T) { + const text = "You are logged out. The last login error was: register request: http 502" + + var lines []string + r := &bringUpProgress{ev: collect(&lines)} + r.notify(state(ipn.NeedsLogin)) + r.notify(unhealthyLogin(text)) + + if len(lines) != 2 || !strings.Contains(lines[1], text) { + t.Fatalf("reported %q, want the wait followed by why it will not end", lines) + } + + // Every retry re-sends the state with a fresh request ID in the text, about + // once a second. Reporting each one would push the phases off the screen. + r.notify(unhealthyLogin(text + " REQ-0001")) + r.notify(unhealthyLogin(text + " REQ-0002")) + if len(lines) != 2 { + t.Errorf("reported %q, want the failure named once while it lasts", lines) + } + + // A retry that succeeds clears the warning, and the next failure is news + // again rather than a repeat. + r.notify(&ipn.Notify{Health: &health.State{}}) + r.notify(unhealthyLogin(text)) + if len(lines) != 3 { + t.Errorf("reported %q, want a failure after a recovery to be reported", lines) + } +} + +// TestLoginReporterIgnoresWarningsThatAreNotTheLogin keeps the connect screen +// about the wait it is in. A node with no DERP home is a real warning and not +// this attempt's business. +func TestLoginReporterIgnoresWarningsThatAreNotTheLogin(t *testing.T) { + var lines []string + r := &bringUpProgress{ev: collect(&lines)} + r.notify(&ipn.Notify{Health: &health.State{ + Warnings: map[health.WarnableCode]health.UnhealthyState{ + "no-derp-home": {WarnableCode: "no-derp-home", Text: "no home DERP"}, + }, + }}) + if len(lines) != 0 { + t.Errorf("reported %q, want an unrelated warning left off the connect screen", lines) + } +} diff --git a/internal/bridges/machines.go b/internal/bridges/machines.go new file mode 100644 index 0000000..5e76adf --- /dev/null +++ b/internal/bridges/machines.go @@ -0,0 +1,124 @@ +package bridges + +import ( + "errors" + "fmt" + "maps" + "net" + "slices" + "strings" + "sync" + "time" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const ( + bridgePeerWaitWindow = 5 * time.Second + bridgePeerWaitInterval = 250 * time.Millisecond +) + +// Machines holds the process's Machines, one per Bridge, and is the only +// place a Machine is created. Two Machines for one Bridge would open the same +// state directory. Getting a member does no network work. Close ends every +// member and refuses new ones. +type Machines struct { + mu sync.Mutex + byBridge map[string]*Machine + closed bool + shutdown func() error + + debug bool + // peerWait bounds how long a dial waits for the target to appear in the + // node's peer map before giving up and resolving it the way tsnet would. + peerWait time.Duration + peerWaitInterval time.Duration + newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode +} + +// NewMachines returns an empty collection. When debug is true, verbose tsnet +// backend logs are also reported to the attempt using a Machine. +func NewMachines(debug bool) *Machines { + ms := &Machines{ + byBridge: make(map[string]*Machine), + debug: debug, + peerWait: bridgePeerWaitWindow, + peerWaitInterval: bridgePeerWaitInterval, + newNode: newTSNetNode(debug), + } + ms.shutdown = sync.OnceValue(ms.close) + return ms +} + +// For returns the Machine for bridge, creating an idle one on first use. +func (ms *Machines) For(bridge config.Bridge) (*Machine, error) { + if ms == nil { + return nil, errors.New("bridges are not configured") + } + if err := validateBridgeID(bridge.ID); err != nil { + return nil, err + } + ms.mu.Lock() + defer ms.mu.Unlock() + if ms.closed { + return nil, net.ErrClosed + } + mc := ms.byBridge[bridge.ID] + if mc == nil { + mc = newMachine(bridge, ms) + ms.byBridge[bridge.ID] = mc + } + return mc, nil +} + +// lookup returns the Machine for bridgeID, or nil when none exists. Unlike +// For, lookup never creates one. +func (ms *Machines) lookup(bridgeID string) *Machine { + if ms == nil { + return nil + } + ms.mu.Lock() + defer ms.mu.Unlock() + return ms.byBridge[bridgeID] +} + +// Close ends every Machine. Concurrent and subsequent callers wait for the +// same cleanup and receive the same result, so nobody reports completion while +// another caller is still tearing a Machine down. +func (ms *Machines) Close() error { + if ms == nil || ms.shutdown == nil { + return nil + } + return ms.shutdown() +} + +func (ms *Machines) close() error { + ms.mu.Lock() + ms.closed = true + members := slices.Collect(maps.Values(ms.byBridge)) + ms.mu.Unlock() + var errs []error + for _, mc := range members { + errs = append(errs, mc.Close()) + } + return errors.Join(errs...) +} + +// validateBridgeID rejects an ID that does not match the generated +// "bridge-" format, so a hand-edited config cannot inject arbitrary +// content into the tailnet hostname. +func validateBridgeID(id string) error { + suffix, ok := strings.CutPrefix(id, "bridge-") + if !ok || suffix == "" { + return fmt.Errorf("invalid bridge ID %q", id) + } + if len(suffix) > 64 { + return fmt.Errorf("invalid bridge ID %q", id) + } + for _, r := range suffix { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return fmt.Errorf("invalid bridge ID %q", id) + } + } + return nil +} diff --git a/internal/bridges/main_test.go b/internal/bridges/main_test.go new file mode 100644 index 0000000..7c23b0a --- /dev/null +++ b/internal/bridges/main_test.go @@ -0,0 +1,23 @@ +package bridges + +import ( + "os" + "testing" +) + +// TestMain points every test at a throwaway config directory. A test that +// writes settings without isolating itself overwrote the developer's real +// settings.json on 2026-09-21; per-test t.Setenv still applies on top. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "aperture-test-") + if err != nil { + panic(err) + } + os.Setenv("HOME", tmp) + os.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + // os.UserConfigDir reads APPDATA on Windows and neither of the above. + os.Setenv("APPDATA", tmp+"/AppData") + code := m.Run() + os.RemoveAll(tmp) + os.Exit(code) +} diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go deleted file mode 100644 index e2ec470..0000000 --- a/internal/bridges/manager.go +++ /dev/null @@ -1,392 +0,0 @@ -// Package bridges runs embedded tsnet reverse proxies for Aperture endpoints. -package bridges - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" - "sync" - "time" - - "github.com/tailscale/aperture-cli/internal/config" - "tailscale.com/ipn/ipnstate" - "tailscale.com/tsnet" -) - -// Manager owns active tsnet nodes and localhost reverse proxies. -type Manager struct { - mu sync.Mutex - - debug bool - nodes map[string]*nodeRuntime - - newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode -} - -const ( - bridgeDNSRetryWindow = 5 * time.Second - bridgeDNSRetryInterval = 250 * time.Millisecond -) - -type nodeRuntime struct { - node tailnetNode - proxies map[string]*proxyRuntime -} - -type proxyRuntime struct { - localURL string - server *http.Server - listener net.Listener -} - -type tailnetNode interface { - Up(context.Context) (*ipnstate.Status, error) - Status(context.Context) (*ipnstate.Status, error) - DialContext(context.Context, string, string) (net.Conn, error) - Close() error -} - -type tsnetNode struct { - server *tsnet.Server -} - -func (n *tsnetNode) Up(ctx context.Context) (*ipnstate.Status, error) { - return n.server.Up(ctx) -} - -func (n *tsnetNode) Status(ctx context.Context) (*ipnstate.Status, error) { - lc, err := n.server.LocalClient() - if err != nil { - return nil, err - } - return lc.Status(ctx) -} - -func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - return n.server.Dial(ctx, network, address) -} - -func (n *tsnetNode) Close() error { - return n.server.Close() -} - -// NewManager returns a bridge manager. When debug is true, verbose tsnet -// backend logs are also emitted to the supplied activation log sink. -func NewManager(debug bool) *Manager { - m := &Manager{ - debug: debug, - nodes: make(map[string]*nodeRuntime), - } - m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { - s := &tsnet.Server{ - Dir: stateDir, - Hostname: "aperture-cli-" + bridge.ID, - UserLogf: userLogf, - } - if debug { - s.Logf = debugLogf - } - return &tsnetNode{server: s} - } - return m -} - -// Activate starts or reuses a bridge reverse proxy for remoteURL and returns -// the localhost URL clients should use. -func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL string, logf func(string)) (string, error) { - if m == nil { - return "", fmt.Errorf("bridge manager is not configured") - } - if err := validateBridgeID(bridge.ID); err != nil { - return "", err - } - if logf == nil { - logf = func(string) {} - } - target, err := parseTarget(remoteURL) - if err != nil { - return "", err - } - - m.mu.Lock() - rt := m.nodes[bridge.ID] - needUp := false - if rt == nil { - stateDir, err := config.BridgeStateDir(bridge.ID) - if err != nil { - m.mu.Unlock() - return "", err - } - userLogf := func(format string, args ...any) { - logf(fmt.Sprintf(format, args...)) - } - debugLogf := func(format string, args ...any) { - if m.debug { - logf(fmt.Sprintf(format, args...)) - } - } - node := m.newNode(bridge, stateDir, userLogf, debugLogf) - rt = &nodeRuntime{ - node: node, - proxies: make(map[string]*proxyRuntime), - } - m.nodes[bridge.ID] = rt - needUp = true - } - m.mu.Unlock() - - var status *ipnstate.Status - if needUp { - logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") - var err error - status, err = rt.node.Up(ctx) - if err != nil { - m.mu.Lock() - if m.nodes[bridge.ID] == rt { - delete(m.nodes, bridge.ID) - } - m.mu.Unlock() - return "", errors.Join(err, rt.node.Close()) - } - logf("Bridge connected.") - } - if m.debug { - // Up deliberately returns status without peers. Ask the in-process - // LocalAPI for full status so debug output can distinguish a DNS - // problem from a target that is absent from this node's netmap. Do - // this on reuse too, since the selected endpoint might have changed. - if fullStatus, err := rt.node.Status(ctx); err != nil { - logf("Could not read bridge network status: " + err.Error()) - } else { - status = fullStatus - } - logBridgeStatus(logf, status, target) - } - - m.mu.Lock() - defer m.mu.Unlock() - if m.nodes[bridge.ID] != rt { - return "", fmt.Errorf("bridge stopped before activation completed") - } - key := target.String() - if proxy := rt.proxies[key]; proxy != nil { - return proxy.localURL, nil - } - - proxy, err := startProxy(rt.node, target, logf, m.debug) - if err != nil { - return "", err - } - rt.proxies[key] = proxy - logf("Listening on " + proxy.localURL) - return proxy.localURL, nil -} - -// Close shuts down all active reverse proxies and tsnet nodes. -func (m *Manager) Close() error { - if m == nil { - return nil - } - m.mu.Lock() - defer m.mu.Unlock() - - var errs []error - for id, rt := range m.nodes { - for key, proxy := range rt.proxies { - if err := proxy.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { - errs = append(errs, err) - } - if err := proxy.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { - errs = append(errs, err) - } - delete(rt.proxies, key) - } - if err := rt.node.Close(); err != nil { - errs = append(errs, err) - } - delete(m.nodes, id) - } - return errors.Join(errs...) -} - -// validateBridgeID rejects IDs that don't match the system-generated -// "bridge-" format, so a hand-edited config can't inject arbitrary -// content into the tailnet hostname. -func validateBridgeID(id string) error { - suffix, ok := strings.CutPrefix(id, "bridge-") - if !ok || suffix == "" { - return fmt.Errorf("invalid bridge ID %q", id) - } - if len(suffix) > 64 { - return fmt.Errorf("invalid bridge ID %q", id) - } - for _, r := range suffix { - if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { - return fmt.Errorf("invalid bridge ID %q", id) - } - } - return nil -} - -func parseTarget(raw string) (*url.URL, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil, fmt.Errorf("endpoint URL is empty") - } - target, err := url.Parse(raw) - if err != nil { - return nil, err - } - if target.Scheme == "" || target.Host == "" { - return nil, fmt.Errorf("endpoint URL must include scheme and host") - } - return target, nil -} - -func startProxy(node tailnetNode, target *url.URL, logf func(string), debug bool) (*proxyRuntime, error) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, err - } - - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { - start := time.Now() - if debug { - logf(fmt.Sprintf("Bridge dialing network=%s address=%s", network, address)) - } - conn, attempts, err := dialWithDNSRetry( - ctx, - node.DialContext, - network, - address, - bridgeDNSRetryWindow, - bridgeDNSRetryInterval, - ) - elapsed := time.Since(start).Round(time.Millisecond) - if err != nil { - logf(fmt.Sprintf("Bridge dial failed: network=%s address=%s attempts=%d elapsed=%s error=%T: %v", network, address, attempts, elapsed, err, err)) - return nil, err - } - if debug { - logf(fmt.Sprintf("Bridge dial connected: address=%s remote=%s attempts=%d elapsed=%s", address, conn.RemoteAddr(), attempts, elapsed)) - } - return conn, nil - } - - proxy := httputil.NewSingleHostReverseProxy(target) - director := proxy.Director - proxy.Director = func(req *http.Request) { - director(req) - req.Host = target.Host - } - proxy.Transport = transport - proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - logf(fmt.Sprintf("Bridge proxy error: target=%s path=%s error=%T: %v", target.Redacted(), r.URL.Path, err, err)) - http.Error(w, "bridge proxy error: "+err.Error(), http.StatusBadGateway) - } - - srv := &http.Server{Handler: proxy} - go func() { - _ = srv.Serve(ln) - }() - - return &proxyRuntime{ - localURL: "http://" + ln.Addr().String(), - server: srv, - listener: ln, - }, nil -} - -type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) - -// dialWithDNSRetry gives an embedded tsnet node a short window to receive the -// target's peer map after Up reports Running. Until that map arrives, tsnet's -// MagicDNS lookup falls through to the host resolver and returns a DNSError. -// Non-DNS failures are returned immediately. -func dialWithDNSRetry( - ctx context.Context, - dial bridgeDialFunc, - network, address string, - retryWindow, retryInterval time.Duration, -) (net.Conn, int, error) { - deadline := time.Now().Add(retryWindow) - attempts := 0 - for { - conn, err := dial(ctx, network, address) - attempts++ - if err == nil { - return conn, attempts, nil - } - if ctxErr := ctx.Err(); ctxErr != nil { - return nil, attempts, ctxErr - } - var dnsErr *net.DNSError - if !errors.As(err, &dnsErr) || retryWindow <= 0 || retryInterval <= 0 { - return nil, attempts, err - } - - remaining := time.Until(deadline) - if remaining <= 0 { - return nil, attempts, err - } - if retryInterval > remaining { - retryInterval = remaining - } - timer := time.NewTimer(retryInterval) - select { - case <-ctx.Done(): - timer.Stop() - return nil, attempts, ctx.Err() - case <-timer.C: - } - } -} - -func logBridgeStatus(logf func(string), status *ipnstate.Status, target *url.URL) { - if status == nil { - logf("Bridge network status is unavailable.") - return - } - - var tailnetName, dnsSuffix string - var magicDNS bool - if status.CurrentTailnet != nil { - tailnetName = status.CurrentTailnet.Name - dnsSuffix = status.CurrentTailnet.MagicDNSSuffix - magicDNS = status.CurrentTailnet.MagicDNSEnabled - } - var selfDNS string - if status.Self != nil { - selfDNS = status.Self.DNSName - } - logf(fmt.Sprintf( - "Bridge network: state=%s tailnet=%q dns_suffix=%q magic_dns=%t self=%q ips=%v peers=%d", - status.BackendState, tailnetName, dnsSuffix, magicDNS, selfDNS, status.TailscaleIPs, len(status.Peer), - )) - if len(status.Health) > 0 { - logf("Bridge health: " + strings.Join(status.Health, "; ")) - } - - host := strings.ToLower(strings.TrimSuffix(target.Hostname(), ".")) - expectedFQDN := host - if !strings.Contains(host, ".") && dnsSuffix != "" { - expectedFQDN += "." + strings.ToLower(strings.TrimSuffix(dnsSuffix, ".")) - } - for _, peer := range status.Peer { - peerDNS := strings.ToLower(strings.TrimSuffix(peer.DNSName, ".")) - if peerDNS == host || peerDNS == expectedFQDN { - logf(fmt.Sprintf("Bridge target is visible: requested=%q peer=%q ips=%v", host, peer.DNSName, peer.TailscaleIPs)) - return - } - } - logf(fmt.Sprintf( - "Bridge target is not present among visible peers: requested=%q expected_fqdn=%q peers=%d; check the selected tailnet and grants/ACLs", - host, expectedFQDN, len(status.Peer), - )) -} diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go deleted file mode 100644 index 846c99a..0000000 --- a/internal/bridges/manager_test.go +++ /dev/null @@ -1,502 +0,0 @@ -package bridges - -import ( - "context" - "errors" - "io" - "net" - "net/http" - "net/http/httptest" - "net/netip" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/tailscale/aperture-cli/internal/config" - "tailscale.com/ipn/ipnstate" -) - -type fakeNode struct { - backendAddr string - status *ipnstate.Status - upErr error - statusErr error - dialErr error - dialFn bridgeDialFunc - up int - closed bool -} - -func (n *fakeNode) Up(context.Context) (*ipnstate.Status, error) { - n.up++ - return n.status, n.upErr -} - -func (n *fakeNode) Status(context.Context) (*ipnstate.Status, error) { - return n.status, n.statusErr -} - -func (n *fakeNode) DialContext(ctx context.Context, network, _ string) (net.Conn, error) { - if n.dialFn != nil { - return n.dialFn(ctx, network, n.backendAddr) - } - if n.dialErr != nil { - return nil, n.dialErr - } - var d net.Dialer - return d.DialContext(ctx, network, n.backendAddr) -} - -func TestActivateDebugDiagnostics(t *testing.T) { - status := &ipnstate.Status{ - BackendState: "Running", - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, - Self: &ipnstate.PeerStatus{DNSName: "aperture-cli.example.ts.net."}, - CurrentTailnet: &ipnstate.TailnetStatus{ - Name: "example.com", - MagicDNSSuffix: "example.ts.net", - MagicDNSEnabled: true, - }, - } - node := &fakeNode{status: status, dialErr: errors.New("lookup aperture on 127.0.0.53:53: no such host")} - m := NewManager(true) - m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { - return node - } - defer m.Close() - - var logs []string - localURL, err := m.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://aperture", - func(line string) { logs = append(logs, line) }, - ) - if err != nil { - t.Fatal(err) - } - - resp, err := http.Get(localURL + "/v1/models") - if err != nil { - t.Fatal(err) - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - t.Fatal(err) - } - if resp.StatusCode != http.StatusBadGateway { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadGateway) - } - if !strings.Contains(string(body), "lookup aperture") { - t.Errorf("response body = %q, want dial error", body) - } - - got := strings.Join(logs, "\n") - for _, want := range []string{ - `tailnet="example.com"`, - `dns_suffix="example.ts.net"`, - `target is not present among visible peers`, - `Bridge dial failed`, - `lookup aperture`, - } { - if !strings.Contains(got, want) { - t.Errorf("logs missing %q:\n%s", want, got) - } - } -} - -func TestActivateClosesNodeWhenUpFails(t *testing.T) { - node := &fakeNode{upErr: errors.New("login failed")} - m := NewManager(false) - m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { - return node - } - - _, err := m.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://ai", - nil, - ) - if !errors.Is(err, node.upErr) { - t.Fatalf("Activate error = %v, want %v", err, node.upErr) - } - if !node.closed { - t.Error("node was not closed after Up failure") - } -} - -func TestActivateNormalLoggingOmitsDebugDiagnostics(t *testing.T) { - status := &ipnstate.Status{ - BackendState: "Running", - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, - Self: &ipnstate.PeerStatus{DNSName: "aperture-cli.example.ts.net."}, - CurrentTailnet: &ipnstate.TailnetStatus{ - Name: "example.com", - MagicDNSSuffix: "example.ts.net", - MagicDNSEnabled: true, - }, - } - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer backend.Close() - backendAddr := strings.TrimPrefix(backend.URL, "http://") - node := &fakeNode{status: status, backendAddr: backendAddr} - m := NewManager(false) - m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { - return node - } - defer m.Close() - - var logs []string - localURL, err := m.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://aperture", - func(line string) { logs = append(logs, line) }, - ) - if err != nil { - t.Fatal(err) - } - resp, err := http.Get(localURL + "/v1/models") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - - got := strings.Join(logs, "\n") - for _, unwanted := range []string{"Bridge network:", "Bridge health:", "Bridge target ", "Bridge dialing", "Bridge dial connected:"} { - if strings.Contains(got, unwanted) { - t.Errorf("normal logs contain debug diagnostic %q:\n%s", unwanted, got) - } - } -} - -func TestActivateRetriesDNSWhilePeerMapArrives(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer backend.Close() - - var attempts atomic.Int32 - node := &fakeNode{ - backendAddr: strings.TrimPrefix(backend.URL, "http://"), - status: &ipnstate.Status{ - BackendState: "Running", - TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.1")}, - }, - } - node.dialFn = func(ctx context.Context, network, address string) (net.Conn, error) { - if attempts.Add(1) == 1 { - return nil, &net.DNSError{ - Err: "server misbehaving", - Name: "ai", - Server: "127.0.0.53:53", - IsTemporary: true, - } - } - var d net.Dialer - return d.DialContext(ctx, network, address) - } - - m := NewManager(true) - m.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { - return node - } - defer m.Close() - - var logs []string - localURL, err := m.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://ai", - func(line string) { logs = append(logs, line) }, - ) - if err != nil { - t.Fatal(err) - } - - resp, err := http.Get(localURL + "/v1/models") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent) - } - if got := attempts.Load(); got != 2 { - t.Fatalf("dial attempts = %d, want 2", got) - } - if got := strings.Join(logs, "\n"); !strings.Contains(got, "attempts=2") { - t.Fatalf("logs missing recovered dial attempt count:\n%s", got) - } -} - -func TestDialWithDNSRetry(t *testing.T) { - t.Run("recovers when embedded DNS receives the target", func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - defer backend.Close() - - attempts := 0 - dial := func(ctx context.Context, network, address string) (net.Conn, error) { - attempts++ - if attempts == 1 { - return nil, &net.DNSError{ - Err: "server misbehaving", - Name: "ai", - Server: "127.0.0.53:53", - IsTemporary: true, - } - } - var d net.Dialer - return d.DialContext(ctx, network, strings.TrimPrefix(backend.URL, "http://")) - } - - conn, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, - ) - if err != nil { - t.Fatal(err) - } - conn.Close() - if gotAttempts != 2 { - t.Fatalf("attempts = %d, want 2", gotAttempts) - } - }) - - t.Run("stops when the retry window expires", func(t *testing.T) { - wantErr := &net.DNSError{Err: "server misbehaving", Name: "ai"} - attempts := 0 - dial := func(context.Context, string, string) (net.Conn, error) { - attempts++ - return nil, wantErr - } - - _, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", 5*time.Millisecond, time.Hour, - ) - if !errors.Is(err, wantErr) { - t.Fatalf("error = %v, want %v", err, wantErr) - } - if gotAttempts < 2 || gotAttempts != attempts { - t.Fatalf("attempts = %d/%d, want at least 2 matching attempts", gotAttempts, attempts) - } - }) - - t.Run("does not retry non-DNS failures", func(t *testing.T) { - wantErr := errors.New("connection refused") - attempts := 0 - dial := func(context.Context, string, string) (net.Conn, error) { - attempts++ - return nil, wantErr - } - - _, gotAttempts, err := dialWithDNSRetry( - context.Background(), dial, "tcp", "ai:80", time.Second, time.Millisecond, - ) - if !errors.Is(err, wantErr) { - t.Fatalf("error = %v, want %v", err, wantErr) - } - if gotAttempts != 1 || attempts != 1 { - t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) - } - }) - - t.Run("stops when activation is canceled", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - attempts := 0 - dial := func(context.Context, string, string) (net.Conn, error) { - attempts++ - cancel() - return nil, &net.DNSError{Err: "server misbehaving", Name: "ai"} - } - - _, gotAttempts, err := dialWithDNSRetry( - ctx, dial, "tcp", "ai:80", time.Second, time.Second, - ) - if !errors.Is(err, context.Canceled) { - t.Fatalf("error = %v, want context canceled", err) - } - if gotAttempts != 1 || attempts != 1 { - t.Fatalf("attempts = %d/%d, want 1/1", gotAttempts, attempts) - } - }) -} - -func (n *fakeNode) Close() error { - n.closed = true - return nil -} - -// activatedManager creates a Manager with a fake node wired to backend, -// activates the bridge once, and returns everything tests need. -type activatedFixture struct { - manager *Manager - node *fakeNode - localURL string - logs []string -} - -func activate(t *testing.T, backend *httptest.Server) activatedFixture { - t.Helper() - var f activatedFixture - f.manager = NewManager(false) - f.manager.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { - f.node = &fakeNode{backendAddr: backend.Listener.Addr().String()} - return f.node - } - - var err error - f.localURL, err = f.manager.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://aperture.tailnet", - func(line string) { f.logs = append(f.logs, line) }, - ) - if err != nil { - t.Fatal(err) - } - return f -} - -func TestActivate(t *testing.T) { - tests := []struct { - name string - run func(t *testing.T) - }{ - { - name: "proxies requests to backend", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) - })) - defer backend.Close() - - f := activate(t, backend) - defer f.manager.Close() - - resp, err := http.Get(f.localURL + "/v1/models") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatal(err) - } - if got := string(body); got != `[{"id":"anthropic"}]` { - t.Errorf("body = %s, want %s", got, `[{"id":"anthropic"}]`) - } - }, - }, - { - name: "rewrites Host header to target", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Host != "aperture.tailnet" { - t.Errorf("Host = %q, want aperture.tailnet", r.Host) - } - })) - defer backend.Close() - - f := activate(t, backend) - defer f.manager.Close() - - resp, err := http.Get(f.localURL + "/") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - }, - }, - { - name: "forwards request path", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/v1/models" { - t.Errorf("path = %q, want /v1/models", r.URL.Path) - } - })) - defer backend.Close() - - f := activate(t, backend) - defer f.manager.Close() - - resp, err := http.Get(f.localURL + "/v1/models") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - }, - }, - { - name: "returns localhost URL and calls Up once", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - defer backend.Close() - - f := activate(t, backend) - defer f.manager.Close() - - if !strings.HasPrefix(f.localURL, "http://127.0.0.1:") { - t.Fatalf("localURL = %q, want http://127.0.0.1:... prefix", f.localURL) - } - if f.node.up != 1 { - t.Errorf("Up called %d times, want 1", f.node.up) - } - if len(f.logs) == 0 { - t.Error("expected activation logs") - } - }, - }, - { - name: "reuses existing bridge without calling Up again", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - defer backend.Close() - - f := activate(t, backend) - defer f.manager.Close() - - localURL2, err := f.manager.Activate( - context.Background(), - config.Bridge{ID: "bridge-abcdef", Name: "Work"}, - "http://aperture.tailnet", - nil, - ) - if err != nil { - t.Fatal(err) - } - if localURL2 != f.localURL { - t.Errorf("reused localURL = %q, want %q", localURL2, f.localURL) - } - if f.node.up != 1 { - t.Errorf("Up called %d times after reuse, want 1", f.node.up) - } - }, - }, - { - name: "Close shuts down node", - run: func(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - defer backend.Close() - - f := activate(t, backend) - - if err := f.manager.Close(); err != nil { - t.Fatal(err) - } - if !f.node.closed { - t.Error("node was not closed") - } - }, - }, - } - - for _, tc := range tests { - t.Run(tc.name, tc.run) - } -} diff --git a/internal/bridges/node.go b/internal/bridges/node.go new file mode 100644 index 0000000..c51b1da --- /dev/null +++ b/internal/bridges/node.go @@ -0,0 +1,233 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" + "tailscale.com/health" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tsnet" +) + +type tailnetNode interface { + BringUp(context.Context, events) (*ipnstate.Status, error) + Status(context.Context) (*ipnstate.Status, error) + DialContext(context.Context, string, string) (net.Conn, error) + Logout(context.Context) error + Close() error +} + +type tsnetNode struct { + server *tsnet.Server +} + +// BringUp waits for the node to be usable and reports each wait from the one +// IPN bus watch (ADR 0001, decision 4). tsnet.Server.Up runs a watch of its +// own, and a second consumer of the same bus is evicted when it lags. The +// eviction surfaces as a terminal "IPN bus consumer fell behind" on a login +// the user did nothing wrong in. +// +// Owning the wait means owning what Up did with it: a terminal ErrMessage +// and the check that a Running node has an address. resetServeStateOnce is +// left out because nothing here sets a serve config. +func (n *tsnetNode) BringUp(ctx context.Context, ev events) (*ipnstate.Status, error) { + // LocalClient calls Start, so this is where the node begins registering. + lc, err := n.server.LocalClient() + if err != nil { + return nil, err + } + // InitialHealthState is requested too. Health changes reach every watcher + // regardless of mask, but a login that broke before this watch started + // shows up only in the initial state. That is the reused node case. + watcher, err := lc.WatchIPNBus(ctx, ipn.NotifyInitialState|ipn.NotifyInitialHealthState) + if err != nil { + return nil, err + } + defer watcher.Close() + return bringUp(ctx, watcher, lc.Status, ev) +} + +func (n *tsnetNode) Status(ctx context.Context) (*ipnstate.Status, error) { + lc, err := n.server.LocalClient() + if err != nil { + return nil, err + } + return lc.Status(ctx) +} + +func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return n.server.Dial(ctx, network, address) +} + +// ipnBusWatch is the part of an IPN bus watch that bringUp reads. Tests run the +// loop against a recorded bus through it. +type ipnBusWatch interface { + Next() (ipn.Notify, error) +} + +// bringUp waits for Running on one watch and reports each phase as the node +// enters it. The login link comes off the bus rather than tsnet's five second +// poll loop. The poll loop hides a link that lands just after a tick: one +// bridge was killed a few hundred milliseconds before its link would have +// printed. +func bringUp(ctx context.Context, w ipnBusWatch, statusOf func(context.Context) (*ipnstate.Status, error), ev events) (*ipnstate.Status, error) { + progress := bringUpProgress{ev: ev} + for { + notify, err := w.Next() + if err != nil { + return nil, err + } + if notify.ErrMessage != nil { + return nil, fmt.Errorf("bridge backend: %s", *notify.ErrMessage) + } + progress.notify(¬ify) + if notify.State == nil || *notify.State != ipn.Running { + continue + } + status, err := statusOf(ctx) + if err != nil { + return nil, err + } + if status == nil || len(status.TailscaleIPs) == 0 { + return nil, errors.New("bridge node is running with no tailnet address") + } + return status, nil + } +} + +// bringUpProgress translates IPN bus notifications into connection phases. It +// remembers the last phase because the bus repeats states. +// +// ipn.NeedsLogin covers two waits that look identical and are not. Before a +// BrowseToURL arrives, the control plane has not answered and there is +// nothing to do. After it arrives, everything waits on the user. Reporting +// the backend state made a 29 second registration indistinguishable from +// someone who wandered off. +type bringUpProgress struct { + ev events + phase connection.Phase + // loginBroken records whether the login-state warning is up. Health state + // is re-sent on every retry with a fresh request ID in the text, so + // reporting on the text would add a line a second for as long as the + // failure lasts. + loginBroken bool +} + +func (r *bringUpProgress) enter(p connection.Phase) { + // A re-notified NeedsLogin after the link is already on screen would walk + // the attempt backwards through a wait the user has already left. + if p <= r.phase { + return + } + r.phase = p + r.ev.enter(p) +} + +func (r *bringUpProgress) notify(n *ipn.Notify) { + if n == nil { + return + } + if n.State != nil { + // Log the raw state, not just the phase. NoState and NeedsLogin are + // one phase on screen on purpose, and telling them apart is the whole + // question in a log. NoState means control has not answered the + // register yet. + slog.Info("bridge ipn state", "state", n.State.String()) + switch *n.State { + case ipn.NoState, ipn.NeedsLogin: + // Both map here, and NoState is the one that matters. A bridge + // that never logged in sits in NoState for the whole of POST + // /machine/register, so NoState is the wait, not a not-started-yet. + // Tailscale's own comment reads "UIs should print Loading..." + // (ipnlocal/local.go). + r.enter(connection.AwaitingLoginLink) + case ipn.NeedsMachineAuth: + // NeedsMachineAuth has no phase of its own. We have never seen it, + // and inventing a wait we cannot observe is worse than a line that + // says what to go and do. Promote it if this turns out to be + // common. + r.ev.note("This bridge is waiting to be approved in the tailnet's admin console.") + case ipn.Starting: + r.enter(connection.JoiningTailnet) + case ipn.Running: + r.enter(connection.FindingEndpoint) + } + } + if n.BrowseToURL != nil { + link, err := connection.ParseLoginLink(*n.BrowseToURL) + if err != nil { + // Log the rejection reason, never the link itself. + slog.Error("unusable login link from the control plane", "err", err) + // This does not fail the login. tsnet keeps printing its own copy + // and the user can still finish by hand. The note tells them the + // browser is not going to open. + r.ev.note("Ignoring an unusable login link from the control plane: " + err.Error()) + return + } + r.enter(connection.AwaitingAuthorization) + r.ev.loginRequired(link) + } + r.health(n.Health) +} + +// health reports a login that is failing rather than merely slow. A register +// answered with a 502 leaves the node in NeedsLogin with no BrowseToURL, so +// the attempt sits on "Waiting for a login link" while tsnet retries behind a +// backoff. The error is not a vizerror, so it never reaches Notify.ErrMessage. +// +// Only the login-state warnable is reported. The other warnables describe a +// node that is up and imperfect, and would bury the one line this attempt +// cares about. +func (r *bringUpProgress) health(state *health.State) { + if state == nil { + return + } + warning, broken := state.Warnings[health.LoginStateWarnable.Code] + if broken == r.loginBroken { + return + } + r.loginBroken = broken + if !broken { + slog.Info("bridge login recovered") + return + } + slog.Error("bridge login is failing", "text", redactDiagnostic(warning.Text)) + r.ev.note("The tailnet will not log this bridge in: " + warning.Text) +} + +// Logout initializes the LocalAPI, but does not wait for authorization. A +// bridge whose old identity cannot log in must still be able to leave it. +func (n *tsnetNode) Logout(ctx context.Context) error { + lc, err := n.server.LocalClient() + if err != nil { + return err + } + return lc.Logout(ctx) +} + +func (n *tsnetNode) Close() error { + return n.server.Close() +} + +// newTSNetNode returns the node factory production Machines use. Each node is +// a tsnet.Server on the bridge's state directory, named the way the admin +// console will show it. +func newTSNetNode(debug bool) func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { + return func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { + s := &tsnet.Server{ + Dir: stateDir, + Hostname: MachineName(bridge.ID), + UserLogf: userLogf, + } + if debug { + s.Logf = debugLogf + } + return &tsnetNode{server: s} + } +} diff --git a/internal/bridges/remove.go b/internal/bridges/remove.go new file mode 100644 index 0000000..f65e0b2 --- /dev/null +++ b/internal/bridges/remove.go @@ -0,0 +1,120 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +// destroyTimeout bounds the logout a removal waits on. /machine/register was +// hanging past 90 seconds on 2026-09-17 and logout is a round trip to the same +// place, so a removal cannot wait on it indefinitely (ADR 0002, decision 6). +var destroyTimeout = 45 * time.Second + +// CheckRemovable returns an error when endpoint, or the bridge itself when +// endpoint is nil, cannot be removed. The active endpoint stays: it is the +// connection the user falls back to. A bridge stays while an endpoint still +// connects through it. +func CheckRemovable(g *config.Global, bridge config.Bridge, endpoint config.Endpoint) error { + if endpoint != nil && endpoint == g.ActiveEndpoint() { + return errors.New("connect to another endpoint before removing the active one") + } + if endpoint == nil && bridge.ID != "" { + if users := endpointsThroughBridge(g, bridge.ID); len(users) > 0 { + return fmt.Errorf("bridge %s is used by endpoint %s; remove that connection instead", bridge.Name, users[0].URL()) + } + } + return nil +} + +// WillDestroyMachine reports whether removing endpoint, or the bridge itself +// when endpoint is nil, logs a device out of a tailnet. It does when the +// bridge has started a Machine and no other endpoint connects through it. A +// bridge that never started has no device and must not start one to find out. +func WillDestroyMachine(g *config.Global, bridge config.Bridge, endpoint config.Endpoint) bool { + if bridge.ID == "" || !HasMachine(bridge.ID) { + return false + } + for _, other := range endpointsThroughBridge(g, bridge.ID) { + if other != endpoint { + return false + } + } + return true +} + +// Destroy logs the bridge's Machine out of its tailnet and discards its +// login, waiting at most destroyTimeout for the tailnet to answer. Destroy +// writes no settings. The caller removes the bridge's records only after +// Destroy returns nil: the records are the only thing naming the device, and +// a failed or timed-out logout must stay retryable (ADR 0002). +func (ms *Machines) Destroy(ctx context.Context, bridge config.Bridge, emit func(connection.Event)) error { + mc, err := ms.For(bridge) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, destroyTimeout) + defer cancel() + err = mc.Destroy(ctx, emit) + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("the tailnet did not answer within %s: %w", destroyTimeout, err) + } + return err +} + +// Tailnet returns the tailnet name the bridge's running Machine reports, +// falling back to the name saved on the bridge. A bridge that switched +// tailnets this session keeps a stale saved name until the next verified +// connection rewrites it. +func (ms *Machines) Tailnet(bridge config.Bridge) string { + if mc := ms.lookup(bridge.ID); mc != nil { + if name := mc.Tailnet(); name != "" { + return name + } + } + return bridge.Tailnet +} + +// RemoveFromSettings deletes endpoint from settings, then deletes bridge when +// no endpoint connects through it any more. Call it only after Destroy has +// returned nil, or when WillDestroyMachine is false. +// +// The endpoint and its bridge go together because the picker shows them as +// one row. Deleting the endpoint alone left the bridge listed as a bare +// "Connect via" row, so to the user the row moved instead of disappearing. +func RemoveFromSettings(g *config.Global, bridge config.Bridge, endpoint config.Endpoint) error { + if err := CheckRemovable(g, bridge, endpoint); err != nil { + return err + } + if endpoint != nil { + if err := g.RemoveEndpoint(endpoint); err != nil { + return err + } + } + if bridge.ID != "" && len(endpointsThroughBridge(g, bridge.ID)) == 0 { + return g.RemoveBridge(bridge.ID) + } + return nil +} + +// endpointsThroughBridge returns every saved endpoint that connects through +// bridgeID. +func endpointsThroughBridge(g *config.Global, bridgeID string) []config.Endpoint { + var users []config.Endpoint + for _, endpoint := range g.Settings.Endpoints { + if isReachableThroughBridge(endpoint, bridgeID) { + users = append(users, endpoint) + } + } + return users +} + +// isReachableThroughBridge reports whether endpoint connects through bridgeID. +func isReachableThroughBridge(endpoint config.Endpoint, bridgeID string) bool { + bridged, ok := endpoint.(config.BridgeEndpoint) + return ok && bridged.BridgeID() == bridgeID +} diff --git a/internal/bridges/route.go b/internal/bridges/route.go new file mode 100644 index 0000000..aa65f72 --- /dev/null +++ b/internal/bridges/route.go @@ -0,0 +1,292 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/netip" + "net/url" + "strings" + "time" + + "tailscale.com/ipn/ipnstate" +) + +// Route reverse-proxies a loopback listener to one Endpoint over one +// Machine's node. LocalURL is the URL a client is told to use. A Route +// belongs to exactly one Machine and closes with it. +type Route struct { + LocalURL string + server *http.Server + listener net.Listener +} + +// close shuts the listener and server. An already closed Route is not a +// failure, because Close and LeaveTailnet can both reach the same Route. +func (r *Route) close() error { + var errs []error + if err := r.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errs = append(errs, err) + } + if err := r.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func parseTarget(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("endpoint URL is empty") + } + target, err := url.Parse(raw) + if err != nil { + return nil, err + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("endpoint URL must include scheme and host") + } + return target, nil +} + +// openRoute builds the reverse proxy for one target on the Machine's node. +// The proxy reports through the Machine's relay because the Route is cached +// and keeps serving long after the attempt that asked for it has gone. The +// caller holds the Machine's turn. +func (mc *Machine) openRoute(target *url.URL) (*Route, error) { + node, ev := mc.node, events(mc.ev.emit) + debug := mc.machines.debug + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + start := time.Now() + if debug { + ev.notef("Bridge dialing network=%s address=%s", network, address) + } + conn, attempts, err := dialViaNode( + ctx, + node, + network, + address, + ev, + mc.machines.peerWait, + mc.machines.peerWaitInterval, + ) + elapsed := time.Since(start).Round(time.Millisecond) + if err != nil { + ev.notef("Bridge dial failed: network=%s address=%s attempts=%d elapsed=%s error=%T: %v", network, address, attempts, elapsed, err, err) + return nil, err + } + if debug { + ev.notef("Bridge dial connected: address=%s remote=%s attempts=%d elapsed=%s", address, conn.RemoteAddr(), attempts, elapsed) + } + return conn, nil + } + + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(req *http.Request) { + director(req) + req.Host = target.Host + } + proxy.Transport = transport + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + ev.notef("Bridge proxy error: target=%s path=%s error=%T: %v", redactURL(target.String()), r.URL.Path, err, err) + http.Error(w, "bridge proxy error: "+err.Error(), http.StatusBadGateway) + } + + srv := &http.Server{Handler: proxy} + go func() { + _ = srv.Serve(ln) + }() + + return &Route{ + LocalURL: "http://" + ln.Addr().String(), + server: srv, + listener: ln, + }, nil +} + +type bridgeDialFunc func(context.Context, string, string) (net.Conn, error) + +// dialViaNode dials address over the bridge's node. A hostname is resolved +// against the node's own peer map first and the IP found there is dialed. +// +// Handing the name to tsnet made a first connection hang for 30s. Until the +// netmap lands, tsnet's resolver falls through to the host resolver, and on a +// machine already on a tailnet that answers with a same-named node on the +// wrong one. A short alias gets this node's current tailnet suffix. A shared +// peer needs its full name. +func dialViaNode( + ctx context.Context, + node tailnetNode, + network, address string, + ev events, + peerWaitWindow, peerWaitInterval time.Duration, +) (net.Conn, int, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, 0, err + } + if _, err := netip.ParseAddr(host); err == nil { + conn, err := node.DialContext(ctx, network, address) + return conn, 1, err + } + + ip, attempts, err := waitForPeerAddr(ctx, node, host, peerWaitWindow, peerWaitInterval) + if err != nil { + if ctx.Err() != nil { + return nil, attempts, err + } + // A bare name is a peer alias and nothing else. Handed to tsnet it + // would fall through to the host resolver, and on a machine already + // on another tailnet that answers with that tailnet's node of the + // same name. The wait above only delayed that. + if !strings.Contains(host, ".") { + return nil, attempts, fmt.Errorf("%s is not a node on this bridge's tailnet (%v)", host, err) + } + // A qualified name can be a subnet route or the tailnet's own DNS. + // Only tsnet can resolve those, so fall through to it and say so, + // because this path can leave the tailnet. + ev.notef("Bridge target %s is not a node on this bridge's tailnet (%v); resolving it the usual way.", host, err) + conn, derr := node.DialContext(ctx, network, address) + return conn, attempts, derr + } + + conn, err := node.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + return conn, attempts, err +} + +// waitForPeerAddr polls the node's status until host shows up as a peer. A +// node that just came up reports Running before its peer map arrives, so the +// first look usually misses. +func waitForPeerAddr( + ctx context.Context, + node tailnetNode, + host string, + window, interval time.Duration, +) (netip.Addr, int, error) { + deadline := time.Now().Add(window) + attempts := 0 + for { + status, err := node.Status(ctx) + attempts++ + if err == nil { + if ip, ok := peerAddr(status, host); ok { + return ip, attempts, nil + } + err = errors.New("not in this node's peer map") + } + if ctxErr := ctx.Err(); ctxErr != nil { + return netip.Addr{}, attempts, ctxErr + } + + remaining := time.Until(deadline) + if remaining <= 0 || interval <= 0 { + return netip.Addr{}, attempts, err + } + if interval > remaining { + interval = remaining + } + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return netip.Addr{}, attempts, ctx.Err() + case <-timer.C: + } + } +} + +// peerAddr finds host in the peer map. A short name is qualified with the +// current tailnet's MagicDNS suffix and matched only there. A shared-in peer +// can have the same first label but belongs to another tailnet, so reaching +// it requires its full name. +func peerAddr(status *ipnstate.Status, host string) (netip.Addr, bool) { + if status == nil { + return netip.Addr{}, false + } + want := strings.ToLower(strings.TrimSuffix(host, ".")) + if !strings.Contains(want, ".") { + if status.CurrentTailnet == nil { + return netip.Addr{}, false + } + suffix := strings.ToLower(strings.TrimSuffix(status.CurrentTailnet.MagicDNSSuffix, ".")) + if suffix == "" || want == "" { + return netip.Addr{}, false + } + want += "." + suffix + } + for _, peer := range status.Peer { + if peer == nil || strings.ToLower(strings.TrimSuffix(peer.DNSName, ".")) != want { + continue + } + if ip, ok := preferIPv4(peer.TailscaleIPs); ok { + return ip, true + } + } + return netip.Addr{}, false +} + +func preferIPv4(addrs []netip.Addr) (netip.Addr, bool) { + var fallback netip.Addr + for _, addr := range addrs { + if addr.Is4() { + return addr, true + } + if !fallback.IsValid() { + fallback = addr + } + } + return fallback, fallback.IsValid() +} + +func logBridgeStatus(ev events, status *ipnstate.Status, target *url.URL) { + if status == nil { + ev.note("Bridge network status is unavailable.") + return + } + + var tailnetName, dnsSuffix string + var magicDNS bool + if status.CurrentTailnet != nil { + tailnetName = status.CurrentTailnet.Name + dnsSuffix = status.CurrentTailnet.MagicDNSSuffix + magicDNS = status.CurrentTailnet.MagicDNSEnabled + } + var selfDNS string + if status.Self != nil { + selfDNS = status.Self.DNSName + } + ev.notef( + "Bridge network: state=%s tailnet=%q dns_suffix=%q magic_dns=%t self=%q ips=%v peers=%d", + status.BackendState, tailnetName, dnsSuffix, magicDNS, selfDNS, status.TailscaleIPs, len(status.Peer), + ) + if len(status.Health) > 0 { + ev.note("Bridge health: " + strings.Join(status.Health, "; ")) + } + + host := strings.ToLower(strings.TrimSuffix(target.Hostname(), ".")) + expectedFQDN := host + if !strings.Contains(host, ".") && dnsSuffix != "" { + expectedFQDN += "." + strings.ToLower(strings.TrimSuffix(dnsSuffix, ".")) + } + for _, peer := range status.Peer { + peerDNS := strings.ToLower(strings.TrimSuffix(peer.DNSName, ".")) + if peerDNS == host || peerDNS == expectedFQDN { + ev.notef("Bridge target is visible: requested=%q peer=%q ips=%v", host, peer.DNSName, peer.TailscaleIPs) + return + } + } + ev.notef( + "Bridge target is not present among visible peers: requested=%q expected_fqdn=%q peers=%d; check the selected tailnet and grants/ACLs", + host, expectedFQDN, len(status.Peer), + ) +} diff --git a/internal/bridges/security_test.go b/internal/bridges/security_test.go new file mode 100644 index 0000000..f26ce7d --- /dev/null +++ b/internal/bridges/security_test.go @@ -0,0 +1,215 @@ +package bridges + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "os" + "strings" + "testing" + "time" + + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" + "tailscale.com/ipn/ipnstate" + "tailscale.com/types/key" +) + +func TestPeerAddrScopesShortNames(t *testing.T) { + for _, tt := range []struct { + name, host, suffix, want string + local bool + }{ + {name: "foreign short name", host: "ai", suffix: "work-tail.ts.net"}, + {name: "unknown suffix", host: "ai"}, + {name: "local short name wins", host: "ai", suffix: "work-tail.ts.net", local: true, want: "100.64.0.2"}, + {name: "normalized suffix", host: "AI.", suffix: "WORK-TAIL.TS.NET.", local: true, want: "100.64.0.2"}, + {name: "explicit shared peer", host: "AI.attacker-tail.ts.net.", suffix: "work-tail.ts.net", want: "100.64.0.99"}, + {name: "explicit peer without suffix", host: "ai.attacker-tail.ts.net", want: "100.64.0.99"}, + } { + t.Run(tt.name, func(t *testing.T) { + status := tailnetStatus("ai.attacker-tail.ts.net.", "100.64.0.99") + status.CurrentTailnet = nil + if tt.suffix != "" { + status.CurrentTailnet = &ipnstate.TailnetStatus{MagicDNSSuffix: tt.suffix} + } + if tt.local { + status.Peer[key.NewNode().Public()] = &ipnstate.PeerStatus{ + DNSName: "ai.work-tail.ts.net.", TailscaleIPs: []netip.Addr{netip.MustParseAddr("100.64.0.2")}, + } + } + for range 64 { // Map iteration must never choose the foreign short alias. + got, ok := peerAddr(status, tt.host) + if ok != (tt.want != "") || ok && got.String() != tt.want { + t.Fatalf("peerAddr(%q) = %v, %v; want %q", tt.host, got, ok, tt.want) + } + } + }) + } +} + +type sharedPeerNode struct { + *fakeNode + backend string +} + +func (n *sharedPeerNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if address != "100.64.0.99:80" { + return nil, fmt.Errorf("no work-tail peer at %s", address) + } + var d net.Dialer + return d.DialContext(ctx, network, n.backend) +} + +func TestProxyRequiresExplicitSharedPeerName(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + const payload = "synthetic-private-prompt" + received := make(chan string, 2) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + received <- string(body) + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + status := tailnetStatus("ai.attacker-tail.ts.net.", "100.64.0.99") + status.CurrentTailnet = &ipnstate.TailnetStatus{MagicDNSSuffix: "work-tail.ts.net"} + node := &sharedPeerNode{fakeNode: &fakeNode{status: status}, backend: backend.Listener.Addr().String()} + m := NewMachines(false) + m.peerWait = 0 + m.newNode = func(config.Bridge, string, func(string, ...any), func(string, ...any)) tailnetNode { return node } + defer m.Close() + for _, target := range []string{"http://ai", "http://ai.attacker-tail.ts.net"} { + t.Run(target, func(t *testing.T) { + localURL, err := activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef", Name: "Work"}, target, nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Timeout: time.Second} + resp, err := client.Post(localURL+"/v1/chat/completions", "application/json", strings.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if target == "http://ai" { + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("bare name status = %d, want a failed dial", resp.StatusCode) + } + select { + case body := <-received: + t.Errorf("shared-in peer received data intended for work-tail's ai: %q", body) + default: + } + } else { + if resp.StatusCode != http.StatusOK { + t.Fatalf("explicit peer status = %d", resp.StatusCode) + } + if body := <-received; body != payload { + t.Errorf("explicit peer received %q", body) + } + } + }) + } +} + +func TestRunLogOmitsLoginCapabilities(t *testing.T) { + const secret = "synthetic-login-token" + const authURL = "https://login.tailscale.com/a/" + secret + for _, level := range []slog.Level{slog.LevelInfo, slog.LevelDebug} { + for _, source := range []string{"login event", "rejected link", "health warning", "backend and startup error"} { + t.Run(level.String()+"/"+source, func(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + f, err := config.OpenRunLog() + if err != nil { + t.Fatal(err) + } + defer f.Close() + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: level}))) + defer slog.SetDefault(previous) + switch source { + case "login event": + link, err := connection.ParseLoginLink(authURL) + if err != nil { + t.Fatal(err) + } + var shown *connection.LoginLink + sink(func(e connection.Event) { shown = e.Link })(connection.LoginRequired(link)) + if shown.String() != authURL { + t.Fatal("interactive consumer lost the authorization URL") + } + case "rejected link": + r := bringUpProgress{ev: sink(nil)} + r.notify(browse("http://login.tailscale.com/a/" + secret)) + case "health warning": + r := bringUpProgress{ev: sink(nil)} + r.notify(unhealthyLogin("request failed: " + authURL)) + case "backend and startup error": + m := NewMachines(true) + m.newNode = func(_ config.Bridge, _ string, userLogf, debugLogf func(string, ...any)) tailnetNode { + userLogf("To authenticate, visit: %s", authURL) + debugLogf("Received auth URL: %q", "HTTPS://login.tailscale.com/a/"+secret) + return &fakeNode{upErr: errors.New("authorization failed at " + authURL)} + } + _, _ = activateMachine(m, context.Background(), config.Bridge{ID: "bridge-abcdef"}, "http://ai", nil) + _ = m.Close() + } + data, err := os.ReadFile(f.Name()) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Fatal("diagnostic event was lost instead of redacted") + } + if strings.Contains(string(data), secret) { + t.Errorf("persistent %s log contains the authorization capability", level) + } + }) + } + } +} + +// A bare name that is not in this Machine's peer map is not handed to tsnet +// to resolve. Its resolver falls through to the host resolver, which on a +// machine already on another tailnet answers with that tailnet's node of the +// same name, and the five second wait only delayed that. A name with a dot +// cannot be mistaken for a peer alias, so a subnet router or the tailnet's +// own DNS may still serve it. +func TestDialRefusesAnUnknownShortName(t *testing.T) { + node := &fakeNode{status: tailnetStatus("other.work-tail.ts.net.", "100.64.0.7")} + _, _, err := dialViaNode(context.Background(), node, "tcp", "ai:80", sink(nil), 0, 0) + if err == nil { + t.Fatal("dial of an unknown short name succeeded") + } + if dialed := node.dialedAddrs(); len(dialed) != 0 { + t.Errorf("short name handed to tsnet: dialed %v", dialed) + } + + node.dialErr = errors.New("no route") + _, _, err = dialViaNode(context.Background(), node, "tcp", "db.internal.example:5432", sink(nil), 0, 0) + if err == nil || !errors.Is(err, node.dialErr) { + t.Fatalf("qualified name err = %v, want the node's own dial", err) + } + if dialed := node.dialedAddrs(); len(dialed) != 1 || dialed[0] != "db.internal.example:5432" { + t.Errorf("qualified name dialed %v, want it resolved the way tsnet would", dialed) + } +} + +// The run log is the file people share for help. An endpoint URL can carry +// userinfo and a query, so the log gets the origin and nothing else. +func TestRedactURLKeepsOnlyTheOrigin(t *testing.T) { + for raw, want := range map[string]string{ + "https://user:password@ai.example.ts.net:8443/v1?token=secret": "https://ai.example.ts.net:8443", + "http://ai": "http://ai", + "not a url at all": "[redacted URL]", + } { + if got := redactURL(raw); got != want { + t.Errorf("redactURL(%q) = %q, want %q", raw, got, want) + } + } +} diff --git a/internal/config/endpoint.go b/internal/config/endpoint.go new file mode 100644 index 0000000..378805f --- /dev/null +++ b/internal/config/endpoint.go @@ -0,0 +1,119 @@ +package config + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// DefaultLocation is the well-known Aperture URL. Every connection attempt, +// direct or bridged, tries it first. A user with no saved settings starts +// here. +const DefaultLocation = "http://ai" + +// Endpoint names a remote Aperture and how to reach it. Two kinds exist and +// no third. A DirectEndpoint is reached by the host itself. A BridgeEndpoint +// is reached through a Bridge's Machine. Values are comparable, so two +// Endpoints are the same when == says so. +type Endpoint interface { + URL() string + // WithURL returns the same kind of Endpoint pointed at a different URL. + // An edit or an inline override keeps its Bridge. + WithURL(url string) Endpoint + endpoint() +} + +// DirectEndpoint reaches an Aperture over the host's own network. +type DirectEndpoint struct{ url string } + +// BridgeEndpoint reaches an Aperture through the Machine of one Bridge. +type BridgeEndpoint struct{ url, bridgeID string } + +// Direct returns the Endpoint that reaches url without a Bridge. +func Direct(url string) DirectEndpoint { return DirectEndpoint{url: url} } + +// Bridged returns the Endpoint that reaches url through bridgeID. +func Bridged(url, bridgeID string) BridgeEndpoint { + return BridgeEndpoint{url: url, bridgeID: bridgeID} +} + +func (e DirectEndpoint) URL() string { return e.url } +func (e DirectEndpoint) WithURL(url string) Endpoint { return Direct(url) } +func (e DirectEndpoint) endpoint() {} + +func (e BridgeEndpoint) URL() string { return e.url } +func (e BridgeEndpoint) BridgeID() string { return e.bridgeID } +func (e BridgeEndpoint) WithURL(url string) Endpoint { return Bridged(url, e.bridgeID) } +func (e BridgeEndpoint) endpoint() {} + +// Bridge configures an embedded tsnet node. The node reaches Aperture without +// Tailscale running on the host. +type Bridge struct { + ID string `json:"id"` + Name string `json:"name"` + // Tailnet names the tailnet the node last logged in to. Commit records it + // after a successful connection, so the connection picker can name the + // tailnet before the node starts again. + Tailnet string `json:"tailnet,omitempty"` +} + +// ParseEndpointURL turns user input into an Endpoint URL. A bare host gets +// http, since Aperture is reached over the tailnet. +func ParseEndpointURL(value string) (string, error) { + value = strings.TrimSpace(value) + if !strings.Contains(value, "://") { + value = "http://" + value + } + u, err := url.ParseRequestURI(value) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "", fmt.Errorf("endpoint URL must be an absolute http or https URL") + } + return strings.TrimRight(value, "/"), nil +} + +// endpointRecord is the settings.json form of an Endpoint. Both kinds share +// one shape, and bridgeId being present tells them apart. The file predates +// the two types and is not changing under existing users. +type endpointRecord struct { + URL string `json:"url"` + BridgeID string `json:"bridgeId,omitempty"` +} + +func recordOf(ep Endpoint) endpointRecord { + switch ep := ep.(type) { + case BridgeEndpoint: + return endpointRecord{URL: ep.URL(), BridgeID: ep.BridgeID()} + case nil: + return endpointRecord{} + default: + return endpointRecord{URL: ep.URL()} + } +} + +func (r endpointRecord) endpoint() Endpoint { + if r.BridgeID != "" { + return Bridged(r.URL, r.BridgeID) + } + return Direct(r.URL) +} + +func (e DirectEndpoint) MarshalJSON() ([]byte, error) { return json.Marshal(recordOf(e)) } +func (e BridgeEndpoint) MarshalJSON() ([]byte, error) { return json.Marshal(recordOf(e)) } + +// endpointList holds the Endpoints field of Settings. Its elements are an +// interface, and encoding/json cannot decode those without being told the +// concrete types. +type endpointList []Endpoint + +func (l *endpointList) UnmarshalJSON(data []byte) error { + var records []endpointRecord + if err := json.Unmarshal(data, &records); err != nil { + return err + } + *l = make(endpointList, 0, len(records)) + for _, r := range records { + *l = append(*l, r.endpoint()) + } + return nil +} diff --git a/internal/config/endpoint_test.go b/internal/config/endpoint_test.go new file mode 100644 index 0000000..584b9cc --- /dev/null +++ b/internal/config/endpoint_test.go @@ -0,0 +1,63 @@ +package config + +import "testing" + +func TestParseEndpointURL(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "bare host assumes http", in: "ai", want: "http://ai"}, + {name: "trailing slash trimmed", in: "http://ai/", want: "http://ai"}, + {name: "surrounding space trimmed", in: " http://ai ", want: "http://ai"}, + {name: "https preserved", in: "https://aperture.example.ts.net", want: "https://aperture.example.ts.net"}, + {name: "empty", in: " ", wantErr: true}, + {name: "scheme only", in: "http://", wantErr: true}, + {name: "unsupported scheme", in: "ftp://ai", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseEndpointURL(tt.in) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseEndpointURL(%q) = %q, want error", tt.in, got) + } + return + } + if err != nil { + t.Fatalf("ParseEndpointURL(%q) error = %v", tt.in, err) + } + if got != tt.want { + t.Errorf("ParseEndpointURL(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// Two kinds, told apart by the type and not by an empty field, and the same +// file shape as before for both. +func TestEndpointKindsRoundTripThroughSettings(t *testing.T) { + s := Settings{Endpoints: []Endpoint{Direct("http://ai"), Bridged("http://ai", "bridge-abcdef")}} + data, err := s.Endpoints[1].(BridgeEndpoint).MarshalJSON() + if err != nil { + t.Fatal(err) + } + if string(data) != `{"url":"http://ai","bridgeId":"bridge-abcdef"}` { + t.Errorf("bridge endpoint = %s", data) + } + var list endpointList + if err := list.UnmarshalJSON([]byte(`[{"url":"http://ai"},{"url":"http://ai","bridgeId":"bridge-abcdef"}]`)); err != nil { + t.Fatal(err) + } + if len(list) != 2 || list[0] != Direct("http://ai") || list[1] != Bridged("http://ai", "bridge-abcdef") { + t.Errorf("decoded = %#v", list) + } + if Direct("http://ai") == Endpoint(Bridged("http://ai", "")) { + t.Error("a direct endpoint compared equal to a bridged one") + } + if got := Bridged("http://old", "bridge-abcdef").WithURL("http://new"); got != Bridged("http://new", "bridge-abcdef") { + t.Errorf("WithURL = %#v", got) + } +} diff --git a/internal/config/global.go b/internal/config/global.go index 99c5f01..cfa06dd 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -5,34 +5,33 @@ import ( "strings" ) -// Global is the live mutable app-level state threaded through the TUI and -// every client package. It holds the current Aperture endpoint, the user's -// persisted settings, the last-launch record, and the provider list fetched -// from the active endpoint. Mutator methods persist to disk on success. +// Global holds the live app state the TUI and every client package share: +// the current Aperture URL, the user's saved settings, the last-launch record +// and the providers fetched from the active endpoint. Every mutator method +// writes to disk before it changes the in-memory copy. type Global struct { - // ApertureHost is the currently active Aperture endpoint URL. + // ApertureHost is the URL clients send requests to right now. ApertureHost string - // Settings is the persisted user configuration (endpoint list, YOLO mode). + // Settings holds the saved user configuration. Settings Settings - // LastLaunch is the persisted record of the last successful client launch. + // LastLaunch records the last successful client launch. LastLaunch LaunchState - // Providers is the provider-level view aggregated from the active - // endpoint's /v1/models response. - // Populated by the TUI's preflight after a successful check. + // Providers lists the providers the active endpoint answered /v1/models + // with. The TUI's preflight fills it after a successful check. Providers []ProviderInfo - // Debug enables bridge diagnostics and verbose stderr dumps of env/args - // before each launch. Not persisted; set from the --debug flag. + // Debug turns on bridge diagnostics and dumps env and args to stderr + // before each launch. Not saved; set from the --debug flag. Debug bool } -// Load reads Settings and LaunchState from disk and returns a populated -// Global. The active ApertureHost is the first endpoint if any are configured, -// otherwise DefaultLocation. Providers is left empty for the TUI to populate -// after its preflight. +// Load reads Settings and LaunchState from disk and returns a Global. +// ApertureHost starts as the first configured endpoint, or DefaultLocation +// when there is none. Providers stays empty until the TUI's preflight fills +// it. func Load() (*Global, error) { s, err := LoadSettings() if err != nil { @@ -44,7 +43,7 @@ func Load() (*Global, error) { } host := DefaultLocation if len(s.Endpoints) > 0 { - host = s.Endpoints[0].URL + host = s.Endpoints[0].URL() } return &Global{ ApertureHost: host, @@ -53,29 +52,30 @@ func Load() (*Global, error) { }, nil } -// SetYolo toggles YOLO mode and persists the new setting. +// SetYolo sets YOLO mode and saves it. func (g *Global) SetYolo(on bool) error { g.Settings.YoloMode = on return SaveSettings(g.Settings) } -// ActiveEndpoint returns the persisted endpoint currently selected by the -// user. The runtime ApertureHost may differ for bridge endpoints because it +// ActiveEndpoint returns the saved endpoint the user selected. ApertureHost +// can differ from its URL for a bridged endpoint, because ApertureHost then // points at the local reverse proxy. func (g *Global) ActiveEndpoint() Endpoint { if len(g.Settings.Endpoints) == 0 { - return Endpoint{URL: DefaultLocation} + return Direct(DefaultLocation) } return g.Settings.Endpoints[0] } -// SetActiveEndpoint rotates the endpoint to the front of the endpoint list -// (adding it if missing), updates ApertureHost to the endpoint URL, and -// persists. Bridge activation later rewrites ApertureHost to localhost. -func (g *Global) SetActiveEndpoint(ep Endpoint) error { +// SetActiveEndpoint moves ep to the front of the endpoint list, adding it if +// missing, sets ApertureHost to its URL and saves. replacing is the original +// of a verified URL edit and goes in the same write; pass nil otherwise. +// Bridge activation later rewrites ApertureHost to the local proxy. +func (g *Global) SetActiveEndpoint(ep Endpoint, replacing Endpoint) error { eps := []Endpoint{ep} for _, existing := range g.Settings.Endpoints { - if !sameEndpoint(existing, ep) { + if existing != ep && existing != replacing { eps = append(eps, existing) } } @@ -85,21 +85,21 @@ func (g *Global) SetActiveEndpoint(ep Endpoint) error { return err } g.Settings = next - g.ApertureHost = ep.URL + g.ApertureHost = ep.URL() return nil } -// SetApertureHost rotates the direct URL to the front of the endpoint list -// (adding it if missing), updates ApertureHost, and persists. +// SetApertureHost makes the direct endpoint at url active. See +// SetActiveEndpoint. func (g *Global) SetApertureHost(url string) error { - return g.SetActiveEndpoint(Endpoint{URL: url}) + return g.SetActiveEndpoint(Direct(url), nil) } -// UpsertEndpoint appends the endpoint to the endpoint list if not already present, -// without changing which endpoint is active, and persists. +// UpsertEndpoint appends ep to the endpoint list when it is not already +// there and saves. The active endpoint does not change. func (g *Global) UpsertEndpoint(ep Endpoint) error { for _, existing := range g.Settings.Endpoints { - if sameEndpoint(existing, ep) { + if existing == ep { return nil } } @@ -112,27 +112,27 @@ func (g *Global) UpsertEndpoint(ep Endpoint) error { return nil } -// ReplaceEndpoint replaces old with next in place and persists the result. -// It does not change which endpoint is active unless old is already active. +// ReplaceEndpoint puts next where old was and saves. The active endpoint +// changes only when old was the active one. func (g *Global) ReplaceEndpoint(old, next Endpoint) error { eps := append([]Endpoint(nil), g.Settings.Endpoints...) oldIdx := -1 for i, existing := range eps { - if !sameEndpoint(existing, old) { + if existing != old { continue } oldIdx = i break } if oldIdx < 0 { - return fmt.Errorf("endpoint %s is not configured", old.URL) + return fmt.Errorf("endpoint %s is not configured", old.URL()) } eps[oldIdx] = next deduped := eps[:0] for _, ep := range eps { duplicate := false for _, existing := range deduped { - if sameEndpoint(existing, ep) { + if existing == ep { duplicate = true break } @@ -148,15 +148,15 @@ func (g *Global) ReplaceEndpoint(old, next Endpoint) error { } g.Settings = updated if oldIdx == 0 { - g.ApertureHost = next.URL + g.ApertureHost = next.URL() } return nil } -// RemoveEndpoint deletes the endpoint at idx and persists. The active endpoint -// is kept pointing at index 0 after removal; callers are responsible for -// re-running preflight if the active endpoint changed. -func (g *Global) RemoveEndpoint(idx int) error { +// removeEndpointAt deletes the endpoint at idx and saves. Index 0 stays the +// active endpoint, so removing index 0 promotes the next one. Callers rerun +// preflight when the active endpoint changed. +func (g *Global) removeEndpointAt(idx int) error { if idx < 0 || idx >= len(g.Settings.Endpoints) { return nil } @@ -170,12 +170,26 @@ func (g *Global) RemoveEndpoint(idx int) error { } g.Settings = next if idx == 0 && len(eps) > 0 { - g.ApertureHost = eps[0].URL + g.ApertureHost = eps[0].URL() } return nil } -// AddBridge creates, saves, and returns a bridge with a generated stable ID. +// RemoveEndpoint removes ep from the list and saves. The active endpoint is +// never dropped: it is the connection the user falls back to. An endpoint +// not in the list is not an error. +func (g *Global) RemoveEndpoint(ep Endpoint) error { + for i, existing := range g.Settings.Endpoints { + if i == 0 || existing != ep { + continue + } + return g.removeEndpointAt(i) + } + return nil +} + +// AddBridge creates a bridge named name with a generated ID, saves it and +// returns it. func (g *Global) AddBridge(name string) (Bridge, error) { name = strings.TrimSpace(name) if name == "" { @@ -195,11 +209,32 @@ func (g *Global) AddBridge(name string) (Bridge, error) { return p, nil } -// RemoveBridge deletes a bridge if no endpoint still references it. +// SetBridgeTailnet records the tailnet bridge id logged in to and saves. An +// unknown bridge is not an error. The user may have deleted it while the +// connection that reported the name was still coming up. +func (g *Global) SetBridgeTailnet(id, tailnet string) error { + for i, p := range g.Settings.Bridges { + if p.ID != id || p.Tailnet == tailnet { + continue + } + next := g.Settings + next.Bridges = append([]Bridge(nil), g.Settings.Bridges...) + next.Bridges[i].Tailnet = tailnet + if err := SaveSettings(next); err != nil { + return err + } + g.Settings = next + return nil + } + return nil +} + +// RemoveBridge deletes bridge id and saves. It refuses while an endpoint +// still connects through the bridge. func (g *Global) RemoveBridge(id string) error { for _, ep := range g.Settings.Endpoints { - if ep.BridgeID == id { - return fmt.Errorf("bridge is used by endpoint %s", ep.URL) + if ep, ok := ep.(BridgeEndpoint); ok && ep.BridgeID() == id { + return fmt.Errorf("bridge is used by endpoint %s", ep.URL()) } } for i, p := range g.Settings.Bridges { @@ -218,7 +253,7 @@ func (g *Global) RemoveBridge(id string) error { return nil } -// Bridge returns the configured bridge with id. +// Bridge returns the configured bridge with id, and whether one exists. func (g *Global) Bridge(id string) (Bridge, bool) { for _, p := range g.Settings.Bridges { if p.ID == id { @@ -228,17 +263,20 @@ func (g *Global) Bridge(id string) (Bridge, bool) { return Bridge{}, false } -// RecordLaunch stores the launch record to disk and updates the in-memory copy. +// RecordLaunch stamps s with the active endpoint, saves it and keeps it as +// LastLaunch. func (g *Global) RecordLaunch(s LaunchState) error { ep := g.ActiveEndpoint() - s.LastEndpointURL = ep.URL - s.LastBridgeID = ep.BridgeID + s.LastEndpointURL = ep.URL() + if ep, ok := ep.(BridgeEndpoint); ok { + s.LastBridgeID = ep.BridgeID() + } g.LastLaunch = s return SaveState(s) } -// Provider returns the ProviderInfo for id, or a zero value and false if no -// such provider is in g.Providers. +// Provider returns the ProviderInfo for id, or a zero value and false when +// g.Providers has no such provider. func (g *Global) Provider(id string) (ProviderInfo, bool) { for _, p := range g.Providers { if p.ID == id { diff --git a/internal/config/runlog.go b/internal/config/runlog.go new file mode 100644 index 0000000..2e5b0f4 --- /dev/null +++ b/internal/config/runlog.go @@ -0,0 +1,44 @@ +package config + +import ( + "os" + "path/filepath" +) + +// runLogCap bounds the run log. The next run starts the file over once it +// passes this size. A connect attempt writes a few hundred bytes, so the cap +// holds a long history and still cannot grow without bound on a box nobody +// prunes. +// +// The file is truncated, not rotated. Rotate if anyone ever needs the older +// runs. +const runLogCap = 2 << 20 + +// RunLogPath returns the file every run writes its diagnostics to. The file +// sits beside the settings and bridge state rather than in a temp dir. The +// question it answers ("what was the last run waiting on?") gets asked after +// a reboot as often as before one. +func RunLogPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "aperture", "aperture.log"), nil +} + +// OpenRunLog opens the run log for appending, creating the directory on first +// use and starting the file over once it passes runLogCap. +func OpenRunLog() (*os.File, error) { + path, err := RunLogPath() + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + flags := os.O_CREATE | os.O_WRONLY | os.O_APPEND + if info, err := os.Stat(path); err == nil && info.Size() > runLogCap { + flags = os.O_CREATE | os.O_WRONLY | os.O_TRUNC + } + return os.OpenFile(path, flags, 0o600) +} diff --git a/internal/config/runlog_test.go b/internal/config/runlog_test.go new file mode 100644 index 0000000..5f4636a --- /dev/null +++ b/internal/config/runlog_test.go @@ -0,0 +1,57 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// TestOpenRunLogAppendsThenTruncates covers the two things the run log has to +// get right to be readable: a run does not erase the one before it, and the +// file cannot grow forever on a box where nothing prunes it. +func TestOpenRunLogAppendsThenTruncates(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + write := func(s string) { + f, err := config.OpenRunLog() + if err != nil { + t.Fatalf("OpenRunLog: %v", err) + } + if _, err := f.WriteString(s); err != nil { + t.Fatalf("write: %v", err) + } + f.Close() + } + + write("first run\n") + write("second run\n") + + path, err := config.RunLogPath() + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if want := "first run\nsecond run\n"; string(got) != want { + t.Errorf("log = %q, want %q: a run erased the one that failed before it", got, want) + } + + if err := os.WriteFile(path, []byte(strings.Repeat("x", (2<<20)+1)), 0o600); err != nil { + t.Fatal(err) + } + write("after the cap\n") + got, err = os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "after the cap\n" { + t.Errorf("log is %d bytes, want the oversized file started over", len(got)) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index bdbfb7c..6176957 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,7 +1,7 @@ -// Package config holds the launcher's app-level persistent state: the list -// of Aperture endpoints the user has configured, the active endpoint, the -// YOLO-mode flag, and the record of the last-used client launch. Clients -// also reach through this package for isolated per-client JSON storage. +// Package config holds the launcher's persistent state: the Aperture +// endpoints the user has configured, the active endpoint, the YOLO-mode flag +// and the record of the last client launch. Clients also use this package +// for isolated per-client JSON storage. package config import ( @@ -16,31 +16,14 @@ import ( "tailscale.com/atomicfile" ) -// DefaultLocation is the fallback Aperture endpoint URL used when the user -// has no saved settings. -const DefaultLocation = "http://ai" - -// Endpoint holds the URL and per-endpoint configuration for an Aperture proxy. -type Endpoint struct { - URL string `json:"url"` - BridgeID string `json:"bridgeId,omitempty"` -} - -// Bridge is an embedded tsnet node used to reach Aperture without requiring -// Tailscale to run on the host. -type Bridge struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// Settings holds persistent launcher configuration managed by the user. +// Settings holds the launcher configuration the user manages. type Settings struct { - // Bridges is the set of embedded tsnet nodes the user has configured. + // Bridges lists the embedded tsnet nodes the user has configured. Bridges []Bridge `json:"bridges,omitempty"` - // Endpoints is the ordered list of Aperture proxy endpoints. - // The first entry is used as the active endpoint on startup. - Endpoints []Endpoint `json:"endpoints,omitempty"` + // Endpoints lists the Aperture endpoints in order. The first entry is + // the active endpoint on startup. + Endpoints endpointList `json:"endpoints,omitempty"` // YoloMode appends each client's skip-permissions args (e.g. // --dangerously-skip-permissions for Claude Code, --yolo for Gemini) @@ -57,9 +40,10 @@ func settingsPath() (string, error) { return filepath.Join(dir, "aperture", "settings.json"), nil } -// LoadSettings reads the persisted launcher settings. A missing file is a -// first-run condition; other read and parse errors are returned so a later -// settings write cannot silently replace unreadable configuration. +// LoadSettings reads the saved launcher settings. A missing file means a +// first run and yields the defaults. Other read and parse errors are +// returned, so a later settings write cannot silently replace configuration +// that could not be read. func LoadSettings() (Settings, error) { path, err := settingsPath() if err != nil { @@ -77,7 +61,7 @@ func LoadSettings() (Settings, error) { return Settings{}, fmt.Errorf("parsing settings: %w", err) } if len(s.Endpoints) == 0 { - s.Endpoints = []Endpoint{{URL: DefaultLocation}} + s.Endpoints = []Endpoint{Direct(DefaultLocation)} } return s, nil } @@ -100,7 +84,7 @@ func SaveSettings(s Settings) error { func defaultSettings() Settings { return Settings{ - Endpoints: []Endpoint{{URL: DefaultLocation}}, + Endpoints: []Endpoint{Direct(DefaultLocation)}, } } @@ -117,10 +101,6 @@ func BridgeStateDir(id string) (string, error) { return filepath.Join(dir, "aperture", "bridges", suffix), nil } -func sameEndpoint(a, b Endpoint) bool { - return a.URL == b.URL && a.BridgeID == b.BridgeID -} - func newBridgeID(existing []Bridge) (string, error) { for range 10 { var b [3]byte diff --git a/internal/config/startup.go b/internal/config/startup.go new file mode 100644 index 0000000..5d85867 --- /dev/null +++ b/internal/config/startup.go @@ -0,0 +1,71 @@ +package config + +import ( + "fmt" + "strings" +) + +// EndpointFromFlags returns the Endpoint the command line asked the launcher +// to open. When neither flag names anything it returns the saved active +// Endpoint. +// +// A URL alone means a direct connection. A bridge alone opens at +// DefaultLocation, the same guess the connection picker makes, because naming +// a bridge usually means knowing how to get on the tailnet rather than what +// is listening on it. Both together pin the URL behind the bridge. +// +// Callers resolve the flags before the TUI takes the terminal, so a URL we +// cannot use becomes a line on stderr rather than a full-screen error. +func EndpointFromFlags(g *Global, rawURL, bridgeName string) (Endpoint, error) { + rawURL = strings.TrimSpace(rawURL) + bridgeName = strings.TrimSpace(bridgeName) + if rawURL == "" && bridgeName == "" { + return g.ActiveEndpoint(), nil + } + // Check the URL before looking up the bridge, because the lookup writes. + // An invocation that exits with a usage error must not leave a bridge on + // disk that the user then has to find and delete. + location := DefaultLocation + if rawURL != "" { + parsed, err := ParseEndpointURL(rawURL) + if err != nil { + return nil, err + } + location = parsed + } + if bridgeName == "" { + return Direct(location), nil + } + bridge, err := bridgeNamed(g, bridgeName) + if err != nil { + return nil, err + } + return Bridged(location, bridge.ID), nil +} + +// bridgeNamed returns the bridge called name, creating it when there is none. +// Creating it is what makes a first run scriptable. Matching ignores case. +// The name is the user's own label and nothing keys off it. +// +// Two bridges can carry one name, and then the flag names neither. Picking +// the first would leave the other unreachable from the command line, +// silently. +func bridgeNamed(g *Global, name string) (Bridge, error) { + var matched []Bridge + for _, b := range g.Settings.Bridges { + if strings.EqualFold(b.Name, name) { + matched = append(matched, b) + } + } + switch len(matched) { + case 0: + return g.AddBridge(name) + case 1: + return matched[0], nil + } + ids := make([]string, 0, len(matched)) + for _, b := range matched { + ids = append(ids, b.ID) + } + return Bridge{}, fmt.Errorf("%d bridges are called %q (%s); rename one in the connection picker", len(matched), name, strings.Join(ids, ", ")) +} diff --git a/internal/config/startup_test.go b/internal/config/startup_test.go new file mode 100644 index 0000000..7ad4c4e --- /dev/null +++ b/internal/config/startup_test.go @@ -0,0 +1,142 @@ +package config_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +// loadInto points config at a scratch directory and returns a Global holding +// the given settings, saved, so Resolve's writes have somewhere to go. +func loadInto(t *testing.T, s config.Settings) *config.Global { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + if err := config.SaveSettings(s); err != nil { + t.Fatalf("SaveSettings: %v", err) + } + g, err := config.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + return g +} + +func TestResolveFallsBackToTheSavedOne(t *testing.T) { + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) + + ep, err := config.EndpointFromFlags(g, "", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep != (config.Direct("http://saved")) { + t.Errorf("endpoint = %+v, want the saved one", ep) + } +} + +func TestResolveTakesABareHost(t *testing.T) { + g := loadInto(t, config.Settings{Endpoints: []config.Endpoint{config.Direct("http://saved")}}) + + ep, err := config.EndpointFromFlags(g, "aperture.example.com", "") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep != (config.Direct("http://aperture.example.com")) { + t.Errorf("endpoint = %+v, want the named one, schemed", ep) + } +} + +// A named bridge with no URL is the scripted equivalent of picking a bridge in +// the connection picker, which starts at the well-known location rather than +// demanding a URL the user may not know. +func TestResolveGuessesTheLocationForANamedBridge(t *testing.T) { + g := loadInto(t, config.Settings{Bridges: []config.Bridge{{ID: "bridge-abc123", Name: "Work"}}}) + + ep, err := config.EndpointFromFlags(g, "", "work") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if ep != (config.Bridged(config.DefaultLocation, "bridge-abc123")) { + t.Errorf("endpoint = %+v, want %s through the existing bridge", ep, config.DefaultLocation) + } + if len(g.Settings.Bridges) != 1 { + t.Errorf("bridges = %+v, want the name matched rather than a second bridge made", g.Settings.Bridges) + } +} + +// The first scripted run has no bridge yet. Refusing there would mean the +// flag only works after someone has already done the thing by hand. +func TestResolveCreatesAnUnknownBridge(t *testing.T) { + g := loadInto(t, config.Settings{}) + + ep, err := config.EndpointFromFlags(g, "http://aperture.example.com", "Work") + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if len(g.Settings.Bridges) != 1 || g.Settings.Bridges[0].Name != "Work" { + t.Fatalf("bridges = %+v, want one called Work", g.Settings.Bridges) + } + if ep != config.Endpoint(config.Bridged("http://aperture.example.com", g.Settings.Bridges[0].ID)) { + t.Errorf("endpoint = %+v, want the URL through the new bridge", ep) + } + + // Persisted, not just held: the next run has to match this bridge by name + // instead of making another one. + reloaded, err := config.LoadSettings() + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + if len(reloaded.Bridges) != 1 { + t.Errorf("saved bridges = %+v, want the new one written out", reloaded.Bridges) + } +} + +func TestResolveRejectsAUnusableURL(t *testing.T) { + g := loadInto(t, config.Settings{}) + + if _, err := config.EndpointFromFlags(g, "ftp://aperture.example.com", ""); err == nil { + t.Error("Resolve accepted an ftp URL, want it refused before the TUI takes the terminal") + } +} + +// A refused invocation has to leave settings as it found them, or the run that +// exits with an error still costs the user a bridge to clean up by hand. +func TestResolveRejectsTheURLBeforeCreatingTheBridge(t *testing.T) { + g := loadInto(t, config.Settings{}) + + if _, err := config.EndpointFromFlags(g, "ftp://aperture.example.com", "Work"); err == nil { + t.Fatal("Resolve accepted an ftp URL") + } + if len(g.Settings.Bridges) != 0 { + t.Errorf("bridges = %+v, want none created for a rejected invocation", g.Settings.Bridges) + } + saved, err := config.LoadSettings() + if err != nil { + t.Fatalf("LoadSettings: %v", err) + } + if len(saved.Bridges) != 0 { + t.Errorf("saved bridges = %+v, want the rejected invocation to write nothing", saved.Bridges) + } +} + +// Names are the user's own labels and nothing keeps them unique, so a name that +// matches two bridges cannot address either of them. +func TestResolveRejectsAnAmbiguousBridgeName(t *testing.T) { + g := loadInto(t, config.Settings{Bridges: []config.Bridge{ + {ID: "bridge-aaa111", Name: "Work"}, + {ID: "bridge-bbb222", Name: "work"}, + }}) + + _, err := config.EndpointFromFlags(g, "", "WORK") + if err == nil { + t.Fatal("Resolve picked one of two bridges called work") + } + for _, id := range []string{"bridge-aaa111", "bridge-bbb222"} { + if !strings.Contains(err.Error(), id) { + t.Errorf("error %q does not name %s, so the user cannot tell them apart", err, id) + } + } +} diff --git a/internal/config/state.go b/internal/config/state.go index c9602b3..91a31f6 100644 --- a/internal/config/state.go +++ b/internal/config/state.go @@ -6,8 +6,8 @@ import ( "path/filepath" ) -// LaunchState records the last-used client, endpoint, provider, backend, and -// model so the TUI can offer a one-key quick re-launch on startup. +// LaunchState records the last client, endpoint, provider, backend and model +// used, so the TUI can offer a one-key relaunch on startup. type LaunchState struct { LastClientName string `json:"lastClientName,omitempty"` LastBackendType string `json:"lastBackendType,omitempty"` @@ -26,8 +26,9 @@ func statePath() (string, error) { return filepath.Join(dir, "aperture", "launcher.json"), nil } -// LoadState reads the persisted launcher state. Errors are silently ignored -// and a zero LaunchState is returned. +// LoadState reads the saved launcher state. Every error yields a zero +// LaunchState and no error: a lost relaunch hint is not worth refusing to +// start. func LoadState() (LaunchState, error) { path, err := statePath() if err != nil { @@ -39,8 +40,8 @@ func LoadState() (LaunchState, error) { } var s LaunchState if err := json.Unmarshal(data, &s); err != nil { - // Fall back to the legacy schema used by earlier versions of the - // launcher, which named the field lastProfileName. + // Earlier launcher versions named the field lastProfileName. Read + // that schema when the current one does not parse. var legacy struct { LastProfileName string `json:"lastProfileName,omitempty"` LastBackendType string `json:"lastBackendType,omitempty"` @@ -57,7 +58,8 @@ func LoadState() (LaunchState, error) { LastModel: legacy.LastModel, } } - // Accept old-format files that only have lastProfileName set. + // An old file may parse as the current schema and still carry the + // client name only under lastProfileName. if s.LastClientName == "" { var legacy struct { LastProfileName string `json:"lastProfileName,omitempty"` diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 5bd8191..39daca5 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -82,7 +82,7 @@ func TestGlobal_RecordLaunchBindsActiveEndpoint(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) g := &config.Global{Settings: config.Settings{Endpoints: []config.Endpoint{ - {URL: "http://aperture.example.com", BridgeID: "bridge-abcdef"}, + config.Bridged("http://aperture.example.com", "bridge-abcdef"), }}} if err := g.RecordLaunch(config.LaunchState{LastClientName: "Claude Code"}); err != nil { t.Fatal(err) @@ -102,8 +102,8 @@ func TestSettings_RoundTrip(t *testing.T) { {ID: "bridge-abcdef", Name: "Work"}, }, Endpoints: []config.Endpoint{ - {URL: "http://ai"}, - {URL: "http://aperture.example.com", BridgeID: "bridge-abcdef"}, + config.Direct("http://ai"), + config.Bridged("http://aperture.example.com", "bridge-abcdef"), }, YoloMode: true, } @@ -115,13 +115,13 @@ func TestSettings_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("LoadSettings: %v", err) } - if len(got.Endpoints) != 2 || got.Endpoints[0].URL != "http://ai" { + if len(got.Endpoints) != 2 || got.Endpoints[0].URL() != "http://ai" { t.Errorf("endpoints = %+v", got.Endpoints) } if len(got.Bridges) != 1 || got.Bridges[0].ID != "bridge-abcdef" { t.Errorf("bridges = %+v", got.Bridges) } - if got.Endpoints[1].BridgeID != "bridge-abcdef" { + if got.Endpoints[1] != config.Endpoint(config.Bridged("http://aperture.example.com", "bridge-abcdef")) { t.Errorf("bridge endpoint = %+v", got.Endpoints[1]) } if !got.YoloMode { @@ -138,7 +138,7 @@ func TestSettings_MissingFileUsesDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got.Endpoints) != 1 || got.Endpoints[0].URL != config.DefaultLocation { + if len(got.Endpoints) != 1 || got.Endpoints[0].URL() != config.DefaultLocation { t.Errorf("default endpoints = %+v", got.Endpoints) } } @@ -183,9 +183,9 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { g := &config.Global{ Settings: config.Settings{ Endpoints: []config.Endpoint{ - {URL: "http://a"}, - {URL: "http://b"}, - {URL: "http://c"}, + config.Direct("http://a"), + config.Direct("http://b"), + config.Direct("http://c"), }, }, } @@ -195,8 +195,8 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { if g.ApertureHost != "http://b" { t.Errorf("ApertureHost = %q", g.ApertureHost) } - if g.Settings.Endpoints[0].URL != "http://b" { - t.Errorf("front endpoint = %q, want http://b", g.Settings.Endpoints[0].URL) + if g.Settings.Endpoints[0].URL() != "http://b" { + t.Errorf("front endpoint = %q, want http://b", g.Settings.Endpoints[0].URL()) } if len(g.Settings.Endpoints) != 3 { t.Errorf("endpoints len = %d, want 3", len(g.Settings.Endpoints)) @@ -211,15 +211,15 @@ func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { g := &config.Global{ Settings: config.Settings{ Endpoints: []config.Endpoint{ - {URL: "http://ai"}, - {URL: "http://ai", BridgeID: "bridge-abcdef"}, + config.Direct("http://ai"), + config.Bridged("http://ai", "bridge-abcdef"), }, }, } - if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}); err != nil { + if err := g.SetActiveEndpoint(config.Bridged("http://ai", "bridge-abcdef"), nil); err != nil { t.Fatal(err) } - if g.Settings.Endpoints[0].BridgeID != "bridge-abcdef" { + if g.Settings.Endpoints[0] != config.Endpoint(config.Bridged("http://ai", "bridge-abcdef")) { t.Errorf("front endpoint = %+v, want bridge endpoint", g.Settings.Endpoints[0]) } if len(g.Settings.Endpoints) != 2 { @@ -235,11 +235,11 @@ func TestGlobal_RemoveInactiveEndpointPreservesRuntimeHost(t *testing.T) { g := &config.Global{ ApertureHost: "http://127.0.0.1:12345", Settings: config.Settings{Endpoints: []config.Endpoint{ - {URL: "http://active", BridgeID: "bridge-abcdef"}, - {URL: "http://candidate", BridgeID: "bridge-fedcba"}, + config.Bridged("http://active", "bridge-abcdef"), + config.Bridged("http://candidate", "bridge-fedcba"), }}, } - if err := g.RemoveEndpoint(1); err != nil { + if err := g.RemoveEndpoint(config.Bridged("http://candidate", "bridge-fedcba")); err != nil { t.Fatal(err) } if g.ApertureHost != "http://127.0.0.1:12345" { @@ -252,8 +252,8 @@ func TestGlobal_ReplaceEndpointDeduplicates(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) - active := config.Endpoint{URL: "http://active"} - candidate := config.Endpoint{URL: "http://candidate"} + active := config.Direct("http://active") + candidate := config.Direct("http://candidate") g := &config.Global{Settings: config.Settings{Endpoints: []config.Endpoint{active, candidate}}} if err := g.ReplaceEndpoint(candidate, active); err != nil { t.Fatal(err) @@ -263,6 +263,30 @@ func TestGlobal_ReplaceEndpointDeduplicates(t *testing.T) { } } +func TestGlobal_SetBridgeTailnetPersists(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + g := &config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work"}}, + }} + if err := g.SetBridgeTailnet("bridge-abcdef", "corp.example.com"); err != nil { + t.Fatal(err) + } + if err := g.SetBridgeTailnet("bridge-missing", "other.example.com"); err != nil { + t.Fatalf("unknown bridge: %v", err) + } + + got, err := config.LoadSettings() + if err != nil { + t.Fatal(err) + } + if len(got.Bridges) != 1 || got.Bridges[0].Tailnet != "corp.example.com" { + t.Fatalf("bridges = %+v", got.Bridges) + } +} + func TestBridgeStateDir(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/internal/connection/event.go b/internal/connection/event.go new file mode 100644 index 0000000..9f2b522 --- /dev/null +++ b/internal/connection/event.go @@ -0,0 +1,135 @@ +// Package connection defines what a connection attempt reports while it runs: +// the phase it is waiting in, the login link it needs visited, and the odd +// line for the user that is neither. A Machine produces most of these, but the +// attempt is wider than the Machine, since the model fetch after bring-up is +// part of the same wait. So the vocabulary lives here rather than with the +// tailnet code. +// +// Nothing here may reference tsnet, ipn or ipnstate. internal/bridges +// translates the tailnet's vocabulary into this one. +package connection + +import ( + "fmt" + "net/url" + "strings" +) + +// Phase names what an attempt is waiting on. Phases are named for what the +// user is waiting for, not for the backend state underneath. The zero value +// is no phase. +// +// AwaitingLoginLink and AwaitingAuthorization are why this type exists. Both +// are ipn.NeedsLogin and they are different problems: the control plane has +// not answered yet, versus the user has not finished in the browser. A 29 +// second bridge spent them in the first and showed only "NeedsLogin". +type Phase int + +// Phases in the order an attempt passes through them. The order matters: a +// phase only ever moves forward, and consumers compare them. +const ( + StartingMachine Phase = iota + 1 + AwaitingLoginLink + AwaitingAuthorization + JoiningTailnet + FindingEndpoint + AskingForModels +) + +var phaseNames = [...]string{ + StartingMachine: "StartingMachine", + AwaitingLoginLink: "AwaitingLoginLink", + AwaitingAuthorization: "AwaitingAuthorization", + JoiningTailnet: "JoiningTailnet", + FindingEndpoint: "FindingEndpoint", + AskingForModels: "AskingForModels", +} + +// String returns the phase's name, for logs and errors. The presentation +// layer decides what the user reads for it. +func (p Phase) String() string { + if p > 0 && int(p) < len(phaseNames) { + return phaseNames[p] + } + return fmt.Sprintf("Phase(%d)", int(p)) +} + +// LoginLink is the URL the user visits to authorize a machine on a tailnet. +type LoginLink struct { + url string +} + +func (l LoginLink) String() string { return l.url } + +// ParseLoginLink validates raw and is the only way to make a LoginLink. The +// value is handed to a desktop opener and shown as something to click, so +// anything that is not an https URL is rejected. Tailscale applies the same +// rules in validPopBrowserURLLocked. +func ParseLoginLink(raw string) (LoginLink, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return LoginLink{}, fmt.Errorf("login link is empty") + } + if strings.ContainsAny(raw, " \t\r\n") { + return LoginLink{}, fmt.Errorf("login link contains whitespace") + } + parsed, err := url.Parse(raw) + if err != nil { + return LoginLink{}, fmt.Errorf("login link is not a URL") + } + if parsed.Scheme != "https" { + return LoginLink{}, fmt.Errorf("login link is not https") + } + if parsed.Host == "" { + return LoginLink{}, fmt.Errorf("login link has no host") + } + return LoginLink{url: raw}, nil +} + +// Event is one report from a running connection attempt. Exactly one field +// is set. Phase means the attempt entered that phase. Link means the Machine +// needs authorizing at that URL. Note is a line for the user with no domain +// meaning. +// +// Event replaced a sink of plain strings. The TUI used to open a browser on a +// phrase matched from inside a vendored package, and a reworded log line +// silently stranded the user. +type Event struct { + Phase Phase + Link *LoginLink + Note string +} + +// Note returns an Event carrying a line for the user that is neither a phase +// nor a link: tsnet backend chatter, dial detail, a health warning. A Note is +// the only event a consumer may drop. +func Note(text string) Event { return Event{Note: text} } + +// Notef returns Note with the text formatted. +func Notef(format string, args ...any) Event { return Note(fmt.Sprintf(format, args...)) } + +// Entered returns the Event for an attempt entering p. +func Entered(p Phase) Event { return Event{Phase: p} } + +// LoginRequired returns the Event for a Machine that needs authorizing at +// link. +func LoginRequired(link LoginLink) Event { return Event{Link: &link} } + +// Droppable reports whether a consumer under backpressure may discard this +// event. Only a Note may be dropped. A lost phase leaves a gap in where the +// time went, and a lost login link leaves the user waiting on a browser tab +// nothing opened. The old sink dropped whatever arrived on a full buffer, +// which under -debug it shared with tsnet's backend logger. +func (e Event) Droppable() bool { return e.Phase == 0 && e.Link == nil } + +// String returns the event for logs and test failures: the phase's name, the +// link marked as one, or the note's text. +func (e Event) String() string { + switch { + case e.Phase != 0: + return e.Phase.String() + case e.Link != nil: + return "LoginRequired(" + e.Link.String() + ")" + } + return e.Note +} diff --git a/internal/connection/event_test.go b/internal/connection/event_test.go new file mode 100644 index 0000000..6f61810 --- /dev/null +++ b/internal/connection/event_test.go @@ -0,0 +1,121 @@ +package connection + +import ( + "strings" + "testing" +) + +func TestParseLoginLink(t *testing.T) { + for _, tt := range []struct { + name string + raw string + ok bool + }{ + {"a real one", "https://login.tailscale.com/a/17bceb7b0129ba", true}, + {"surrounding space is the log's, not the link's", " https://login.tailscale.com/a/17bceb7b0129ba ", true}, + {"plaintext", "http://evil.example.com", false}, + {"a flag, which is what a bad parse of a log line yields", "--version", false}, + {"a file path", "/etc/passwd", false}, + {"a trailing argument smuggled past the URL", "https://login.tailscale.com/a/x --flag", false}, + {"no host", "https:///a/x", false}, + {"empty", "", false}, + {"whitespace only", " ", false}, + } { + t.Run(tt.name, func(t *testing.T) { + link, err := ParseLoginLink(tt.raw) + if tt.ok != (err == nil) { + t.Fatalf("ParseLoginLink(%q) error = %v, want ok = %t", tt.raw, err, tt.ok) + } + if !tt.ok { + return + } + // The trim is part of the value, not of the rendering: this string + // is handed to a desktop opener. + if link.String() != "https://login.tailscale.com/a/17bceb7b0129ba" { + t.Errorf("link = %q, want it trimmed", link) + } + }) + } +} + +func TestLoginLinkErrorsDoNotContainInput(t *testing.T) { + const secret = "synthetic-login-token" + for _, raw := range []string{ + "http://login.tailscale.com/a/" + secret, + "https://login.tailscale.com/a/" + secret + " trailing argument", + "https://login.tailscale.com/%zz/" + secret, + "https:///a/" + secret, + } { + _, err := ParseLoginLink(raw) + if err == nil { + t.Errorf("invalid link accepted: %q", raw) + } else if strings.Contains(err.Error(), secret) { + t.Errorf("rejection error exposes the login capability: %v", err) + } + } +} + +// TestOnlyNotesAreDroppable is the invariant the whole type exists for: the +// sink discards events when its buffer fills, and the login link sharing that +// buffer with tsnet's debug chatter is what could strand an attempt. +func TestOnlyNotesAreDroppable(t *testing.T) { + link, err := ParseLoginLink("https://login.tailscale.com/a/x") + if err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + event Event + droppable bool + }{ + {Note("magicsock: home is derp-1"), true}, + {Notef("dialing %s", "ai"), true}, + {Entered(AwaitingLoginLink), false}, + {LoginRequired(link), false}, + } { + if got := tt.event.Droppable(); got != tt.droppable { + t.Errorf("%q Droppable() = %t, want %t", tt.event, got, tt.droppable) + } + } +} + +// TestPhasesAreOrdered guards the comparison both the bus watch and the +// attempt use to reject a phase that would walk the user backwards. +func TestPhasesAreOrdered(t *testing.T) { + ordered := []Phase{ + StartingMachine, + AwaitingLoginLink, + AwaitingAuthorization, + JoiningTailnet, + FindingEndpoint, + AskingForModels, + } + for i, p := range ordered { + if i > 0 && !(ordered[i-1] < p) { + t.Errorf("%v does not sort before %v", ordered[i-1], p) + } + if p.String() == "" { + t.Errorf("phase %d has no name", int(p)) + } + } +} + +// TestEventSaysWhatItIsByItsFields is the contract consumers switch on: one +// field set, and which one is the kind. +func TestEventSaysWhatItIsByItsFields(t *testing.T) { + link, err := ParseLoginLink("https://login.tailscale.com/a/x") + if err != nil { + t.Fatal(err) + } + if e := Entered(JoiningTailnet); e.Phase != JoiningTailnet || e.Link != nil || e.Note != "" { + t.Errorf("Entered = %+v", e) + } + if e := LoginRequired(link); e.Phase != 0 || e.Link == nil || *e.Link != link || e.Note != "" { + t.Errorf("LoginRequired = %+v", e) + } + if e := Note("x"); e.Phase != 0 || e.Link != nil || e.Note != "x" { + t.Errorf("Note = %+v", e) + } + if Phase(0).String() == "" || Phase(0) == StartingMachine { + t.Error("the zero Phase must mean no phase") + } +} diff --git a/internal/tui/browser.go b/internal/tui/browser.go new file mode 100644 index 0000000..892787c --- /dev/null +++ b/internal/tui/browser.go @@ -0,0 +1,33 @@ +package tui + +import ( + "os" + "strings" + + "github.com/aymanbagabas/go-osc52/v2" +) + +// openURL asks the desktop to open a link. Overridable in tests. +var openURL = platformOpenURL + +// copyToClipboard puts s on the clipboard of the terminal displaying this +// TUI, over OSC 52. A local helper such as xclip or pbcopy would write to the +// clipboard of the host aperture runs on. Over SSH that is the wrong computer, +// and SSH is where the user most needs the link. Tests override it. +// +// Terminals without OSC 52, or with it off, drop the sequence silently, so a +// nil error means sent, not pasted. +var copyToClipboard = func(s string) error { + seq := osc52.New(s) + // tmux and screen eat escape sequences they don't recognize. The + // passthrough wrapping carries the sequence to the outer terminal. tmux + // sets TERM to a screen-* value of its own, so it has to be checked first. + switch { + case os.Getenv("TMUX") != "": + seq = seq.Tmux() + case strings.HasPrefix(os.Getenv("TERM"), "screen"): + seq = seq.Screen() + } + _, err := seq.WriteTo(os.Stdout) + return err +} diff --git a/internal/tui/browser_nonwindows.go b/internal/tui/browser_nonwindows.go new file mode 100644 index 0000000..ba5bdc4 --- /dev/null +++ b/internal/tui/browser_nonwindows.go @@ -0,0 +1,23 @@ +//go:build !windows + +package tui + +import ( + "os/exec" + "runtime" +) + +func platformOpenURL(url string) error { + opener := "xdg-open" + if runtime.GOOS == "darwin" { + opener = "open" + } + cmd := exec.Command(opener, url) + // Start, not Run: the opener can live as long as the browser. Its output + // must not land in the TUI, so leave stdout and stderr disconnected. + if err := cmd.Start(); err != nil { + return err + } + go cmd.Wait() + return nil +} diff --git a/internal/tui/browser_test.go b/internal/tui/browser_test.go new file mode 100644 index 0000000..6a8435c --- /dev/null +++ b/internal/tui/browser_test.go @@ -0,0 +1,44 @@ +package tui + +import ( + "go/build" + "go/parser" + "go/token" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// This runs in make check on Linux too: Windows URL opening must not depend +// on process argument quoting. Native argument/error tests run on Windows. +func TestWindowsLoginOpenerDoesNotLaunchCommands(t *testing.T) { + windowsBuild := build.Default + windowsBuild.GOOS = "windows" + files, err := filepath.Glob("browser*.go") + if err != nil { + t.Fatal(err) + } + for _, name := range files { + if strings.HasSuffix(name, "_test.go") { + continue + } + match, err := windowsBuild.MatchFile(".", name) + if err != nil { + t.Fatal(err) + } + if !match { + continue + } + file, err := parser.ParseFile(token.NewFileSet(), name, nil, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Imports { + path, _ := strconv.Unquote(spec.Path.Value) + if path == "os/exec" { + t.Errorf("Windows login opener %s imports os/exec; use a native URL API instead of commands", name) + } + } + } +} diff --git a/internal/tui/browser_windows.go b/internal/tui/browser_windows.go new file mode 100644 index 0000000..76ce22c --- /dev/null +++ b/internal/tui/browser_windows.go @@ -0,0 +1,19 @@ +package tui + +import "golang.org/x/sys/windows" + +var shellExecute = windows.ShellExecute + +func platformOpenURL(url string) error { + target, err := windows.UTF16PtrFromString(url) + if err != nil { + return err + } + verb, err := windows.UTF16PtrFromString("open") + if err != nil { + return err + } + // The URL is a file argument to the native URL handler, never input to + // cmd.exe. Query separators and shell punctuation remain URL data. + return shellExecute(0, verb, target, nil, nil, windows.SW_SHOWNORMAL) +} diff --git a/internal/tui/browser_windows_test.go b/internal/tui/browser_windows_test.go new file mode 100644 index 0000000..dc94e2a --- /dev/null +++ b/internal/tui/browser_windows_test.go @@ -0,0 +1,30 @@ +package tui + +import ( + "errors" + "testing" + + "golang.org/x/sys/windows" +) + +func TestWindowsOpenURLPreservesURLData(t *testing.T) { + const link = "https://login.example/a/token?next=x&calc.exe&value=%PATH%|test^value" + previous := shellExecute + t.Cleanup(func() { shellExecute = previous }) + wantErr := errors.New("no URL association") + called := false + shellExecute = func(hwnd windows.Handle, verb, file, args, cwd *uint16, show int32) error { + called = true + if hwnd != 0 || windows.UTF16PtrToString(verb) != "open" || windows.UTF16PtrToString(file) != link || args != nil || cwd != nil || show != windows.SW_SHOWNORMAL { + t.Fatal("URL was not passed intact as the native opener's sole target") + } + return wantErr + } + if err := platformOpenURL(link); err != wantErr || !called { + t.Fatalf("open = %v, called = %t; want native error", err, called) + } + called = false + if err := platformOpenURL("https://login.example/a/\x00token"); err == nil || called { + t.Fatalf("NUL URL = %v, called = %t; want rejection before native call", err, called) + } +} diff --git a/internal/tui/connection_test.go b/internal/tui/connection_test.go new file mode 100644 index 0000000..2327b9e --- /dev/null +++ b/internal/tui/connection_test.go @@ -0,0 +1,205 @@ +package tui + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" +) + +func TestEndpointEditPreservesVerifiedConnection(t *testing.T) { + for _, outcome := range []string{"failure", "cancel"} { + t.Run(outcome, func(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + withFakeTailscale(t, tsConnected) + old, host := m.g.ActiveEndpoint(), m.g.ApertureHost + m.g.Providers = []config.ProviderInfo{{ID: "verified-provider"}} + m.resetStack(m.rootMenu()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer srv.Close() + m.promptEditEndpoint(old) + cmd := m.inputOnSave(srv.URL) + if outcome == "failure" { + m.Update(activationResult(t, cmd)) + } else { + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + // The cancelled request can still deliver a success already queued. + m.Update(endpointActivationResult{id: m.activationSeq, gateway: bridges.Gateway{URL: srv.URL}}) + } + if got := m.g.ActiveEndpoint(); got != old { + t.Errorf("%s replaced verified endpoint: got %+v, want %+v", outcome, got, old) + } + if m.g.ApertureHost != host || !m.connected || len(m.g.Providers) != 1 || m.g.Providers[0].ID != "verified-provider" { + t.Errorf("%s changed verified runtime: host=%q connected=%v providers=%+v", outcome, m.g.ApertureHost, m.connected, m.g.Providers) + } + saved, err := config.LoadSettings() + if err != nil { + t.Fatal(err) + } + if saved.Endpoints[0] != old { + t.Errorf("%s persisted unverified active endpoint: %+v", outcome, saved.Endpoints) + } + }) + } +} + +func TestEndpointEditCommitsOnlyAfterSuccess(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + old := m.g.ActiveEndpoint() + srv := modelsServer(t) + m.promptEditEndpoint(old) + cmd := m.inputOnSave(srv.URL) + if m.g.ActiveEndpoint() != old { + t.Error("edit became active before verification") + } + m.Update(activationResult(t, cmd)) + if m.g.ActiveEndpoint().URL() != srv.URL || m.g.ApertureHost != srv.URL || m.endpointConfigured(old) { + t.Fatalf("verified edit did not replace original: %+v host=%q", m.g.Settings.Endpoints, m.g.ApertureHost) + } +} + +func TestTailnetSwitchInvalidatesSharedConnection(t *testing.T) { + for _, shared := range []bool{true, false} { + for _, outcome := range []string{"failure", "cancel", "remove"} { + t.Run(fmt.Sprintf("shared=%v/%s", shared, outcome), func(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{&fakeClient{name: "Test agent", installed: true}}) + withFakeTailscale(t, tsConnected) + target := m.g.Settings.Endpoints[1] + if shared { + m.g.Settings.Endpoints[0] = config.Bridged("http://first", target.(config.BridgeEndpoint).BridgeID()) + } + m.g.ApertureHost = "http://127.0.0.1:12345" + m.resetStack(m.rootMenu()) + m.connectVia(target, true) + if outcome == "cancel" { + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + } else { + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("model check failed after logout")}) + if outcome == "remove" { + _, item := findItem(t, m.top().Items, "Remove endpoint") + m.applyResult(item.Action()) + } + } + if m.connected == shared { + t.Errorf("connected=%v after switch, want %v", m.connected, !shared) + } + if shared { + if strings.Contains(m.top().Preamble, "previous endpoint remains active") { + t.Error("offers a proxy closed by the tailnet switch") + } + // Even a route back to the root must not retain a launch action. + root := m.rootMenu() + for _, item := range root.Items { + if item.Label == "Test agent" && !item.Disabled && item.Action != nil { + t.Error("agent can launch through the closed proxy") + } + } + } + }) + } + } +} + +func TestEndpointEditSaveFailurePreservesRuntime(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + withFakeTailscale(t, tsConnected) + old, host := m.g.ActiveEndpoint(), m.g.ApertureHost + srv := modelsServer(t) + m.promptEditEndpoint(old) + cmd := m.inputOnSave(srv.URL) + before := m.g.Settings + // Block the final atomic rename, on every supported platform. + dir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "aperture", "settings.json") + if err := os.Rename(path, path+".before"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + m.Update(activationResult(t, cmd)) + if m.g.ActiveEndpoint() != old || m.g.ApertureHost != host || !reflect.DeepEqual(m.g.Settings, before) { + t.Errorf("failed commit changed settings/runtime: %+v host=%q", m.g.Settings, m.g.ApertureHost) + } +} + +func TestEndpointEditRetryAndOverrideKeepOriginal(t *testing.T) { + for _, action := range []string{"retry", "override", "edit again"} { + t.Run(action, func(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + withFakeTailscale(t, tsConnected) + old := m.g.ActiveEndpoint() + srv := modelsServer(t) + m.promptEditEndpoint(old) + m.inputOnSave(srv.URL) + first := m.act.endpoint() + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("temporary failure")}) + var cmd tea.Cmd + switch action { + case "retry": + _, item := findItem(t, m.top().Items, "Retry connection") + cmd = item.Action().Cmd + case "override": + _, cmd = m.overrideActivationURL(srv.URL + "/new") + case "edit again": + m.promptEditEndpoint(first) + cmd = m.inputOnSave(srv.URL + "/new") + } + m.Update(activationResult(t, cmd)) + if m.endpointConfigured(old) || !m.connected { + t.Fatalf("%s lost pending replacement: %+v", action, m.g.Settings.Endpoints) + } + if action != "retry" && m.endpointConfigured(first) { + t.Errorf("%s left the superseded candidate in settings", action) + } + }) + } +} + +func TestEndpointEditSameCandidateCancellation(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + withFakeTailscale(t, tsConnected) + original := m.g.ActiveEndpoint() + m.promptEditEndpoint(original) + m.inputOnSave("http://candidate") + candidate := m.act.endpoint() + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("temporary failure")}) + m.promptEditEndpoint(candidate) + m.inputOnSave(candidate.URL()) + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + if m.endpointConfigured(candidate) { + t.Error("cancelling an unchanged edit retained its temporary candidate") + } + if m.g.ActiveEndpoint() != original || !m.connected { + t.Errorf("cancellation changed the verified connection: %+v connected=%v", m.g.ActiveEndpoint(), m.connected) + } + saved, err := config.LoadSettings() + if err != nil { + t.Fatal(err) + } + for _, ep := range saved.Endpoints { + if ep == candidate { + t.Error("cancelled candidate remains on disk") + } + } +} diff --git a/internal/tui/main_test.go b/internal/tui/main_test.go new file mode 100644 index 0000000..a30de7f --- /dev/null +++ b/internal/tui/main_test.go @@ -0,0 +1,23 @@ +package tui + +import ( + "os" + "testing" +) + +// TestMain points every test at a throwaway config directory. A test that +// writes settings without isolating itself overwrote the developer's real +// settings.json on 2026-09-21; per-test t.Setenv still applies on top. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "aperture-test-") + if err != nil { + panic(err) + } + os.Setenv("HOME", tmp) + os.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + // os.UserConfigDir reads APPDATA on Windows and neither of the above. + os.Setenv("APPDATA", tmp+"/AppData") + code := m.Run() + os.RemoveAll(tmp) + os.Exit(code) +} diff --git a/internal/tui/menus.go b/internal/tui/menus.go index 25c759c..5368322 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -2,11 +2,12 @@ package tui import ( "fmt" - "net/url" "os" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" @@ -15,6 +16,7 @@ import ( const ( rootTitle = "Which editor do you want to use?" endpointsTitle = "Aperture Endpoints" + bridgesTitle = "Bridges" setupGuideTitle = "Getting Started" ) @@ -49,10 +51,14 @@ func (m *model) rootMenu() *menu.Menu { if it.Action == nil { continue } + if !m.connected { + it.Disabled = true + it.Action = nil + } items = append(items, it) } - hints := []string{"[s] Settings"} + hints := []string{"[c] Change connection", "[s] Settings"} if len(uninstalled) > 0 { hints = append(hints, "[i] Install agents") } @@ -60,6 +66,15 @@ func (m *model) rootMenu() *menu.Menu { // Shortcut-only items (hidden so they don't take a number but are // activated via their Shortcut key). + // Listed before Settings in the hints: with a reachable default the + // launcher connects on its own, and a second bridge or tailnet is + // otherwise unreachable from here. + items = append(items, menu.MenuItem{ + Label: "Change connection", + Shortcut: "c", + Hidden: true, + Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, + }) items = append(items, menu.MenuItem{ Label: "Settings", Shortcut: "s", @@ -86,7 +101,7 @@ func (m *model) rootMenu() *menu.Menu { // and the human-readable label to render next to [0]. Returns nil if the // recorded endpoint is not active or the client selection is stale. func (m *model) quickSelect() (tea.Cmd, string) { - if m.g.LastLaunch.LastClientName == "" { + if !m.connected || m.g.LastLaunch.LastClientName == "" { return nil, "" } for _, c := range registeredClients(m.g) { @@ -99,7 +114,7 @@ func (m *model) quickSelect() (tea.Cmd, string) { if !hasSavedEndpoint || !m.endpointConfigured(saved) { return nil, "" } - if !sameEndpoint(saved, m.g.ActiveEndpoint()) { + if saved != m.g.ActiveEndpoint() { return nil, "" } if cmd := c.Replay(m.g); cmd != nil { @@ -113,38 +128,18 @@ func (m *model) quickSelect() (tea.Cmd, string) { } func (m *model) lastLaunchEndpoint() (config.Endpoint, bool) { - if m.g.LastLaunch.LastEndpointURL == "" { - return config.Endpoint{}, false - } - return config.Endpoint{ - URL: m.g.LastLaunch.LastEndpointURL, - BridgeID: m.g.LastLaunch.LastBridgeID, - }, true + last := m.g.LastLaunch + switch { + case last.LastEndpointURL == "": + return nil, false + case last.LastBridgeID != "": + return config.Bridged(last.LastEndpointURL, last.LastBridgeID), true + } + return config.Direct(last.LastEndpointURL), true } func (m *model) endpointConfigured(want config.Endpoint) bool { - for _, ep := range m.g.Settings.Endpoints { - if sameEndpoint(ep, want) { - return true - } - } - return false -} - -func sameEndpoint(a, b config.Endpoint) bool { - return a.URL == b.URL && a.BridgeID == b.BridgeID -} - -func endpointFromInput(value, bridgeID string) (config.Endpoint, error) { - value = strings.TrimSpace(value) - if !strings.Contains(value, "://") { - value = "http://" + value - } - u, err := url.ParseRequestURI(value) - if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { - return config.Endpoint{}, fmt.Errorf("endpoint URL must be an absolute http or https URL") - } - return config.Endpoint{URL: strings.TrimRight(value, "/"), BridgeID: bridgeID}, nil + return slices.Contains(m.g.Settings.Endpoints, want) } func simpleErrorCmd(err error) tea.Cmd { @@ -195,8 +190,8 @@ func (m *model) bridgesMenu() *menu.Menu { p := p items = append(items, menu.MenuItem{ Label: p.Name, - Description: p.ID, - Action: func() menu.Result { return menu.Result{} }, + Description: m.bridgeRowDescription(p), + Action: func() menu.Result { return menu.Result{Cmd: m.connectBridgeCmd(p)} }, }) } items = append(items, menu.MenuItem{ @@ -204,7 +199,7 @@ func (m *model) bridgesMenu() *menu.Menu { Shortcut: "a", Hidden: true, Action: func() menu.Result { - m.promptForInput("Add Bridge:", "Name", func(v string) tea.Cmd { + m.promptForInput("Add Bridge:", "Name", "", func(v string) tea.Cmd { if _, err := m.g.AddBridge(v); err != nil { return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } } @@ -223,73 +218,86 @@ func (m *model) bridgesMenu() *menu.Menu { if idx < 0 || idx >= len(m.g.Settings.Bridges) { return menu.Result{} } - if err := m.g.RemoveBridge(m.g.Settings.Bridges[idx].ID); err != nil { - return errResult(err.Error()) - } - return menu.Result{Replace: m.bridgesMenu()} + return m.remove(m.g.Settings.Bridges[idx], nil) }, }) return &menu.Menu{ - Title: "Bridges", + Title: bridgesTitle, Items: items, - Hint: "d to remove · a to add · Esc to go back", + Hint: "Enter to connect · d to remove · a to add · Esc to go back", } } -// endpointsMenu lists configured endpoints with add/delete affordances. -// Selecting an entry runs preflight and promotes it only after success. +// bridgeRowDescription labels a bridge with the tailnet it reaches, falling +// back to its ID when no connection has reported one yet. +func (m *model) bridgeRowDescription(bridge config.Bridge) string { + if name := m.machines.Tailnet(bridge); name != "" { + return "tailnet " + name + } + return bridge.ID +} + +// endpointsMenu builds the connection picker. It shows one row per Aperture +// this launcher can reach, whether a saved endpoint or a bridge with no +// endpoint yet. A row opens its page rather than connecting, so every action +// is on screen instead of behind a remembered key. func (m *model) endpointsMenu() *menu.Menu { - items := make([]menu.MenuItem, 0, len(m.g.Settings.Endpoints)+3) - for i, ep := range m.g.Settings.Endpoints { - ep := ep - label := m.endpointLabel(ep) - if i == 0 { - label = greenStyle.Render(label + " (active)") - } + rows := m.connectionRows() + items := make([]menu.MenuItem, 0, len(rows)+4) + for _, row := range rows { items = append(items, menu.MenuItem{ - Label: label, - Action: func() menu.Result { - return menu.Result{Cmd: m.activateEndpointCmd(ep)} - }, + Label: m.connectionLabel(row), + Description: m.connectionDescription(row), + Action: func() menu.Result { return menu.Result{Next: m.connectionMenu(row)} }, }) } - // Hidden: "a" opens the endpoint connection flow. Surfaced via the footer hint. + items = append(items, menu.MenuItem{ + Label: "Add a connection", + Action: func() menu.Result { return menu.Result{Next: m.addEndpointConnectionMenu()} }, + }) + // "a" still adds, as it did before the rows above existed. Surfaced via the + // footer hint, like the "e" and "d" aliases below it. items = append(items, menu.MenuItem{ Label: "add", Shortcut: "a", Hidden: true, Action: func() menu.Result { return menu.Result{Next: m.addEndpointConnectionMenu()} }, }) - // Hidden: "d" deletes the row under the cursor. + // Hidden: "e" retargets the row under the cursor. Surfaced via the footer hint. items = append(items, menu.MenuItem{ - Label: "delete", - Shortcut: "d", + Label: "edit", + Shortcut: "e", Hidden: true, Action: func() menu.Result { - idx := m.cursor() - if idx < 0 || idx >= len(m.g.Settings.Endpoints) || len(m.g.Settings.Endpoints) <= 1 { + row, ok := m.connectionAtCursor() + if !ok { return menu.Result{} } - if idx == 0 { - return errResult("switch to another endpoint before removing the active endpoint") - } - removed := m.g.Settings.Endpoints[idx] - if err := m.g.RemoveEndpoint(idx); err != nil { - return errResult(err.Error()) + if !row.saved { + return errResult("connect through " + row.bridge.Name + " first, then its URL can be changed") } - if m.failedEndpoint != nil && sameEndpoint(*m.failedEndpoint, removed) { - m.clearEndpointFailure() - m.resetStack(m.rootMenu()) + m.promptEditEndpoint(row.ep) + return menu.Result{} + }, + }) + // Hidden: "d" deletes the row under the cursor. + items = append(items, menu.MenuItem{ + Label: "delete", + Shortcut: "d", + Hidden: true, + Action: func() menu.Result { + row, ok := m.connectionAtCursor() + if !ok { return menu.Result{} } - return menu.Result{Replace: m.endpointsMenu()} + return m.removeRow(row) }, }) return &menu.Menu{ Title: endpointsTitle, Items: items, - Hint: "Enter to select · d to remove · a to add · Esc to go back", + Hint: "Enter for connection options · a to add · e to edit · d to remove · Esc to go back", OnBack: func() tea.Cmd { if len(m.stack) <= 1 { if m.forcedToEndpoint { @@ -303,37 +311,220 @@ func (m *model) endpointsMenu() *menu.Menu { } } +// connectionRow describes one line on the connection picker. A bridge no +// endpoint points at yet gets a row too, described by the endpoint it would +// create. That row is how a second tailnet gets reached the first time. +type connectionRow struct { + ep config.Endpoint + bridge config.Bridge + saved bool // ep is in Settings.Endpoints + active bool +} + +func (m *model) connectionRows() []connectionRow { + rows := make([]connectionRow, 0, len(m.g.Settings.Endpoints)+len(m.g.Settings.Bridges)) + used := make(map[string]bool, len(m.g.Settings.Bridges)) + for i, ep := range m.g.Settings.Endpoints { + row := connectionRow{ep: ep, saved: true, active: i == 0} + if bridged, ok := ep.(config.BridgeEndpoint); ok { + used[bridged.BridgeID()] = true + row.bridge, _ = m.g.Bridge(bridged.BridgeID()) + } + rows = append(rows, row) + } + // Endpoint rows first: the hidden "e" and "d" aliases index + // Settings.Endpoints by cursor position. + for _, b := range m.g.Settings.Bridges { + if used[b.ID] { + continue + } + rows = append(rows, connectionRow{ + ep: config.Bridged(config.DefaultLocation, b.ID), + bridge: b, + }) + } + return rows +} + +// connectionAtCursor resolves the picker row the cursor is on. The hidden "e" +// and "d" aliases go through it so they see the same rows the user does; +// indexing Settings.Endpoints made them silently do nothing on a bridge row. +func (m *model) connectionAtCursor() (connectionRow, bool) { + rows := m.connectionRows() + idx := m.cursor() + if idx < 0 || idx >= len(rows) { + return connectionRow{}, false + } + return rows[idx], true +} + +func (m *model) connectionLabel(row connectionRow) string { + if !row.saved { + return "Connect via " + row.bridge.Name + } + label := m.endpointLabel(row.ep) + if row.active { + return greenStyle.Render(label + " (active)") + } + return label +} + +// connectionDescription names the tailnet behind a bridge row. Which bridge to +// use is a choice between tailnets, so the row has to say which one it reaches +// before it is picked. +func (m *model) connectionDescription(row connectionRow) string { + if row.bridge.ID == "" { + return "" + } + if name := m.machines.Tailnet(row.bridge); name != "" { + return "tailnet " + name + } + return "tailnet not known yet" +} + +// connectionMenu builds one connection's page. Every action it offers is a +// row. The picker is the only way to reach a second bridge, so its actions +// cannot be keys the user has to already know about. +func (m *model) connectionMenu(row connectionRow) *menu.Menu { + title := row.bridge.Name + if row.saved { + title = m.endpointLabel(row.ep) + } + + connect, target := "Connect", row.ep.URL() + if row.active && m.connected { + connect = "Reconnect" + } + if !row.saved { + // Nothing has named a URL for this bridge yet, so the connection is + // about to guess one. Say so rather than showing a bare URL the user + // never typed. + target = "looks for Aperture at " + row.ep.URL() + } + items := []menu.MenuItem{{ + Label: connect, + Description: target, + Action: func() menu.Result { + return menu.Result{Cmd: m.connectVia(row.ep, false)} + }, + }} + + if row.saved { + items = append(items, menu.MenuItem{ + Label: "Change URL", + Description: "now " + row.ep.URL(), + Action: func() menu.Result { + m.promptEditEndpoint(row.ep) + return menu.Result{} + }, + }) + } + + if row.bridge.ID != "" { + description := "log the bridge out and sign in to a different tailnet" + if name := m.machines.Tailnet(row.bridge); name != "" { + description = "leave " + name + " and sign in to a different tailnet" + } + items = append(items, menu.MenuItem{ + Label: "Switch tailnet", + Description: description, + Action: func() menu.Result { return menu.Result{Next: m.switchTailnetMenu(row)} }, + }) + } + + switch { + case row.active: + items = append(items, menu.MenuItem{ + Label: "Remove connection", + Description: "connect to another one first", + Disabled: true, + }) + case row.saved: + items = append(items, menu.MenuItem{ + Label: "Remove connection", + Action: func() menu.Result { return m.removeRow(row) }, + }) + default: + items = append(items, menu.MenuItem{ + Label: "Remove bridge", + Description: row.bridge.ID, + Action: func() menu.Result { return m.removeRow(row) }, + }) + } + + return &menu.Menu{ + Title: title, + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +// switchTailnetMenu confirms logging a bridge out. A bridge holds one tailnet +// at a time, so switching is destructive in a way connecting is not: the node +// leaves the tailnet it is on, and getting back needs another login. +func (m *model) switchTailnetMenu(row connectionRow) *menu.Menu { + preamble := "A bridge is on one tailnet at a time." + if name := m.machines.Tailnet(row.bridge); name != "" { + preamble += " " + row.bridge.Name + " is on " + name + " now." + } + preamble += "\n\nSwitching logs the bridge out, removing its node from that tailnet, then prints a login link. Open the link and pick the tailnet you want; " + + row.ep.URL() + " is looked for there." + return &menu.Menu{ + Title: "Switch tailnet for " + row.bridge.Name + "?", + Preamble: preamble, + Items: []menu.MenuItem{ + { + Label: "Switch tailnet", + Shortcut: "y", + Action: func() menu.Result { + return menu.Result{Cmd: m.connectVia(row.ep, true)} + }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to switch · n to cancel", + } +} + // setupGuideMenu is shown when endpoint activation or its /v1/models check // fails. A candidate endpoint remains configured, but the previous working // endpoint stays active until the candidate passes both stages. func (m *model) setupGuideMenu() *menu.Menu { target := m.g.ActiveEndpoint() if m.failedEndpoint != nil { - target = *m.failedEndpoint + target = m.failedEndpoint } var preamble string - if target.BridgeID != "" { - bridgeName := target.BridgeID - if bridge, ok := m.g.Bridge(target.BridgeID); ok { + if bridged, ok := target.(config.BridgeEndpoint); ok { + bridgeName := bridged.BridgeID() + if bridge, ok := m.g.Bridge(bridged.BridgeID()); ok { bridgeName = bridge.Name } - preamble = "Could not reach Aperture at " + target.URL + " through bridge " + bridgeName + ".\n\n" + + preamble = "Could not reach Aperture at " + target.URL() + " through bridge " + bridgeName + ".\n\n" + "The bridge uses an embedded Tailscale node; this machine does not need Tailscale installed or running." + if target.URL() == config.DefaultLocation { + preamble += "\n\n" + config.DefaultLocation + " is the default Aperture location. " + + "If yours answers on a different hostname, edit the endpoint URL below." + } } else { switch checkTailscale() { case tsNotInstalled: - preamble = "Could not reach Aperture at " + target.URL + ".\n\nTailscale is not installed.\nInstall it from: https://tailscale.com/download" + preamble = "Could not reach Aperture at " + target.URL() + ".\n\nTailscale is not installed.\nInstall it from: https://tailscale.com/download" case tsNotRunning: - preamble = "Could not reach Aperture at " + target.URL + ".\n\nTailscale is installed but not running.\nStart Tailscale, then retry." + preamble = "Could not reach Aperture at " + target.URL() + ".\n\nTailscale is installed but not running.\nStart Tailscale, then retry." case tsNotConnected: - preamble = "Could not reach Aperture at " + target.URL + ".\n\nTailscale is not connected to a network.\nLog in with: tailscale up" + preamble = "Could not reach Aperture at " + target.URL() + ".\n\nTailscale is not connected to a network.\nLog in with: tailscale up" case tsConnected: - preamble = "Tailscale is connected.\n\nCould not reach Aperture at " + target.URL + ".\nEither:\n - set up an Aperture instance at https://aperture.tailscale.com/\n - or enter a different Aperture URL below" + preamble = "Tailscale is connected.\n\nCould not reach Aperture at " + target.URL() + ".\nEither:\n - set up an Aperture instance at https://aperture.tailscale.com/\n - or enter a different Aperture URL below" } } - hasPrevious := m.connected && !sameEndpoint(target, m.g.ActiveEndpoint()) + hasPrevious := m.connected && target != m.g.ActiveEndpoint() if hasPrevious { preamble += "\n\nThe previous endpoint remains active: " + m.endpointLabel(m.g.ActiveEndpoint()) + "." } @@ -348,17 +539,7 @@ func (m *model) setupGuideMenu() *menu.Menu { { Label: "Edit endpoint URL", Action: func() menu.Result { - m.promptForInput("Edit Endpoint:", "Current: "+target.URL, func(v string) tea.Cmd { - next, err := endpointFromInput(v, target.BridgeID) - if err != nil { - return simpleErrorCmd(err) - } - if err := m.g.ReplaceEndpoint(target, next); err != nil { - return simpleErrorCmd(err) - } - m.failedEndpoint = &next - return m.activateEndpointCmd(next) - }) + m.promptEditEndpoint(target) return menu.Result{} }, }, @@ -378,22 +559,10 @@ func (m *model) setupGuideMenu() *menu.Menu { }, }) } - if m.endpointConfigured(target) && !sameEndpoint(target, m.g.ActiveEndpoint()) { + if m.endpointConfigured(target) && target != m.g.ActiveEndpoint() { items = append(items, menu.MenuItem{ - Label: "Remove endpoint", - Action: func() menu.Result { - for i, ep := range m.g.Settings.Endpoints { - if !sameEndpoint(ep, target) { - continue - } - if err := m.g.RemoveEndpoint(i); err != nil { - return errResult(err.Error()) - } - break - } - m.clearEndpointFailure() - return menu.Result{Replace: m.rootMenu()} - }, + Label: "Remove endpoint", + Action: func() menu.Result { return m.remove(m.bridgeOf(target), target) }, }) } @@ -414,6 +583,28 @@ func (m *model) setupGuideMenu() *menu.Menu { } } +// promptEditEndpoint verifies a replacement URL before removing ep, keeping +// its bridge. It is the only way to retarget an endpoint that connects: the +// guessed default answers on any tailnet with a host called "ai", and a success +// shows neither the inline override nor the setup guide. +func (m *model) promptEditEndpoint(ep config.Endpoint) { + m.promptForInput("Edit Endpoint:", "URL", ep.URL(), func(v string) tea.Cmd { + url, err := config.ParseEndpointURL(v) + if err != nil { + return simpleErrorCmd(err) + } + var current *bridges.Attempt + if m.act != nil { + current = m.act.attempt + } + a, err := bridges.EditAttempt(m.g, current, ep, ep.WithURL(url)) + if err != nil { + return simpleErrorCmd(err) + } + return m.startAttempt(a) + }) +} + func (m *model) clearEndpointFailure() { m.failedEndpoint = nil m.preflightErr = "" @@ -427,11 +618,12 @@ func (m *model) addEndpointConnectionMenu() *menu.Menu { { Label: "Direct", Action: func() menu.Result { - m.promptForInput("Add Direct Endpoint:", "URL", func(v string) tea.Cmd { - ep, err := endpointFromInput(v, "") + m.promptForInput("Add Direct Endpoint:", "URL", "", func(v string) tea.Cmd { + url, err := config.ParseEndpointURL(v) if err != nil { return simpleErrorCmd(err) } + ep := config.Direct(url) if err := m.g.UpsertEndpoint(ep); err != nil { return simpleErrorCmd(err) } @@ -461,24 +653,20 @@ func (m *model) endpointBridgeMenu() *menu.Menu { p := p items = append(items, menu.MenuItem{ Label: p.Name, - Description: p.ID, - Action: func() menu.Result { - m.promptForBridgeEndpoint(p) - return menu.Result{} - }, + Description: m.bridgeRowDescription(p), + Action: func() menu.Result { return menu.Result{Cmd: m.connectBridgeCmd(p)} }, }) } items = append(items, menu.MenuItem{ Label: "Add Bridge", Action: func() menu.Result { - m.promptForInput("Add Bridge:", "Name", func(v string) tea.Cmd { + m.promptForInput("Add Bridge:", "Name", "", func(v string) tea.Cmd { bridge, err := m.g.AddBridge(v) if err != nil { return simpleErrorCmd(err) } m.refreshMenuByTitle("Choose a bridge", m.endpointBridgeMenu()) - m.promptForBridgeEndpoint(bridge) - return nil + return m.connectBridgeCmd(bridge) }) return menu.Result{} }, @@ -486,31 +674,33 @@ func (m *model) endpointBridgeMenu() *menu.Menu { return &menu.Menu{ Title: "Choose a bridge", Items: items, - Hint: "Enter to select or add · Esc to go back", + Hint: "Enter to connect or add · Esc to go back", } } -func (m *model) promptForBridgeEndpoint(bridge config.Bridge) { - m.promptForInput("Add Bridge Endpoint:", "URL", func(v string) tea.Cmd { - ep, err := endpointFromInput(v, bridge.ID) - if err != nil { - return simpleErrorCmd(err) - } - if err := m.g.UpsertEndpoint(ep); err != nil { - return simpleErrorCmd(err) - } - return m.activateEndpointCmd(ep) - }) +// connectBridgeCmd starts discovery through bridge: probe the well-known +// Aperture location, the same guess a direct connection starts from, instead of +// demanding a URL the user may not know. The connect screen takes another URL +// while the guess runs. +func (m *model) connectBridgeCmd(bridge config.Bridge) tea.Cmd { + return m.connectVia(config.Bridged(config.DefaultLocation, bridge.ID), false) +} + +// connectVia connects to ep. switchTailnet logs the bridge out on the way, so +// the connection asks for a login. +func (m *model) connectVia(ep config.Endpoint, switchTailnet bool) tea.Cmd { + return m.connect(ep, switchTailnet, nil) } func (m *model) endpointLabel(ep config.Endpoint) string { - if ep.BridgeID == "" { - return ep.URL + " (direct)" + bridged, ok := ep.(config.BridgeEndpoint) + if !ok { + return ep.URL() + " (direct)" } - if p, ok := m.g.Bridge(ep.BridgeID); ok { - return ep.URL + " via " + p.Name + if p, ok := m.g.Bridge(bridged.BridgeID()); ok { + return ep.URL() + " via " + p.Name } - return ep.URL + " via " + ep.BridgeID + return ep.URL() + " via " + bridged.BridgeID() } // installAgentsMenu lists uninstalled clients and confirms/runs each install. @@ -652,17 +842,17 @@ func installDoneMsg(client clients.Client, skipInstalledCheck bool, err error) m return menu.InstallDoneMsg{Err: err} } -// runUninstallFn returns a tea.Cmd that invokes the uninstall function and -// emits menu.InstallDoneMsg (we reuse the install-done flow to re-scan the -// client list on completion). +// runUninstallFn returns a tea.Cmd that runs the uninstall function and emits +// menu.InstallDoneMsg. The install-done flow re-scans the client list on +// completion, so uninstall reuses it. func runUninstallFn(run func() error) tea.Cmd { return func() tea.Msg { return menu.InstallDoneMsg{Err: run()} } } -// errResult is a small helper to emit an error through the shared done-msg -// channel from a menu builder. +// errResult returns a menu.Result whose command reports msg as an error +// through the shared done message, for use from a menu builder. func errResult(msg string) menu.Result { return menu.Result{Cmd: func() tea.Msg { return menu.SimpleDoneMsg{Err: fmt.Errorf("%s", msg)} diff --git a/internal/tui/removal.go b/internal/tui/removal.go new file mode 100644 index 0000000..e5b342c --- /dev/null +++ b/internal/tui/removal.go @@ -0,0 +1,192 @@ +package tui + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" + "github.com/tailscale/aperture-cli/internal/menu" +) + +// bridgeRemovedMsg carries a Destroy's outcome back to the update loop, where +// settings may be written. endpoint is nil for a bare bridge. +type bridgeRemovedMsg struct { + id int + bridge config.Bridge + endpoint config.Endpoint + err error +} + +// destroyBridge logs the bridge's device out of its tailnet. A variable so +// tests can replace it: pickerModel has no Machines, and a real Destroy needs +// a tailnet. +var destroyBridge = func(ctx context.Context, machines *bridges.Machines, bridge config.Bridge, emit func(connection.Event)) error { + return machines.Destroy(ctx, bridge, emit) +} + +// removeRow removes the endpoint and bridge a picker row stands for. The +// row's page and the "d" key both call it, so they agree on what removing a +// row means. +func (m *model) removeRow(row connectionRow) menu.Result { + var ep config.Endpoint + if row.saved { + ep = row.ep + } + return m.remove(row.bridge, ep) +} + +// bridgeOf returns the bridge a saved endpoint connects through, or a zero +// Bridge for a direct endpoint. The setup guide holds an endpoint rather than +// a picker row. +func (m *model) bridgeOf(ep config.Endpoint) config.Bridge { + if bridged, ok := ep.(config.BridgeEndpoint); ok { + bridge, _ := m.g.Bridge(bridged.BridgeID()) + return bridge + } + return config.Bridge{} +} + +// remove asks for confirmation when removing bridge or ep logs a device out +// of a tailnet, and otherwise deletes the records at once. Every delete in the +// TUI comes through here: the device outlives settings, so a site that skips +// this leaves a device on the user's tailnet that nothing names any more. +func (m *model) remove(bridge config.Bridge, ep config.Endpoint) menu.Result { + if err := bridges.CheckRemovable(m.g, bridge, ep); err != nil { + return errResult(err.Error()) + } + if !bridges.WillDestroyMachine(m.g, bridge, ep) { + if err := bridges.RemoveFromSettings(m.g, bridge, ep); err != nil { + return errResult(err.Error()) + } + return menu.Result{Cmd: m.afterRemoval(ep)} + } + return menu.Result{Next: m.removeBridgeMenu(bridge, ep)} +} + +// removeBridgeMenu asks the user to confirm. Removal is irreversible from +// here and logs a device out of the user's tailnet, so the screen names the +// device the way the admin console does. +func (m *model) removeBridgeMenu(bridge config.Bridge, ep config.Endpoint) *menu.Menu { + preamble := "Bridge " + bridge.Name + " is the device " + bridges.MachineName(bridge.ID) + if name := m.machines.Tailnet(bridge); name != "" { + preamble += " on " + name + } + preamble += ".\n\nRemoving it logs that device out of the tailnet and discards the login stored on this machine. " + + "Connecting through a bridge of this name again is a new device and a new login." + return &menu.Menu{ + Title: "Remove bridge " + bridge.Name + "?", + Preamble: preamble, + Items: []menu.MenuItem{ + { + Label: "Remove", + Shortcut: "y", + Action: func() menu.Result { return menu.Result{Cmd: m.destroyBridgeCmd(bridge, ep)} }, + }, + { + Label: "Cancel", + Shortcut: "n", + Action: func() menu.Result { return menu.Result{Pop: true} }, + }, + }, + Hint: "y to remove · n to cancel", + } +} + +// destroyBridgeCmd runs the logout on the connect screen, where this program +// already shows slow bridge work and its log tail. The activation carries no +// cancel handle: settings still name the device, and abandoning the wait half +// way through a logout is how the record and the device end up disagreeing. +func (m *model) destroyBridgeCmd(bridge config.Bridge, ep config.Endpoint) tea.Cmd { + m.stopActivation() + m.step = stepPreflight + m.preflightErr = "" + m.bridgeLogs = nil + + ctx, cancel := context.WithCancel(context.Background()) + ch := make(chan bridgeLine, 32) + m.activationSeq++ + act := &activation{ + id: m.activationSeq, + label: "Removing bridge " + bridge.Name + " ...", + started: time.Now(), + logCh: ch, + logCtx: ctx, + } + m.act = act + emit := bridgeLogSink(ctx, ch, act.started) + machines := m.machines + destroy := func() tea.Msg { + defer cancel() + err := destroyBridge(ctx, machines, bridge, emit) + return bridgeRemovedMsg{id: act.id, bridge: bridge, endpoint: ep, err: err} + } + return tea.Batch(destroy, waitBridgeLog(ctx, ch), activationTick(act.id)) +} + +// bridgeRemoved applies a Destroy's outcome. The records go only after the +// device is gone. Any failure keeps the connection and shows why, so the +// user can retry; a quit deferred by Ctrl+C is dropped so the message stays +// on screen. +func (m *model) bridgeRemoved(msg bridgeRemovedMsg) (tea.Model, tea.Cmd) { + if m.act == nil || m.act.id != msg.id { + return m, nil + } + m.act = nil + m.step = stepMenu + err := msg.err + if err == nil { + err = bridges.RemoveFromSettings(m.g, msg.bridge, msg.endpoint) + } + if err != nil { + m.quitAfterRemoval = false + m.step = stepError + m.errMsg = m.removalFailedMessage(msg.bridge, err) + return m, nil + } + if m.quitAfterRemoval { + m.quitAfterRemoval = false + return m, m.quitCmd() + } + return m, m.afterRemoval(msg.endpoint) +} + +// removalFailedMessage tells the user the connection is unchanged and how to +// finish the job: retry here, or delete the device by name in the admin +// console. A bare error leaves them hunting for a machine whose name this +// program chose. +func (m *model) removalFailedMessage(bridge config.Bridge, err error) string { + msg := "Could not remove bridge " + bridge.Name + ": " + err.Error() + + "\n\nThe connection is unchanged. Removing it again retries the logout. " + + "If the device " + bridges.MachineName(bridge.ID) + if name := m.machines.Tailnet(bridge); name != "" { + msg += " is still on " + name + } else { + msg += " is still registered" + } + return msg + " after that, delete it from the Tailscale admin console." +} + +// afterRemoval returns the user to a list that no longer shows what they +// removed. When the removed endpoint is the one the failure screen is about, +// that screen has nothing left to retry, so the root menu takes its place. +func (m *model) afterRemoval(ep config.Endpoint) tea.Cmd { + if ep != nil && m.failedEndpoint == ep { + m.clearEndpointFailure() + m.resetStack(m.rootMenu()) + return tea.ClearScreen + } + // By title, and Bridges before the picker: both pages delete the same + // thing, and refreshing the picker from the bridges page would replace the + // page the user is on with a different list. + for _, entry := range m.stack { + if entry.Title == bridgesTitle { + m.refreshBridgesMenu() + return tea.ClearScreen + } + } + m.refreshEndpointsMenu() + return tea.ClearScreen +} diff --git a/internal/tui/removal_test.go b/internal/tui/removal_test.go new file mode 100644 index 0000000..2ab0a2a --- /dev/null +++ b/internal/tui/removal_test.go @@ -0,0 +1,299 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" +) + +// withFakeDestroy stands in for the tailnet round trip a removal makes. +func withFakeDestroy(t *testing.T, fn func(context.Context, config.Bridge) error) { + t.Helper() + orig := destroyBridge + destroyBridge = func(ctx context.Context, _ *bridges.Machines, b config.Bridge, _ func(connection.Event)) error { + return fn(ctx, b) + } + t.Cleanup(func() { destroyBridge = orig }) +} + +// startedBridge creates the state directory tsnet would have made, which is +// what says this bridge registered a device. +func startedBridge(t *testing.T, id string) { + t.Helper() + dir, err := config.BridgeStateDir(id) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } +} + +func hasBridge(m *model, id string) bool { + _, ok := m.g.Bridge(id) + return ok +} + +// removeRowResult drives a picker row's removal through its confirmation and +// returns the message the destruction produced. +func removeRowResult(t *testing.T, m *model, row connectionRow) tea.Msg { + t.Helper() + res := m.removeRow(row) + if res.Next == nil { + t.Fatalf("removing %s did not confirm before destroying its machine", row.bridge.Name) + } + _, item := findItem(t, res.Next.Items, "Remove") + _, cmd := m.applyResult(item.Action()) + return activationResult(t, cmd) +} + +// bridgedRow is the picker row for the saved endpoint behind Work, the one +// bridged connection pickerModel has. +func bridgedRow(t *testing.T, m *model) connectionRow { + t.Helper() + for _, row := range m.connectionRows() { + if row.saved && !row.active && row.bridge.ID == "bridge-aaaaaa" { + return row + } + } + t.Fatal("no removable bridged row") + return connectionRow{} +} + +func TestRemovingTheLastConnectionDestroysTheMachineFirst(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + row := bridgedRow(t, m) + destroyed := 0 + withFakeDestroy(t, func(_ context.Context, b config.Bridge) error { + destroyed++ + if b.ID != "bridge-aaaaaa" { + t.Errorf("destroyed %s", b.ID) + } + // Settings are the only record naming the device, so they have to + // outlast the logout. + if !m.endpointConfigured(row.ep) || !hasBridge(m, b.ID) { + t.Error("settings dropped before the machine was destroyed") + } + return nil + }) + m.resetStack(m.endpointsMenu()) + + msg := removeRowResult(t, m, row) + if destroyed != 1 { + t.Fatalf("destroy calls = %d, want the machine deregistered once", destroyed) + } + m.Update(msg) + if m.endpointConfigured(row.ep) || hasBridge(m, "bridge-aaaaaa") { + t.Errorf("records survived the removal: %+v %+v", m.g.Settings.Endpoints, m.g.Settings.Bridges) + } + saved, err := config.LoadSettings() + if err != nil { + t.Fatal(err) + } + if len(saved.Bridges) != 1 || saved.Bridges[0].ID != "bridge-bbbbbb" { + t.Errorf("saved bridges = %+v, want only the untouched one", saved.Bridges) + } +} + +// A logout the tailnet refuses leaves a device the CLI must still be able to +// name, so nothing local goes. +func TestFailedDestroyKeepsTheConnection(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + row := bridgedRow(t, m) + withFakeDestroy(t, func(context.Context, config.Bridge) error { return errors.New("control plane said no") }) + m.resetStack(m.endpointsMenu()) + + m.Update(removeRowResult(t, m, row)) + if !m.endpointConfigured(row.ep) || !hasBridge(m, "bridge-aaaaaa") { + t.Errorf("failed removal dropped local records: %+v %+v", m.g.Settings.Endpoints, m.g.Settings.Bridges) + } + if m.step != stepError || !strings.Contains(m.errMsg, "control plane said no") { + t.Errorf("step=%v errMsg=%q, want the refusal reported", m.step, m.errMsg) + } +} + +// A bridge nobody finished a login for has no device and no state directory. +// Confirming a tailnet round trip that cannot happen is a lie. +func TestRemovingAnUnstartedBridgeIsImmediate(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + withFakeDestroy(t, func(_ context.Context, b config.Bridge) error { + t.Errorf("logged out a bridge that never started: %s", b.ID) + return nil + }) + var row connectionRow + for _, r := range m.connectionRows() { + if !r.saved && r.bridge.ID == "bridge-bbbbbb" { + row = r + } + } + if row.bridge.ID == "" { + t.Fatal("no bare bridge row") + } + m.resetStack(m.endpointsMenu()) + + if res := m.removeRow(row); res.Next != nil { + t.Error("confirmed a removal with nothing to remove from a tailnet") + } + if hasBridge(m, "bridge-bbbbbb") { + t.Errorf("bridge survived: %+v", m.g.Settings.Bridges) + } +} + +// The bridges page deletes the same thing the picker does, so it has to reach +// the same destruction rather than dropping the record on its own. +func TestBridgesMenuDeleteDestroysTheMachine(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-bbbbbb") + destroyed := 0 + withFakeDestroy(t, func(context.Context, config.Bridge) error { destroyed++; return nil }) + m.resetStack(m.bridgesMenu()) + // Row 0 is the explainer; Home is the bridge no endpoint points at. + m.setCursor(2) + + _, item := findItem(t, m.top().Items, "delete") + res := item.Action() + if res.Next == nil { + t.Fatal("deleting a bridge did not confirm") + } + _, confirm := findItem(t, res.Next.Items, "Remove") + _, cmd := m.applyResult(confirm.Action()) + m.Update(activationResult(t, cmd)) + if destroyed != 1 || hasBridge(m, "bridge-bbbbbb") { + t.Errorf("destroy calls = %d, bridges = %+v", destroyed, m.g.Settings.Bridges) + } +} + +// A bridge an endpoint still reaches through is not the picker's to delete +// from under it, and saying nothing reads as a key that does not work. +func TestBridgesMenuDeleteRefusesAReferencedBridge(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + withFakeDestroy(t, func(_ context.Context, b config.Bridge) error { + t.Errorf("destroyed %s while an endpoint still used it", b.ID) + return nil + }) + m.resetStack(m.bridgesMenu()) + m.setCursor(1) + + _, item := findItem(t, m.top().Items, "delete") + m.Update(activationResult(t, item.Action().Cmd)) + if m.step != stepError || !strings.Contains(m.errMsg, config.DefaultLocation) { + t.Errorf("step=%v errMsg=%q, want the endpoint holding the bridge named", m.step, m.errMsg) + } + if !hasBridge(m, "bridge-aaaaaa") { + t.Error("referenced bridge was removed") + } +} + +// A logout the tailnet did not answer in time may or may not have run. The +// records stay so the user can retry, and the message names the device in +// case the retry finds nothing left to log out. +func TestDestroyTimeoutKeepsTheConnectionAndNamesTheDevice(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + withFakeDestroy(t, func(context.Context, config.Bridge) error { + return fmt.Errorf("the tailnet did not answer within 50ms: %w", context.DeadlineExceeded) + }) + row := bridgedRow(t, m) + m.resetStack(m.endpointsMenu()) + + m.Update(removeRowResult(t, m, row)) + if !m.endpointConfigured(row.ep) || !hasBridge(m, row.bridge.ID) { + t.Errorf("timed-out removal dropped local records: %+v", m.g.Settings) + } + if m.step != stepError { + t.Errorf("step = %v, want the timeout reported", m.step) + } + for _, want := range []string{bridges.MachineName(row.bridge.ID), "corp.example.com", "did not answer"} { + if !strings.Contains(m.errMsg, want) { + t.Errorf("message %q does not name %q", m.errMsg, want) + } + } +} + +// Ctrl+C during a removal that then fails must not quit: quitting would take +// the only message naming the device that may still be on the tailnet off +// the screen, and exit as if the removal worked. +func TestQuitDuringFailedRemovalShowsTheFailure(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + release := make(chan struct{}) + withFakeDestroy(t, func(context.Context, config.Bridge) error { <-release; return errors.New("control plane said no") }) + row := bridgedRow(t, m) + m.resetStack(m.endpointsMenu()) + + res := m.removeRow(row) + _, item := findItem(t, res.Next.Items, "Remove") + _, destroy := m.applyResult(item.Action()) + result := make(chan tea.Msg, 1) + go func() { result <- activationResult(t, destroy) }() + + m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + close(release) + _, after := m.Update(<-result) + if after != nil { + t.Error("quit after a removal that failed") + } + if m.step != stepError || !strings.Contains(m.errMsg, "control plane said no") { + t.Errorf("step=%v errMsg=%q, want the failure on screen", m.step, m.errMsg) + } + if !m.endpointConfigured(row.ep) || !hasBridge(m, row.bridge.ID) { + t.Errorf("failed removal dropped local records: %+v", m.g.Settings) + } +} + +// Ctrl+C during a removal used to close the Machines, which cancelled the +// destroy, and could quit before the outcome dropped the records: the next +// run then named a device that was already gone. Quitting waits for the +// outcome to be applied. +func TestQuitDuringRemovalWaitsForTheOutcome(t *testing.T) { + m := pickerModel(t) + withFakeClients(t, []clients.Client{}) + startedBridge(t, "bridge-aaaaaa") + release := make(chan struct{}) + withFakeDestroy(t, func(context.Context, config.Bridge) error { <-release; return nil }) + row := bridgedRow(t, m) + m.resetStack(m.endpointsMenu()) + + res := m.removeRow(row) + _, item := findItem(t, res.Next.Items, "Remove") + _, destroy := m.applyResult(item.Action()) + result := make(chan tea.Msg, 1) + go func() { result <- activationResult(t, destroy) }() + + _, quit := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + if quit != nil { + if _, quitting := quit().(quitMsg); quitting { + t.Fatal("Ctrl+C quit while the removal was still on the tailnet") + } + } + close(release) + _, after := m.Update(<-result) + if m.endpointConfigured(row.ep) || hasBridge(m, row.bridge.ID) { + t.Errorf("records survived the removal: %+v", m.g.Settings) + } + if after == nil { + t.Fatal("no quit after the removal the user asked to leave during") + } + if _, quitting := activationResult(t, after).(quitMsg); !quitting { + t.Error("the deferred quit did not happen once the outcome was applied") + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 12476c0..252f12e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,18 +1,19 @@ // Package tui is the bubbletea-driven interactive launcher. It renders a -// generic navigable menu stack described by internal/menu; each entry on -// the stack comes from either the root client picker (built from -// internal/clients) or a sub-menu pushed by a client's action closure. -// The TUI owns only the preflight HTTP check, a single-line text input -// step, and error screens — everything else is expressed as Menu values. +// navigable stack of menus described by internal/menu. Each entry on the +// stack comes from the root client picker, built from internal/clients, or +// from a sub-menu pushed by a client's action closure. The TUI owns only the +// preflight HTTP check, a single-line text input step and the error screens. +// Everything else is a Menu value. package tui import ( "context" "fmt" - "io" - "net/http" + "log/slog" "strings" "time" + "unicode" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -20,6 +21,7 @@ import ( "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" "github.com/tailscale/aperture-cli/internal/menu" ) @@ -38,33 +40,36 @@ var ( errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) dimStyle = lipgloss.NewStyle().Faint(true) greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) + // authStyle colors the login link at the foot of the connect screen. It + // uses the palette's bright green on a dark terminal and plain green on a + // light one, where bright green is unreadable. + authStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "2", Dark: "10"}) dotYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Render("●") dotGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("●") dotRed = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Render("●") ) -const ( - providerFetchTimeout = 10 * time.Second - bridgeProviderFetchTimeout = 30 * time.Second -) - -// NewModel returns the TUI model. g holds the persisted launcher state -// (settings, endpoints, last launch). buildVersion is shown at the bottom -// of the client picker. -func NewModel(g *config.Global, buildVersion string, bridgeManager *bridges.Manager) tea.Model { +// NewModel returns the TUI model. start is the endpoint to open on, which is +// the saved active one unless the invocation named another. +func NewModel(g *config.Global, buildVersion string, machines *bridges.Machines, start config.Endpoint) tea.Model { return &model{ - g: g, - buildVersion: buildVersion, - bridgeManager: bridgeManager, - step: stepPreflight, + g: g, + buildVersion: buildVersion, + machines: machines, + start: start, + step: stepPreflight, } } type model struct { - g *config.Global - buildVersion string - bridgeManager *bridges.Manager + g *config.Global + buildVersion string + machines *bridges.Machines + // start is not necessarily in settings yet: one named on the command line + // is written on the way in and taken back out if the attempt is abandoned, + // same as one typed into the connection picker. + start config.Endpoint step step @@ -81,176 +86,451 @@ type model struct { // Input step state. inputTitle string inputPrompt string - inputValue string + input textField inputOnSave func(value string) tea.Cmd // Error screen state. errMsg string // Preflight state. + act *activation + activationSeq int preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu - preflightLabel string - bridgeLogCh chan string - bridgeLogCtx context.Context - bridgeLogs []string - bridgeCancel context.CancelFunc - failedEndpoint *config.Endpoint + bridgeLogs []bridgeLine + failedEndpoint config.Endpoint connected bool + // quitAfterRemoval is true when Ctrl+C was pressed while a removal was on + // the tailnet. bridgeRemoved quits once the outcome has been applied. + quitAfterRemoval bool } -func (m *model) Init() tea.Cmd { - return m.activateEndpointCmd(m.g.ActiveEndpoint()) +// activation holds the presentation state of the connection attempt on +// screen: its identity, its cancellation handle and the URL the user can type +// over the top of it. The log tail stays on the model because the failure +// screen outlives the attempt. +// +// cancel is nil for an attempt that cannot be interrupted, such as the +// post-launch re-check. A nil cancel makes Esc and the inline override inert. +type activation struct { + id int + // attempt is the ConnectionAttempt this screen shows. Nil for the wait a + // bridge removal puts on the same screen. + attempt *bridges.Attempt + label string + started time.Time + cancel context.CancelFunc + logCh chan bridgeLine + logCtx context.Context + // phase is the phase this attempt is in. phaseSet distinguishes "not + // started" from StartingMachine, because StartingMachine is the zero value. + phase connection.Phase + phaseSet bool + // authURL is the Tailscale login link already shown for this attempt. The + // control plane can re-send the link on the bus. Remembering it keeps the + // browser from opening again on each repeat. + authURL string + // copied records that the login link reached the terminal's clipboard, so + // the copy button can say so. A click that does nothing visible reads as a + // button that does not work. + copied bool + // override is the inline "different Aperture URL" editor shown while a + // bridge attempt runs. + override textField } -// preflightResult is emitted when the /v1/models check completes. -type preflightResult struct { - host string - providers []config.ProviderInfo - err error +// logLine stamps a line the TUI itself produces (a browser or clipboard +// failure) against the same clock the bridge's own lines are stamped with. +func (a *activation) logLine(text string) bridgeLine { + return bridgeLine{elapsed: time.Since(a.started), event: connection.Note(text)} } -type endpointActivationResult struct { - endpoint config.Endpoint - host string - providers []config.ProviderInfo - err error +// entered records a phase the attempt moved into, and reports whether it moved. +// The attempt owns the rule rather than the bridge because phases arrive from +// both the IPN bus and the manager, and only something seeing both can order +// them. A bus that re-notifies NeedsLogin would otherwise walk the user back. +func (a *activation) entered(p connection.Phase) bool { + if a.phaseSet && p <= a.phase { + return false + } + a.phase, a.phaseSet = p, true + return true } -type bridgeLogMsg struct { - ch chan string - line string +// endpoint returns the Endpoint the attempt on screen is trying, or nil when +// the screen shows something else. +func (a *activation) endpoint() config.Endpoint { + if a == nil || a.attempt == nil { + return nil + } + return a.attempt.Endpoint } -type bridgeLogDoneMsg struct{ ch chan string } -type quitMsg struct{ Err error } -func runPreflight(host string) tea.Cmd { - return func() tea.Msg { - provs, err := fetchProviders(host) - return preflightResult{host: host, providers: provs, err: err} - } +// canCancel reports whether Esc can interrupt this attempt. +func (a *activation) canCancel() bool { return a != nil && a.cancel != nil } + +// removing reports whether the screen is showing a bridge removal rather than +// a connection attempt. +func (a *activation) isRemoving() bool { return a != nil && a.attempt == nil && a.logCh != nil } + +// canOverride reports whether the attempt accepts a typed URL in place of the +// one being probed. Only bridge attempts start from a guessed URL. +func (a *activation) canOverride() bool { + _, bridged := a.endpoint().(config.BridgeEndpoint) + return a.canCancel() && bridged } -func fetchProviders(host string) ([]config.ProviderInfo, error) { - return fetchProvidersContext(context.Background(), host, providerFetchTimeout) +// textField is the shared single-line editor behind the add-endpoint input +// step and the inline URL override on the connect screen. +type textField struct { + value string + err string } -func fetchProvidersContext(ctx context.Context, host string, timeout time.Duration) ([]config.ProviderInfo, error) { - client := &http.Client{Timeout: timeout} - url := strings.TrimRight(host, "/") + "/v1/models" - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err +// insert appends the text a key press carries. A pasted URL arrives as many +// runes in one message, and dropping it leaves the user retyping an endpoint by +// hand. Named keys and Alt chords carry no text: matching on the key's String() +// would append "up" when someone presses Up. +func (f *textField) insert(msg tea.KeyMsg) { + if msg.Alt || (msg.Type != tea.KeyRunes && msg.Type != tea.KeySpace) { + return } - // Aperture intentionally filters model results for Claude Code user agents. - // Discovery needs the full grant-filtered model list for every harness. - req.Header.Set("User-Agent", "aperture-cli") - resp, err := client.Do(req) - if err != nil { - return nil, err + if len(msg.Runes) == 0 { + return } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) - detail := strings.TrimSpace(string(body)) - if detail != "" { - return nil, fmt.Errorf("unexpected status %d from %s: %s", resp.StatusCode, url, detail) + for _, r := range msg.Runes { + if unicode.IsControl(r) { + return } - return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + f.value += string(msg.Runes) + f.err = "" +} + +func (f *textField) backspace() { + if f.value == "" { + return } - provs, err := config.ParseProviders(body) - if err != nil { - return nil, fmt.Errorf("could not parse models response: %w", err) + _, size := utf8.DecodeLastRuneInString(f.value) + f.value = f.value[:len(f.value)-size] + f.err = "" +} + +func (f *textField) reset() { *f = textField{} } + +// Init opens on m.start through connectVia. An endpoint from the command line +// may not be in settings yet, and connectVia writes it there so the failure +// screen can name it. For the saved endpoint the two calls are the same. +func (m *model) Init() tea.Cmd { + start := m.start + if start == nil { + start = m.g.ActiveEndpoint() + } + return m.connectVia(start, false) +} + +// endpointActivationResult carries an attempt's outcome to the update loop, +// where settings may be written. +type endpointActivationResult struct { + // id identifies the attempt this result belongs to. A result whose id no + // longer matches the current attempt is stale: the user cancelled it or + // typed a different URL over it, and its outcome must not be applied. + id int + gateway bridges.Gateway + err error +} + +// bridgeLine pairs one event the attempt reported with how far into the +// attempt it arrived. The elapsed time is the reason this is not a string. A +// bridge that takes half a minute spends it in the control plane, the browser +// or the first dial, and an unstamped log cannot say which. Three fixes were +// aimed without knowing. +type bridgeLine struct { + elapsed time.Duration + event connection.Event +} + +// String renders a log line the way the connect screen shows it. The event's +// text is flattened to one line because the screen wraps and indents each +// line itself. An embedded newline would land unindented and miscount the +// rows to repaint, and control plane errors carry their request ID on a +// second line. +func (l bridgeLine) String() string { + return fmt.Sprintf("+%-6s %s", l.elapsed.Round(100*time.Millisecond), strings.Join(strings.Fields(describe(l.event)), " ")) +} + +// describe returns the text the user reads for an event. +func describe(e connection.Event) string { + switch { + case e.Phase != 0: + return phaseLabel(e.Phase) + case e.Link != nil: + return "Authorize this bridge at " + e.Link.String() } - return provs, nil + return e.Note } +// phaseLabel names the wait from the user's side. The attempt's elapsed clock +// supplies the "how long". +func phaseLabel(p connection.Phase) string { + switch p { + case connection.StartingMachine: + return "Starting the bridge" + case connection.AwaitingLoginLink: + return "Waiting for a login link" + case connection.AwaitingAuthorization: + return "Waiting for you to authorize this bridge" + case connection.JoiningTailnet: + return "Joining the tailnet" + case connection.FindingEndpoint: + return "Looking for the Aperture on the tailnet" + case connection.AskingForModels: + return "Asking the Aperture for its models" + } + return p.String() +} + +type bridgeLogMsg struct { + ch chan bridgeLine + line bridgeLine +} +type bridgeLogDoneMsg struct{ ch chan bridgeLine } + +// browserOpenMsg reports whether the desktop opener for a bridge login link +// started. id ties it to the attempt that asked, so a cancelled attempt's +// failure does not print over the next one. +type browserOpenMsg struct { + id int + err error +} + +func openURLCmd(id int, url string) tea.Cmd { + return func() tea.Msg { return browserOpenMsg{id: id, err: openURL(url)} } +} + +// clipboardMsg reports the outcome of a click on the login link's copy button. +type clipboardMsg struct { + id int + err error +} + +func copyURLCmd(id int, url string) tea.Cmd { + return func() tea.Msg { return clipboardMsg{id: id, err: copyToClipboard(url)} } +} + +// activationTickMsg repaints the connect screen once a second so a slow attempt +// is visibly still running. Bring-up and the model fetch can take tens of +// seconds logging nothing, and a frozen screen looks like a hang. +type activationTickMsg struct{ id int } + +func activationTick(id int) tea.Cmd { + return tea.Tick(time.Second, func(time.Time) tea.Msg { return activationTickMsg{id: id} }) +} + +type quitMsg struct{ Err error } + +// activateEndpointCmd connects to ep. When ep is the attempt already on +// screen, this is a retry and keeps what that attempt knows: the original of +// a pending edit and whether it wrote ep into settings. func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { + if m.act != nil && m.act.attempt != nil && m.act.endpoint() == ep { + return m.startAttempt(m.act.attempt.Retry()) + } + return m.connect(ep, false, nil) +} + +// connect begins an attempt at ep and puts it on screen. switchTailnet logs +// the bridge out first, so the attempt starts from a login prompt rather than +// the tailnet it is on. replacing is the original of a URL edit. +func (m *model) connect(ep config.Endpoint, switchTailnet bool, replacing config.Endpoint) tea.Cmd { + a, err := bridges.BeginAttempt(m.g, ep, switchTailnet, replacing) + if err != nil { + return simpleErrorCmd(err) + } + return m.startAttempt(a) +} + +// start puts a prepared attempt on the connect screen and runs it. The +// attempt's outcome comes back as an endpointActivationResult and is applied +// there, on this loop, where settings are read. +func (m *model) startAttempt(a *bridges.Attempt) tea.Cmd { + m.stopActivation() m.step = stepPreflight m.preflightErr = "" m.bridgeLogs = nil - m.bridgeLogCh = nil - m.bridgeLogCtx = nil - if m.bridgeCancel != nil { - m.bridgeCancel() - m.bridgeCancel = nil + if a.InvalidatesActive { + m.connected = false } - if ep.BridgeID == "" { - m.preflightLabel = "Checking " + ep.URL + " ..." - return func() tea.Msg { - provs, err := fetchProviders(ep.URL) - return endpointActivationResult{endpoint: ep, host: ep.URL, providers: provs, err: err} - } + ctx, cancel := context.WithCancel(context.Background()) + m.activationSeq++ + act := &activation{ + id: m.activationSeq, + attempt: a, + label: "Checking " + a.Endpoint.URL() + " ...", + started: time.Now(), + cancel: cancel, } + m.act = act + machines := m.machines - bridge, ok := m.g.Bridge(ep.BridgeID) - if !ok { - m.preflightLabel = "Checking " + ep.URL + " ..." - return func() tea.Msg { - return endpointActivationResult{ - endpoint: ep, - host: ep.URL, - err: fmt.Errorf("bridge %s is not configured", ep.BridgeID), - } - } - } - if m.bridgeManager == nil { - return func() tea.Msg { - return endpointActivationResult{ - endpoint: ep, - host: ep.URL, - err: fmt.Errorf("bridge manager is not configured"), - } + if _, bridged := a.Endpoint.(config.BridgeEndpoint); !bridged { + run := func() tea.Msg { + defer cancel() + gw, err := a.Run(ctx, machines, nil) + return endpointActivationResult{id: act.id, gateway: gw, err: err} } + return tea.Batch(run, activationTick(act.id)) } - ch := make(chan string, 32) - ctx, cancel := context.WithCancel(context.Background()) - m.bridgeLogCh = ch - m.bridgeLogCtx = ctx - m.bridgeCancel = cancel - m.preflightLabel = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." - bridgeLogf := bridgeLogSink(ctx, ch) - activate := func() tea.Msg { + ch := make(chan bridgeLine, 32) + act.logCh = ch + act.logCtx = ctx + act.label = "Connecting bridge " + a.Bridge().Name + " to " + a.Endpoint.URL() + " ..." + if a.SwitchesTailnet() { + act.label = "Switching bridge " + a.Bridge().Name + " to a different tailnet ..." + } + emit := bridgeLogSink(ctx, ch, act.started) + run := func() tea.Msg { defer cancel() - localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, bridgeLogf) - if err != nil { - return endpointActivationResult{endpoint: ep, host: ep.URL, err: err} - } - provs, err := fetchProvidersContext(ctx, localURL, bridgeProviderFetchTimeout) - if err != nil { - err = fmt.Errorf("bridge %s could not reach %s: %w", bridge.Name, ep.URL, err) - } - return endpointActivationResult{endpoint: ep, host: localURL, providers: provs, err: err} + gw, err := a.Run(ctx, machines, emit) + return endpointActivationResult{id: act.id, gateway: gw, err: err} } - return tea.Batch(activate, waitBridgeLog(ctx, ch)) + return tea.Batch(run, waitBridgeLog(ctx, ch), activationTick(act.id)) } -func bridgeLogSink(ctx context.Context, ch chan<- string) func(string) { - return func(line string) { - line = strings.TrimSpace(line) - if line == "" { - return +// stopActivation ends the in-flight attempt without touching settings. The +// attempt's own goroutine still delivers a result; the id check in Update +// discards it. +func (m *model) stopActivation() { + act := m.act + if act == nil { + return + } + if act.cancel != nil { + act.cancel() + act.cancel = nil + } + act.logCh = nil + act.logCtx = nil +} + +// discardActivation stops the in-flight attempt and removes the endpoint this +// flow added for it, so an abandoned connection leaves nothing behind. +func (m *model) discardActivation() error { + act := m.act + if act == nil { + return nil + } + m.stopActivation() + return act.attempt.Abandon(m.g) +} + +// cancelActivation abandons the attempt on screen and returns to the menu the +// user started it from. At startup there is no such menu, so the setup guide +// takes its place. +func (m *model) cancelActivation() (tea.Model, tea.Cmd) { + act := m.act + if act == nil { + return m, nil + } + endpoint := act.endpoint() + if err := m.discardActivation(); err != nil { + m.errMsg = "could not remove endpoint: " + err.Error() + m.step = stepError + return m, nil + } + m.act = nil + m.step = stepMenu + _, activeBridged := m.g.ActiveEndpoint().(config.BridgeEndpoint) + if len(m.stack) == 0 || !m.connected && activeBridged { + m.preflightErr = "connection cancelled" + m.forcedToEndpoint = true + m.failedEndpoint = endpoint + m.resetStack(m.setupGuideMenu()) + } + return m, tea.ClearScreen +} + +// overrideActivationURL swaps the URL being probed for one the user typed, +// without waiting for the guess to time out. +func (m *model) overrideActivationURL(value string) (tea.Model, tea.Cmd) { + act := m.act + if act == nil { + return m, nil + } + url, err := config.ParseEndpointURL(value) + if err != nil { + // Keep the running attempt: the typo costs nothing, and the guess + // may still land while the user fixes it. + act.override.err = err.Error() + return m, nil + } + next := act.endpoint().WithURL(url) + if next == act.endpoint() { + act.override.reset() + return m, nil + } + return m, m.retargetActivation(next) +} + +// retargetActivation replaces an attempt's candidate while retaining the +// original endpoint of a pending edit. Both URL editors use this path. +func (m *model) retargetActivation(next config.Endpoint) tea.Cmd { + act := m.act + if act == nil || act.attempt == nil { + return m.connect(next, false, nil) + } + m.stopActivation() + a, err := act.attempt.Retarget(m.g, next) + if err != nil { + m.errMsg = err.Error() + m.step = stepError + return nil + } + return m.startAttempt(a) +} + +// bridgeLogSink returns the function the attempt reports events to on their +// way to the update loop. Only diagnostics are dropped when the buffer is +// full. Everything else waits for room, bounded by the attempt's +// cancellation. This sink used to drop whatever arrived, and under -debug +// tsnet's backend logger shares it, so a burst of chatter could take the +// login link with it. +func bridgeLogSink(ctx context.Context, ch chan<- bridgeLine, started time.Time) func(connection.Event) { + return func(ev connection.Event) { + if ev.Droppable() { + ev.Note = strings.TrimSpace(ev.Note) + if ev.Note == "" { + return + } } + // Stamped here rather than where the message is handled: a burst of + // tsnet logs queues in the channel, and a stamp read after the queue + // would attribute the queueing delay to the wrong line. + line := bridgeLine{elapsed: time.Since(started), event: ev} select { case <-ctx.Done(): return default: } + if ev.Droppable() { + // No ctx case: it was just checked, and a select that offers both + // picks between them at random when the send would also succeed. + select { + case ch <- line: + default: + } + return + } select { case <-ctx.Done(): case ch <- line: - default: } } } -func waitBridgeLog(ctx context.Context, ch chan string) tea.Cmd { +func waitBridgeLog(ctx context.Context, ch chan bridgeLine) tea.Cmd { return func() tea.Msg { // Drain anything already logged before observing cancellation. This // preserves the final dial/proxy error when preflight cancels the log @@ -270,16 +550,25 @@ func waitBridgeLog(ctx context.Context, ch chan string) tea.Cmd { } func (m *model) quitCmd() tea.Cmd { - cancel := m.bridgeCancel - bridgeManager := m.bridgeManager + // The removal's outcome decides whether the records naming the device + // are dropped. Closing the Machines now would cancel the logout, and + // quitting before the outcome arrives would leave settings naming a + // device that may be gone. bridgeRemoved quits once the outcome has + // been applied. + if m.act.isRemoving() { + m.quitAfterRemoval = true + return nil + } + var cancel context.CancelFunc + if m.act != nil { + cancel = m.act.cancel + } + machines := m.machines return func() tea.Msg { if cancel != nil { cancel() } - if bridgeManager == nil { - return quitMsg{} - } - return quitMsg{Err: bridgeManager.Close()} + return quitMsg{Err: machines.Close()} } } @@ -290,53 +579,32 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil - case preflightResult: - if msg.err != nil { - m.connected = false - m.preflightErr = msg.err.Error() - m.forcedToEndpoint = true - failed := m.g.ActiveEndpoint() - m.failedEndpoint = &failed - m.step = stepMenu - m.resetStack(m.setupGuideMenu()) + case endpointActivationResult: + if m.act == nil || msg.id != m.act.id { + // Cancelled or overridden: a newer attempt owns the screen. return m, nil } - m.g.Providers = msg.providers - m.connected = true - m.preflightErr = "" - m.forcedToEndpoint = false - m.failedEndpoint = nil - m.step = stepMenu - m.resetStack(m.rootMenu()) - return m, tea.ClearScreen - - case endpointActivationResult: - m.bridgeCancel = nil + m.act.cancel = nil + a := m.act.attempt if msg.err != nil { - if sameEndpoint(msg.endpoint, m.g.ActiveEndpoint()) { + if a.TargetsActive { m.connected = false } m.preflightErr = msg.err.Error() m.forcedToEndpoint = true - failed := msg.endpoint - m.failedEndpoint = &failed + m.failedEndpoint = a.Endpoint m.step = stepMenu m.resetStack(m.setupGuideMenu()) return m, nil } - if !sameEndpoint(m.g.ActiveEndpoint(), msg.endpoint) { - if err := m.g.SetActiveEndpoint(msg.endpoint); err != nil { - m.preflightErr = "could not save active endpoint: " + err.Error() - m.forcedToEndpoint = true - failed := msg.endpoint - m.failedEndpoint = &failed - m.step = stepMenu - m.resetStack(m.setupGuideMenu()) - return m, nil - } + if err := a.Commit(m.g, msg.gateway); err != nil { + m.preflightErr = err.Error() + m.forcedToEndpoint = true + m.failedEndpoint = a.Endpoint + m.step = stepMenu + m.resetStack(m.setupGuideMenu()) + return m, nil } - m.g.ApertureHost = msg.host - m.g.Providers = msg.providers m.connected = true m.preflightErr = "" m.forcedToEndpoint = false @@ -345,28 +613,74 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetStack(m.rootMenu()) return m, tea.ClearScreen + case bridgeRemovedMsg: + return m.bridgeRemoved(msg) + case bridgeLogMsg: - if m.bridgeLogCh != msg.ch { + if m.act == nil || m.act.logCh != msg.ch { return m, nil } + next := waitBridgeLog(m.act.logCtx, m.act.logCh) + switch { + case msg.line.event.Link != nil: + url := msg.line.event.Link.String() + if url == m.act.authURL { + return m, next // the control plane re-sent the same link + } + // Not appended to the log tail: the footer owns the link now, and + // two copies of a 60 character URL on one screen is noise. + m.act.authURL = url + m.act.copied = false + return m, tea.Batch(next, openURLCmd(m.act.id, url)) + case msg.line.event.Phase != 0: + if !m.act.entered(msg.line.event.Phase) { + return m, next + } + } m.bridgeLogs = appendBridgeLog(m.bridgeLogs, msg.line) - if m.bridgeLogCh != nil { - return m, waitBridgeLog(m.bridgeLogCtx, m.bridgeLogCh) + return m, next + + case activationTickMsg: + if m.step != stepPreflight || m.act == nil || m.act.id != msg.id { + return m, nil } + return m, activationTick(msg.id) + + case browserOpenMsg: + // Only the failure is worth a line: a browser that opened is on the + // user's screen and the link is already in the footer. Over SSH the + // failure is the common case, not an edge case. + if m.act == nil || m.act.id != msg.id || msg.err == nil { + return m, nil + } + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, m.act.logLine("Could not open a browser here ("+msg.err.Error()+"). Use the link below to authorize.")) + return m, nil + + case clipboardMsg: + if m.act == nil || m.act.id != msg.id { + return m, nil + } + if msg.err != nil { + m.bridgeLogs = appendBridgeLog(m.bridgeLogs, m.act.logLine("Could not copy the link ("+msg.err.Error()+"). Select it above instead.")) + return m, nil + } + m.act.copied = true return m, nil case bridgeLogDoneMsg: - if m.bridgeLogCh == msg.ch { - m.bridgeLogCh = nil - m.bridgeLogCtx = nil + if m.act != nil && m.act.logCh == msg.ch { + m.act.logCh = nil + m.act.logCtx = nil } return m, nil case quitMsg: + // A shutdown error is not shown here. Machines.Close memoizes it, so + // a second Close from an error screen would return the same error + // and the user could never leave. main calls Close again after the + // terminal is back and reports the error on stderr. if msg.Err != nil { - m.errMsg = "Error shutting down bridges: " + msg.Err.Error() - m.step = stepError - return m, nil + slog.Error("shutting down bridges", "err", msg.Err) } return m, tea.Quit @@ -375,9 +689,12 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // user may have changed things outside the launcher while the // agent was running. m.popToRoot() - m.step = stepPreflight - m.preflightLabel = "Checking " + m.g.ApertureHost + " ..." - return m, runPreflight(m.g.ApertureHost) + cmd := m.connect(m.g.ActiveEndpoint(), false, nil) + // No cancel handle: this re-check owns the screen until it answers. + if m.act != nil { + m.act.cancel = nil + } + return m, cmd case menu.InstallDoneMsg: if msg.Err != nil { @@ -409,10 +726,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch m.step { case stepPreflight: - if msg.String() == "ctrl+c" { - return m, m.quitCmd() - } - return m, nil + return m.updatePreflight(msg) case stepError: switch msg.String() { case "ctrl+c", "q": @@ -436,12 +750,12 @@ const bridgeLogLimit = 12 // diagnostics produced by aperture-cli itself. Verbose tsnet messages can be // frequent enough to otherwise evict the network identity, target visibility, // and dial failure that -debug is intended to expose. -func appendBridgeLog(logs []string, line string) []string { +func appendBridgeLog(logs []bridgeLine, line bridgeLine) []bridgeLine { logs = append(logs, line) for len(logs) > bridgeLogLimit { drop := 0 for i, line := range logs { - if !importantBridgeLog(line) { + if !line.isImportant() { drop = i break } @@ -451,8 +765,17 @@ func appendBridgeLog(logs []string, line string) []string { return logs } +// important reports whether this line survives trimming. A phase always does: +// the phases are the record of where the time went, and evicting one to make +// room for tsnet chatter puts a gap in exactly the thing the log is for. +func (l bridgeLine) isImportant() bool { + return !l.event.Droppable() || importantBridgeLog(l.event.Note) +} + func importantBridgeLog(line string) bool { for _, prefix := range []string{ + "Could not open a browser here", + "Could not copy the link", "Bridge network:", "Bridge health:", "Bridge target ", @@ -574,10 +897,10 @@ func (m *model) activate(idx int) (tea.Model, tea.Cmd) { if item.Disabled || item.Action == nil { return m, nil } - // Only move the cursor onto visible rows. Hidden shortcut handlers - // (e.g. endpoints menu's "d" delete) read m.cursor() to know which - // visible row to act on — moving the cursor onto the hidden handler - // itself would strand it off-screen and break subsequent actions. + // Only move the cursor onto visible rows. Hidden shortcut handlers, such + // as the endpoints menu's "d" delete, read m.cursor() to know which + // visible row to act on. Moving the cursor onto the hidden handler itself + // would strand it off-screen and break subsequent actions. if !item.Hidden { m.setCursor(idx) } @@ -617,48 +940,166 @@ func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.quitCmd() case "esc": m.step = stepMenu - m.inputValue = "" + m.input.reset() return m, nil case "enter": - v := strings.TrimSpace(m.inputValue) + v := strings.TrimSpace(m.input.value) if v == "" { return m, nil } fn := m.inputOnSave m.step = stepMenu - m.inputValue = "" + m.input.reset() if fn != nil { return m, fn(v) } return m, nil case "backspace": - if len(m.inputValue) > 0 { - m.inputValue = m.inputValue[:len(m.inputValue)-1] - } + m.input.backspace() return m, nil default: - s := msg.String() - if len(s) == 1 { - m.inputValue += s + m.input.insert(msg) + return m, nil + } +} + +// updatePreflight handles keys while a connection attempt is on screen. A +// bridge attempt starts from a guessed URL, so the user can type the real one +// over it instead of waiting for the guess to fail. +func (m *model) updatePreflight(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+c" { + return m, m.quitCmd() + } + // Before the override editor gets a look: that editor owns every printable + // key while a bridge attempt runs, which is the same screen the login link + // appears on, so the copy key has to be a chord the editor drops. + if msg.String() == "ctrl+y" && m.act != nil && m.act.authURL != "" { + return m, copyURLCmd(m.act.id, m.act.authURL) + } + if !m.act.canCancel() { + return m, nil + } + if msg.String() == "esc" { + return m.cancelActivation() + } + if !m.act.canOverride() { + return m, nil + } + switch msg.String() { + case "enter": + v := strings.TrimSpace(m.act.override.value) + if v == "" { + return m, nil } + return m.overrideActivationURL(v) + case "backspace": + m.act.override.backspace() + return m, nil + default: + m.act.override.insert(msg) return m, nil } } -func (m *model) View() string { - switch m.step { - case stepPreflight: - label := m.preflightLabel - if label == "" { - label = "Checking " + m.g.ApertureHost + " ..." +// authCopyHint and authCopiedHint appear under the login link, before and +// after ctrl+y. The key has to be named on screen, because nothing about a +// URL suggests which chord copies it. +const ( + authCopyHint = "ctrl+y to copy the link" + authCopiedHint = "✓ copied to the clipboard" + authProse = "Authorize this bridge in your browser:" +) + +// authFooter renders the login link pinned to the foot of the connect screen. +// +// The link owns its lines outright. Bubble Tea truncates any line wider than +// the terminal, so a long URL has to wrap. Prose sharing those lines would +// land in the selection when the user drags across them, and a browser strips +// a newline out of a URL but not an indent or a label. Every line carries the +// same OSC 8 hyperlink, tagged with one id so terminals rejoin the halves and +// ctrl-click survives. +func (m *model) authFooter() string { + act := m.act + if act == nil || act.authURL == "" { + return "" + } + hint := authCopyHint + if act.copied { + hint = authCopiedHint + } + var sb strings.Builder + // Styled a line at a time. lipgloss pads a multi-line block out to its + // widest line, which would leave trailing spaces on a wrapped link. + for _, line := range strings.Split(m.wrapText("", authProse), "\n") { + sb.WriteString(authStyle.Render(line)) + sb.WriteString("\n") + } + for _, line := range strings.Split(m.wrapText("", act.authURL), "\n") { + sb.WriteString(ansi.SetHyperlink(act.authURL, "id=aperture-auth")) + sb.WriteString(authStyle.Render(line)) + sb.WriteString(ansi.ResetHyperlink()) + sb.WriteString("\n") + } + for i, line := range strings.Split(m.wrapText("", hint), "\n") { + if i > 0 { + sb.WriteString("\n") } - var sb strings.Builder - sb.WriteString(m.wrapText("", dotYellow+" "+label) + "\n") - for _, line := range m.bridgeLogs { - sb.WriteString(dimStyle.Render(m.wrapText(" ", line))) + sb.WriteString(dimStyle.Render(line)) + } + return sb.String() +} + +// activationElapsed counts the attempt up on screen. It starts at 2s so a +// connection that answers immediately does not flash a counter. +func activationElapsed(act *activation) string { + if act == nil || act.started.IsZero() { + return "" + } + if secs := int(time.Since(act.started).Seconds()); secs >= 2 { + return fmt.Sprintf(" (%ds)", secs) + } + return "" +} + +func (m *model) viewPreflight() string { + label := "Checking " + m.g.ApertureHost + " ..." + if m.act != nil && m.act.label != "" { + label = m.act.label + } + var sb strings.Builder + sb.WriteString(m.wrapText("", dotYellow+" "+label+activationElapsed(m.act)) + "\n") + for _, line := range m.bridgeLogs { + sb.WriteString(dimStyle.Render(m.wrapText(" ", line.String()))) + sb.WriteString("\n") + } + switch { + case m.act.canOverride(): + sb.WriteString("\n") + sb.WriteString(dimStyle.Render(m.wrapText(" ", "Different Aperture URL? Type it to connect there instead."))) + sb.WriteString("\n") + sb.WriteString(" > " + m.act.override.value + "█\n") + if m.act.override.err != "" { + sb.WriteString(errorStyle.Render(m.wrapText(" ", m.act.override.err))) sb.WriteString("\n") } - return sb.String() + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Enter to switch · Esc to cancel\n")) + case m.act.canCancel(): + sb.WriteString("\n") + sb.WriteString(dimStyle.Render("Esc to cancel\n")) + } + if footer := m.authFooter(); footer != "" { + sb.WriteString("\n") + sb.WriteString(footer) + sb.WriteString("\n") + } + return sb.String() +} + +func (m *model) View() string { + switch m.step { + case stepPreflight: + return m.viewPreflight() case stepError: var sb strings.Builder sb.WriteString(errorStyle.Render("Error")) @@ -674,7 +1115,7 @@ func (m *model) View() string { if m.inputPrompt != "" { sb.WriteString(" " + m.inputPrompt + "\n") } - sb.WriteString(" > " + m.inputValue + "█\n") + sb.WriteString(" > " + m.input.value + "█\n") sb.WriteString("\n") sb.WriteString(dimStyle.Render("Enter to save · Esc to cancel\n")) return sb.String() @@ -771,11 +1212,11 @@ func (m *model) viewMenu() string { return sb.String() } -// menuLayout decides the visible order and column layout for a menu. -// visible is the list of Items indices that render (hidden rows skipped); -// twoCols is true when the wide-terminal / long-list two-column layout is -// active; half is len(visible) rounded up / 2 (the row count in each -// column). twoCols=false means half is unused. +// menuLayout decides the visible order and column layout for a menu. visible +// lists the Items indices that render, skipping hidden rows. twoCols is true +// when the terminal is wide and the list long enough for two columns. half is +// the row count in each column, len(visible) rounded up and halved. When +// twoCols is false, half is unused. func (m *model) menuLayout(top *menu.Menu) (visible []int, twoCols bool, half int) { visible = make([]int, 0, len(top.Items)) hasZero := false @@ -823,10 +1264,9 @@ func visiblePos(visible []int, i int) int { return -1 } -// autoTokens is the pool of single-character keys auto-assigned to menu -// items in visible order: 1-9, then a-z, then A-Z. "0" is reserved for the -// DigitZero pin; items that set an explicit Shortcut keep that key out of -// the pool. +// autoTokens holds the single-character keys auto-assigned to menu items in +// visible order: 1-9, then a-z, then A-Z. "0" is reserved for the DigitZero +// pin. An item that sets an explicit Shortcut keeps that key out of the pool. var autoTokens = func() []string { var out []string for c := '1'; c <= '9'; c++ { @@ -883,8 +1323,8 @@ func assignTokens(items []menu.MenuItem) []string { return tokens } -// menuHeader returns the one-line status banner shown above certain menus: -// the root menu shows the connected endpoint; the endpoints menu in +// menuHeader returns the one-line status banner shown above certain menus. +// The root menu shows the connected endpoint. The endpoints menu in // preflight-failure mode shows the red "couldn't reach" banner. func (m *model) menuHeader(top *menu.Menu) string { if len(m.stack) == 1 && top.Title == rootTitle { @@ -897,7 +1337,7 @@ func (m *model) menuHeader(top *menu.Menu) string { if m.forcedToEndpoint && (top.Title == endpointsTitle || top.Title == setupGuideTitle) { target := m.g.ActiveEndpoint() if m.failedEndpoint != nil { - target = *m.failedEndpoint + target = m.failedEndpoint } header := m.wrapText("", dotRed+" Could not reach "+m.endpointLabel(target)) + "\n" if m.preflightErr != "" { @@ -905,7 +1345,7 @@ func (m *model) menuHeader(top *menu.Menu) string { } if m.g.Debug { for _, line := range m.bridgeLogs { - header += dimStyle.Render(m.wrapText(" ", line)) + "\n" + header += dimStyle.Render(m.wrapText(" ", line.String())) + "\n" } } return header + "\n" @@ -1008,19 +1448,20 @@ func (m *model) refreshMenuByTitle(title string, next *menu.Menu) { // --- Input step helpers --- -// promptForInput sets up the single-line text input step. onSave is invoked -// with the entered value when the user presses Enter. -func (m *model) promptForInput(title, prompt string, onSave func(value string) tea.Cmd) { +// promptForInput sets up the single-line text input step. initial is the +// editable starting value, empty for a blank field. onSave is invoked with the +// entered value when the user presses Enter. +func (m *model) promptForInput(title, prompt, initial string, onSave func(value string) tea.Cmd) { m.step = stepInput m.inputTitle = title m.inputPrompt = prompt - m.inputValue = "" + m.input = textField{value: initial} m.inputOnSave = onSave } // --- Registered clients access --- -// registeredClients is the set visible to the TUI; overridable in tests. +// registeredClients lists the clients the TUI shows. Tests override it. var registeredClients = func(g *config.Global) []clients.Client { return clients.All(g) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 76525e8..92380fe 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -13,8 +13,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/ansi" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/connection" "github.com/tailscale/aperture-cli/internal/menu" ) @@ -63,16 +65,17 @@ func TestRootMenu_ShowsInstalledClients(t *testing.T) { m := &model{g: &config.Global{}} root := m.rootMenu() - // Installed clients + hidden shortcut items (settings + install-agents). - // Visible count: A, C (2). Plus a hidden Settings and hidden Install agents. - visible := 0 + // Installed clients only: change-connection, settings and install-agents + // are hidden shortcut rows advertised in the hint. + var visible []string for _, it := range root.Items { if !it.Hidden { - visible++ + visible = append(visible, it.Label) } } - if visible != 2 { - t.Errorf("visible items = %d, want 2", visible) + want := []string{"A", "C"} + if !slices.Equal(visible, want) { + t.Errorf("visible items = %v, want %v", visible, want) } } @@ -87,12 +90,13 @@ func TestRootMenu_QuickSelectPrepended(t *testing.T) { withFakeClients(t, []clients.Client{fc}) m := &model{g: &config.Global{ - Settings: config.Settings{Endpoints: []config.Endpoint{{URL: "http://ai"}}}, + Settings: config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}, LastLaunch: config.LaunchState{ LastClientName: "A", LastEndpointURL: "http://ai", }, }} + m.connected = true root := m.rootMenu() // First visible item should be the quick-select row with Digit=0. @@ -126,12 +130,13 @@ func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) { withFakeClients(t, []clients.Client{fc}) m := &model{g: &config.Global{ - Settings: config.Settings{Endpoints: []config.Endpoint{{URL: "http://ai"}}}, + Settings: config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}, LastLaunch: config.LaunchState{ LastClientName: "A", LastEndpointURL: "http://ai", }, }} + m.connected = true root := m.rootMenu() for _, it := range root.Items { if !it.Hidden && strings.Contains(it.Label, "Quick select") { @@ -150,9 +155,10 @@ func TestRootMenu_NoQuickSelectWithoutRecordedEndpoint(t *testing.T) { withFakeClients(t, []clients.Client{fc}) m := &model{g: &config.Global{ - Settings: config.Settings{Endpoints: []config.Endpoint{{URL: "http://ai"}}}, + Settings: config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}, LastLaunch: config.LaunchState{LastClientName: "A"}, }} + m.connected = true for _, it := range m.rootMenu().Items { if !it.Hidden && strings.Contains(it.Label, "Quick select") { t.Errorf("unexpected quick-select row without a recorded endpoint: %+v", it) @@ -173,19 +179,20 @@ func TestQuickSelectRequiresRecordedEndpointToBeActive(t *testing.T) { } withFakeClients(t, []clients.Client{fc}) - active := config.Endpoint{URL: "http://ai", BridgeID: "bridge-current"} - saved := config.Endpoint{URL: "http://ai", BridgeID: "bridge-saved"} + active := config.Bridged("http://ai", "bridge-current") + saved := config.Bridged("http://ai", "bridge-saved") m := &model{ g: &config.Global{ Settings: config.Settings{Endpoints: []config.Endpoint{active, saved}}, Providers: []config.ProviderInfo{{ID: "old-provider"}}, LastLaunch: config.LaunchState{ LastClientName: "A", - LastEndpointURL: saved.URL, - LastBridgeID: saved.BridgeID, + LastEndpointURL: saved.URL(), + LastBridgeID: saved.BridgeID(), }, }, } + m.connected = true for _, item := range m.rootMenu().Items { if !item.Hidden && strings.Contains(item.Label, "Quick select") { t.Fatalf("quick-select shown for inactive endpoint: %+v", item) @@ -215,7 +222,7 @@ func TestMenuEngine_PushPop(t *testing.T) { fc := &fakeClient{name: "A", installed: true, menuActions: sub} withFakeClients(t, []clients.Client{fc}) - m := &model{g: &config.Global{}, step: stepMenu} + m := &model{g: &config.Global{}, step: stepMenu, connected: true} m.resetStack(m.rootMenu()) // Select the visible "A" item (first non-hidden). @@ -411,7 +418,9 @@ func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { g: &config.Global{ApertureHost: "http://ai"}, step: stepPreflight, } - m.Update(preflightResult{err: fmt.Errorf("connection refused")}) + m.activationSeq = 1 + m.act = &activation{id: 1, attempt: &bridges.Attempt{Endpoint: config.Direct("http://ai")}} + m.Update(endpointActivationResult{id: 1, err: fmt.Errorf("connection refused")}) if !m.forcedToEndpoint { t.Error("forcedToEndpoint should be true") } @@ -427,13 +436,12 @@ func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { withFakeTailscale(t, tsConnected) withFakeClients(t, nil) + ep := config.Direct("http://ai") m := &model{ g: &config.Global{ApertureHost: "http://ai"}, } - m.Update(endpointActivationResult{ - endpoint: config.Endpoint{URL: "http://ai"}, - err: fmt.Errorf("timeout"), - }) + m.activateEndpointCmd(ep) + m.Update(endpointActivationResult{id: m.act.id, err: fmt.Errorf("timeout")}) if !m.forcedToEndpoint { t.Error("forcedToEndpoint should be true") } @@ -506,7 +514,7 @@ func TestSetupGuideMenu_BridgeDoesNotRequireSystemTailscale(t *testing.T) { ApertureHost: "http://aperture", Settings: config.Settings{ Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work Bridge"}}, - Endpoints: []config.Endpoint{{URL: "http://aperture", BridgeID: "bridge-abcdef"}}, + Endpoints: []config.Endpoint{config.Bridged("http://aperture", "bridge-abcdef")}, }, }} @@ -525,7 +533,7 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") m := &model{ g: &config.Global{Settings: config.Settings{ - Endpoints: []config.Endpoint{{URL: "http://ai"}}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, }}, step: stepMenu, } @@ -545,20 +553,574 @@ func TestEndpointBridgeMenu_AddsFirstBridgeInline(t *testing.T) { if m.step != stepInput || m.inputOnSave == nil { t.Fatal("Add Bridge did not prompt for a name") } - if cmd := m.inputOnSave("Work Bridge"); cmd != nil { - if msg := cmd(); msg != nil { - t.Fatalf("adding bridge returned %T: %v", msg, msg) - } + cmd := m.inputOnSave("Work Bridge") + if cmd == nil { + t.Fatal("naming the bridge did not start a connection") } if len(m.g.Settings.Bridges) != 1 || m.g.Settings.Bridges[0].Name != "Work Bridge" { t.Fatalf("bridges = %+v", m.g.Settings.Bridges) } + bridgeID := m.g.Settings.Bridges[0].ID if top := m.top(); top.Title != "Choose a bridge" || len(top.Items) != 2 || top.Items[0].Label != "Work Bridge" || top.Items[1].Label != "Add Bridge" { t.Fatalf("bridge chooser was not refreshed: %+v", top) } - if m.step != stepInput || m.inputTitle != "Add Bridge Endpoint:" { - t.Fatalf("adding bridge did not continue to endpoint URL: step=%v title=%q", m.step, m.inputTitle) + // The user is never asked for a URL: discovery guesses the well-known + // Aperture location through the new bridge. + if m.step != stepPreflight { + t.Fatalf("step = %v, want stepPreflight", m.step) + } + want := config.Bridged(config.DefaultLocation, bridgeID) + if m.act == nil || m.act.endpoint() != want { + t.Fatalf("activation = %+v, want %+v", m.act, want) + } + if !m.act.attempt.Ephemeral() { + t.Error("guessed endpoint is not marked ephemeral, so abandoning it would leave it behind") + } + if !m.endpointConfigured(want) { + t.Fatalf("guessed endpoint was not saved: %+v", m.g.Settings.Endpoints) + } + if got := m.g.ActiveEndpoint().URL(); got != "http://other" { + t.Errorf("active endpoint = %q, want the previous one until discovery verifies", got) + } +} + +func TestEndpointBridgeMenu_ConnectsExistingBridgeWithoutPrompting(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + m := &model{ + g: &config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, + }}, + step: stepMenu, + } + m.resetStack(m.endpointBridgeMenu()) + + res := m.top().Items[0].Action() + if res.Cmd == nil { + t.Fatal("selecting a bridge did not start a connection") + } + if m.step == stepInput { + t.Fatal("selecting a bridge prompted for a URL") + } + if m.step != stepPreflight { + t.Fatalf("step = %v, want stepPreflight", m.step) + } + if m.act == nil || m.act.endpoint() != config.Endpoint(config.Bridged(config.DefaultLocation, bridge.ID)) { + t.Fatalf("activation = %+v, want %s via %s", m.act, config.DefaultLocation, bridge.ID) + } + if !m.act.canOverride() { + t.Error("bridge discovery should accept a typed URL while it runs") + } +} + +// The launcher opens on whatever the invocation named, not on the saved +// active endpoint, and does not make it active on the way in: a -endpoint +// that turns out to be unreachable must not displace the one that works. +func TestInitOpensOnTheStartEndpoint(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + saved := config.Direct("http://saved") + named := config.Bridged("http://named", bridge.ID) + m := NewModel(&config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{saved}, + }}, "B0-test", nil, named).(*model) + + if cmd := m.Init(); cmd == nil { + t.Fatal("Init did not start a connection") + } + if m.act == nil || m.act.endpoint() != named { + t.Fatalf("activation = %+v, want %+v", m.act, named) + } + if !m.act.attempt.Ephemeral() { + t.Error("an endpoint named on the command line should come back out if the attempt is abandoned") + } + if got := m.g.ActiveEndpoint(); got != saved { + t.Errorf("active endpoint = %+v, want %+v until the attempt succeeds", got, saved) + } +} + +// The ordinary run names nothing, and has to behave exactly as it did before +// the flags existed. +func TestInitOpensOnTheSavedEndpointWhenNothingIsNamed(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + saved := config.Direct("http://saved") + g := &config.Global{Settings: config.Settings{Endpoints: []config.Endpoint{saved}}} + m := NewModel(g, "B0-test", nil, g.ActiveEndpoint()).(*model) + + if cmd := m.Init(); cmd == nil { + t.Fatal("Init did not start a connection") + } + if m.act == nil || m.act.endpoint() != saved { + t.Fatalf("activation = %+v, want %+v", m.act, saved) + } + if m.act.attempt.Ephemeral() { + t.Error("the saved endpoint is not ephemeral; cancelling must not delete it") + } +} + +func TestPreflightOverrideReplacesGuessedEndpoint(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + previous := config.Direct("http://other") + m := &model{ + g: &config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{previous}, + }}, + step: stepMenu, + } + m.resetStack(m.endpointBridgeMenu()) + m.top().Items[0].Action() + + guessed := m.act + // Typing the real URL must not wait for the guess to fail. The stray + // character and backspace keep the inline editor honest. + for _, r := range "aperture.example.ts.netX" { + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + m.Update(tea.KeyMsg{Type: tea.KeyBackspace}) + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if guessed.cancel != nil { + t.Error("guessed attempt was not cancelled") + } + want := config.Bridged("http://aperture.example.ts.net", bridge.ID) + if m.act == nil || m.act.endpoint() != want { + t.Fatalf("activation = %+v, want %+v", m.act, want) + } + if m.act.id == guessed.id { + t.Error("override reused the cancelled attempt's id, so its stale result would be applied") + } + // The guess is replaced, not accumulated, and the working endpoint stays. + if got := m.g.Settings.Endpoints; len(got) != 2 || got[0] != previous || got[1] != want { + t.Fatalf("endpoints = %+v, want the previous one plus the typed one", got) + } +} + +func TestPreflightOverrideRejectsBadURLWithoutStoppingTheAttempt(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + m := &model{ + g: &config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{config.Direct("http://other")}, + }}, + step: stepMenu, + } + m.resetStack(m.endpointBridgeMenu()) + m.top().Items[0].Action() + running := m.act + + for _, r := range "ftp://nope" { + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } + m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + if m.act != running || running.cancel == nil { + t.Fatal("a rejected URL stopped the running attempt") + } + if running.override.err == "" { + t.Error("rejected URL reported no error to the user") + } + if !strings.Contains(m.View(), running.override.err) { + t.Error("connect screen does not show why the typed URL was rejected") + } +} + +func TestTextFieldTakesTypedAndPastedTextOnly(t *testing.T) { + var f textField + f.insert(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) + // A pasted URL arrives as one message carrying every rune. + f.insert(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ttp://ai"), Paste: true}) + f.insert(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}) + // Named keys and chords carry no text: their String() would otherwise + // land in the field as "up" and "x". + f.insert(tea.KeyMsg{Type: tea.KeyUp}) + f.insert(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x"), Alt: true}) + f.insert(tea.KeyMsg{Type: tea.KeyEnter}) + if f.value != "http://ai " { + t.Errorf("value = %q, want %q", f.value, "http://ai ") + } + + f.backspace() + if f.value != "http://ai" { + t.Errorf("value after backspace = %q", f.value) + } +} + +func TestPreflightEscapeAbandonsDiscovery(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + previous := config.Direct("http://other") + m := &model{ + g: &config.Global{Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{previous}, + }}, + step: stepMenu, + } + m.resetStack(m.endpointBridgeMenu()) + m.top().Items[0].Action() + guessed := m.act + + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if guessed.cancel != nil { + t.Error("Esc did not cancel the attempt") + } + if m.step != stepMenu || m.top().Title != "Choose a bridge" { + t.Fatalf("Esc did not return to the bridge chooser: step=%v top=%+v", m.step, m.top()) + } + if got := m.g.Settings.Endpoints; len(got) != 1 || got[0] != previous { + t.Fatalf("endpoints = %+v, want the abandoned guess removed", got) + } + // A late result from the abandoned attempt must not take over the screen. + m.Update(endpointActivationResult{id: guessed.id, err: fmt.Errorf("too late")}) + if m.step != stepMenu || m.top().Title != "Choose a bridge" { + t.Fatalf("stale result was applied: step=%v top=%+v", m.step, m.top()) + } +} + +func TestPreflightEscapeAtStartupShowsSetupGuide(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ + ApertureHost: "http://ai", + Settings: config.Settings{Endpoints: []config.Endpoint{config.Direct("http://ai")}}, + }} + m.Init() + + m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + if m.step != stepMenu || m.top() == nil || m.top().Title != setupGuideTitle { + t.Fatalf("Esc at startup left nowhere to go: step=%v top=%+v", m.step, m.top()) + } +} + +func TestSetupGuideEditPrefillsFailedURL(t *testing.T) { + withFakeTailscale(t, tsConnected) + target := config.Direct("http://aperture.example.ts.net") + m := &model{ + g: &config.Global{ApertureHost: target.URL()}, + failedEndpoint: target, + } + guide := m.setupGuideMenu() + for _, it := range guide.Items { + if it.Label != "Edit endpoint URL" { + continue + } + it.Action() + if m.input.value != target.URL() { + t.Fatalf("edit field = %q, want the failed URL %q", m.input.value, target.URL()) + } + return + } + t.Fatal("Edit endpoint URL item not found") +} + +// A guessed URL that answers is not necessarily the Aperture the user wanted: +// on a tailnet with a host called "ai" both a direct connection and a new +// bridge land there and succeed, and nothing opens the setup guide's editor. +func TestEndpointsMenu_EditRetargetsWorkingEndpoint(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + connected := config.Bridged(config.DefaultLocation, bridge.ID) + m := &model{ + g: &config.Global{ + ApertureHost: connected.URL(), + Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{connected}, + }, + }, + step: stepMenu, + connected: true, + } + m.resetStack(m.endpointsMenu()) + + edit := -1 + for i, it := range m.top().Items { + if it.Shortcut == "e" { + edit = i + break + } + } + if edit < 0 { + t.Fatal("endpoints menu offers no way to edit an endpoint URL") + } + m.setCursor(0) + m.activate(edit) + if m.step != stepInput || m.input.value != connected.URL() { + t.Fatalf("edit field: step=%v value=%q, want stepInput prefilled with %q", m.step, m.input.value, connected.URL()) + } + + m.inputOnSave("http://aperture.example.ts.net") + + want := config.Bridged("http://aperture.example.ts.net", bridge.ID) + if got := m.g.ActiveEndpoint(); got != connected { + t.Fatalf("active endpoint = %+v, want %+v until verification", got, connected) + } + if m.act == nil || m.act.endpoint() != want { + t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) + } + m.Update(endpointActivationResult{id: m.act.id, gateway: bridges.Gateway{URL: "http://127.0.0.1:12345"}}) + if got := m.g.Settings.Endpoints; len(got) != 1 || got[0] != want { + t.Fatalf("endpoints = %+v, want verified replacement %+v", got, want) + } +} + +// pickerModel is a launcher connected directly to the default location with +// two bridges configured: one already used by an endpoint, one not used at all. +// This is the state the picker exists for, where autoconnect succeeds and the +// bridges are otherwise unreachable. +func pickerModel(t *testing.T) *model { + t.Helper() + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") + return &model{ + g: &config.Global{ + ApertureHost: config.DefaultLocation, + Settings: config.Settings{ + Bridges: []config.Bridge{ + {ID: "bridge-aaaaaa", Name: "Work", Tailnet: "corp.example.com"}, + {ID: "bridge-bbbbbb", Name: "Home"}, + }, + Endpoints: []config.Endpoint{ + config.Direct(config.DefaultLocation), + config.Bridged(config.DefaultLocation, "bridge-aaaaaa"), + }, + }, + }, + step: stepMenu, + connected: true, + } +} + +func findItem(t *testing.T, items []menu.MenuItem, label string) (int, menu.MenuItem) { + t.Helper() + for i, it := range items { + if strings.Contains(it.Label, label) { + return i, it + } + } + var labels []string + for _, it := range items { + if !it.Hidden { + labels = append(labels, it.Label) + } + } + t.Fatalf("no item matching %q in %v", label, labels) + return 0, menu.MenuItem{} +} + +func TestRootMenu_OpensConnectionPicker(t *testing.T) { + withFakeClients(t, []clients.Client{&fakeClient{name: "A", installed: true}}) + m := pickerModel(t) + m.resetStack(m.rootMenu()) + + if !strings.Contains(m.top().Hint, "[c] Change connection") { + t.Errorf("root hint = %q, want it to advertise the picker", m.top().Hint) + } + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}}) + if got := m.top().Title; got != endpointsTitle { + t.Fatalf("menu title = %q, want %q", got, endpointsTitle) + } +} + +func TestConnectionPicker_ListsBridgesAndTailnets(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + + var rows []string + for _, it := range m.top().Items { + if !it.Hidden { + rows = append(rows, ansi.Strip(it.Label)+"|"+it.Description) + } + } + want := []string{ + "http://ai (direct) (active)|", + "http://ai via Work|tailnet corp.example.com", + "Connect via Home|tailnet not known yet", + "Add a connection|", + } + if !slices.Equal(rows, want) { + t.Fatalf("picker rows =\n%v\nwant\n%v", rows, want) + } +} + +func TestConnectionPicker_ConnectsViaUnusedBridge(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + + idx, _ := findItem(t, m.top().Items, "Connect via Home") + m.activate(idx) + connect, _ := findItem(t, m.top().Items, "Connect") + m.activate(connect) + + want := config.Bridged(config.DefaultLocation, "bridge-bbbbbb") + if m.act == nil || m.act.endpoint() != want { + t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) + } + if !m.endpointConfigured(want) { + t.Errorf("endpoints = %+v, want the bridge endpoint saved for retry", m.g.Settings.Endpoints) + } +} + +func TestConnectionPicker_SwitchTailnetConfirmsThenReconnects(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + + idx, _ := findItem(t, m.top().Items, "via Work") + m.activate(idx) + switchIdx, _ := findItem(t, m.top().Items, "Switch tailnet") + m.activate(switchIdx) + + if !strings.Contains(m.top().Preamble, "corp.example.com") { + t.Errorf("confirm preamble = %q, want the tailnet being left", m.top().Preamble) + } + yes, _ := findItem(t, m.top().Items, "Switch tailnet") + m.activate(yes) + + if bridged, ok := m.act.endpoint().(config.BridgeEndpoint); !ok || bridged.BridgeID() != "bridge-aaaaaa" { + t.Fatalf("activation = %+v, want a reconnect through the bridge", m.act) + } + // The bridge has left that tailnet whether or not the new login completes. + if got := m.g.Settings.Bridges[0].Tailnet; got != "" { + t.Errorf("recorded tailnet = %q, want it cleared by the switch", got) + } +} + +func TestConnectionPicker_DirectEndpointHasNoTailnetSwitch(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + + m.activate(0) + for _, it := range m.top().Items { + if strings.Contains(it.Label, "Switch tailnet") { + t.Fatal("direct endpoint offers a tailnet switch") + } + } + // The active connection cannot be removed out from under itself. + _, remove := findItem(t, m.top().Items, "Remove connection") + if !remove.Disabled { + t.Error("active connection offers Remove") + } +} + +func TestConnectionPicker_RemovesInactiveConnection(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + + idx, _ := findItem(t, m.top().Items, "via Work") + m.activate(idx) + remove, _ := findItem(t, m.top().Items, "Remove connection") + m.activate(remove) + + if len(m.g.Settings.Endpoints) != 1 { + t.Fatalf("endpoints = %+v, want only the active one left", m.g.Settings.Endpoints) + } + if got := m.top().Title; got != endpointsTitle { + t.Fatalf("menu title = %q, want to be back on %q", got, endpointsTitle) + } + // Work used to come back as a bare bridge row here, which read as the row + // moving to the bottom rather than being removed and took a second press + // to clear. Removing the connection removes the bridge it was the last + // endpoint for. + for _, it := range m.top().Items { + if strings.Contains(it.Label, "Work") { + t.Errorf("Work still on the picker as %q", it.Label) + } + } + if len(m.g.Settings.Bridges) != 1 || m.g.Settings.Bridges[0].Name != "Home" { + t.Errorf("bridges = %+v, want only Home", m.g.Settings.Bridges) + } +} + +// The hint promises "d to remove" on every row, so it has to mean the same +// thing the row's own page does, including on a bridge that has no endpoint. +func TestConnectionPicker_DeleteKeyRemovesRowUnderCursor(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + del, _ := findItem(t, m.top().Items, "delete") + + m.setCursor(2) // Connect via Home + m.activate(del) + if got := m.g.Settings.Bridges; len(got) != 1 || got[0].Name != "Work" { + t.Fatalf("bridges = %+v, want Home removed", got) + } + + m.setCursor(1) // http://ai via Work + del, _ = findItem(t, m.top().Items, "delete") + m.activate(del) + if got := m.g.Settings.Endpoints; len(got) != 1 || got[0] != config.Endpoint(config.Direct(config.DefaultLocation)) { + t.Fatalf("endpoints = %+v, want the bridge endpoint removed", got) + } +} + +func TestConnectionPicker_DeleteKeySaysWhyTheActiveRowStays(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.endpointsMenu()) + del, _ := findItem(t, m.top().Items, "delete") + + m.setCursor(0) + _, cmd := m.activate(del) + if cmd == nil { + t.Fatal("d on the active connection did nothing at all") + } + m.Update(cmd()) + if m.step != stepError || !strings.Contains(m.errMsg, "active") { + t.Fatalf("step=%v errMsg=%q, want an explanation", m.step, m.errMsg) + } + if len(m.g.Settings.Endpoints) != 2 { + t.Fatalf("endpoints = %+v, want the active one kept", m.g.Settings.Endpoints) + } +} + +func TestBridgesMenu_ConnectsThroughBridge(t *testing.T) { + m := pickerModel(t) + m.resetStack(m.bridgesMenu()) + + idx, _ := findItem(t, m.top().Items, "Home") + m.activate(idx) + + want := config.Bridged(config.DefaultLocation, "bridge-bbbbbb") + if m.act == nil || m.act.endpoint() != want { + t.Fatalf("activation = %+v, want a connection to %+v", m.act, want) + } +} + +func TestSetupGuideExplainsDefaultLocationGuess(t *testing.T) { + bridge := config.Bridge{ID: "bridge-abcdef", Name: "Work"} + target := config.Bridged(config.DefaultLocation, bridge.ID) + m := &model{ + g: &config.Global{ + ApertureHost: config.DefaultLocation, + Settings: config.Settings{ + Bridges: []config.Bridge{bridge}, + Endpoints: []config.Endpoint{target}, + }, + }, + failedEndpoint: target, + } + if got := m.setupGuideMenu().Preamble; !strings.Contains(got, "default Aperture location") { + t.Errorf("preamble does not explain the guessed URL: %q", got) } } @@ -582,7 +1144,7 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") - old := config.Endpoint{URL: "http://old"} + old := config.Direct("http://old") bridge := config.Bridge{ID: "bridge-abcdef", Name: "Second"} m := &model{ g: &config.Global{ @@ -596,30 +1158,29 @@ func TestBridgeEndpointFailureKeepsPreviousEndpointActive(t *testing.T) { step: stepMenu, connected: true, } - chooser := m.endpointBridgeMenu() - chooser.Items[0].Action() - want := config.Endpoint{URL: "http://new", BridgeID: bridge.ID} - cmd := m.inputOnSave(want.URL) - if cmd == nil { - t.Fatal("saving bridge endpoint did not begin activation") + m.resetStack(m.endpointBridgeMenu()) + res := m.top().Items[0].Action() + if res.Cmd == nil { + t.Fatal("selecting the bridge did not begin activation") } - if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { + want := config.Bridged(config.DefaultLocation, bridge.ID) + if got := m.g.ActiveEndpoint(); got != old { t.Fatalf("active endpoint changed before activation: %+v", got) } if !m.endpointConfigured(want) { t.Fatalf("candidate endpoint was not saved: %+v", m.g.Settings.Endpoints) } - msg := cmd() + msg := activationResult(t, res.Cmd) result, ok := msg.(endpointActivationResult) if !ok { t.Fatalf("activation message = %T", msg) } - if !sameEndpoint(result.endpoint, want) || result.err == nil { + if m.act.endpoint() != want || result.err == nil { t.Fatalf("activation result = %+v, want failed second bridge endpoint", result) } m.Update(result) - if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { + if got := m.g.ActiveEndpoint(); got != old { t.Fatalf("failed activation changed active endpoint: %+v", got) } if m.g.ApertureHost != "http://old" || len(m.g.Providers) != 1 || m.g.Providers[0].ID != "old-provider" { @@ -644,7 +1205,7 @@ func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { t.Setenv("HOME", tmp) t.Setenv("XDG_CONFIG_HOME", tmp+"/.config") srv := modelsServer(t) - old := config.Endpoint{URL: "http://old"} + old := config.Direct("http://old") m := &model{ g: &config.Global{ ApertureHost: "http://old", @@ -656,12 +1217,11 @@ func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { m.addEndpointConnectionMenu().Items[0].Action() cmd := m.inputOnSave(srv.URL) - if got := m.g.ActiveEndpoint(); !sameEndpoint(got, old) { + if got := m.g.ActiveEndpoint(); got != old { t.Fatalf("active endpoint changed before /v1/models: %+v", got) } - activation := cmd() - m.Update(activation) - if got := m.g.ActiveEndpoint(); got.URL != srv.URL { + m.Update(activationResult(t, cmd)) + if got := m.g.ActiveEndpoint(); got.URL() != srv.URL { t.Fatalf("active endpoint = %+v, want %q", got, srv.URL) } if m.g.ApertureHost != srv.URL || len(m.g.Providers) != 1 || m.g.Providers[0].ID != "anthropic" { @@ -669,22 +1229,84 @@ func TestDirectEndpointIsPromotedOnlyAfterModelsSucceed(t *testing.T) { } } +// activationResult runs what activateEndpoint returned and hands back the +// attempt's own message. What it returns is a batch: the attempt, a bridge log +// pump for bridge attempts, and the one-second repaint tick, with the attempt +// itself always first. +func activationResult(t *testing.T, cmd tea.Cmd) tea.Msg { + t.Helper() + if cmd == nil { + t.Fatal("no activation command") + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + return activationResult(t, batch[0]) + } + return msg +} + +func TestActivationTickRunsOnlyWhileConnecting(t *testing.T) { + m := &model{g: &config.Global{}, step: stepPreflight, act: &activation{id: 3, started: time.Now().Add(-12 * time.Second)}} + + if _, cmd := m.Update(activationTickMsg{id: 3}); cmd == nil { + t.Error("connect screen stopped counting while the attempt was still running") + } + if _, cmd := m.Update(activationTickMsg{id: 2}); cmd != nil { + t.Error("a superseded attempt kept ticking") + } + m.step = stepMenu + if _, cmd := m.Update(activationTickMsg{id: 3}); cmd != nil { + t.Error("ticks continued after the attempt left the screen") + } + + if got := activationElapsed(m.act); got != " (12s)" { + t.Errorf("elapsed = %q, want %q", got, " (12s)") + } + if got := activationElapsed(&activation{started: time.Now()}); got != "" { + t.Errorf("elapsed on a fresh attempt = %q, want empty", got) + } +} + +// TestBridgeLogSinkStampsElapsed covers the one thing a bridge log has to +// answer after a slow connection: which phase the wait was in. Without the +// stamp, "waiting for a login link" and "Bridge connected" are adjacent lines +// whether the gap between them was 200ms or 29s. +func TestBridgeLogSinkStampsElapsed(t *testing.T) { + ch := make(chan bridgeLine, 1) + emit := bridgeLogSink(context.Background(), ch, time.Now().Add(-12500*time.Millisecond)) + emit(connection.Note(" Bridge connected. ")) + + line := <-ch + if line.event.Note != "Bridge connected." { + t.Errorf("text = %q, want it trimmed", line.event.Note) + } + if line.elapsed < 12*time.Second { + t.Errorf("elapsed = %s, want it measured from the attempt's start", line.elapsed) + } + if want := "+12.5s Bridge connected."; line.String() != want { + t.Errorf("rendered = %q, want %q", line.String(), want) + } +} + func TestBridgeLogSinkIgnoresLateLogsAfterCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - ch := make(chan string, 1) - logf := bridgeLogSink(ctx, ch) + ch := make(chan bridgeLine, 1) + emit := bridgeLogSink(ctx, ch, time.Now()) cancel() close(ch) // This is the sequence that panicked in v0.0.9: preflight had ended and // closed its channel, but tsnet emitted another background debug log. - logf("late tsnet log") + emit(connection.Note("late tsnet log")) + // And the same for an event that is not droppable, which blocks rather + // than falling through a default and so has only cancellation to stop it. + emit(connection.Entered(connection.JoiningTailnet)) } func TestWaitBridgeLogDrainsBufferedLogBeforeCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - ch := make(chan string, 1) - ch <- "final dial error" + ch := make(chan bridgeLine, 1) + ch <- bridgeLine{event: connection.Note("final dial error")} cancel() msg := waitBridgeLog(ctx, ch)() @@ -692,86 +1314,209 @@ func TestWaitBridgeLogDrainsBufferedLogBeforeCancellation(t *testing.T) { if !ok { t.Fatalf("message = %T, want bridgeLogMsg", msg) } - if logMsg.line != "final dial error" { - t.Errorf("line = %q, want final dial error", logMsg.line) + if logMsg.line.event.Note != "final dial error" { + t.Errorf("line = %q, want final dial error", logMsg.line.event.Note) } } func TestAppendBridgeLogRetainsDiagnosticsOverTsnetNoise(t *testing.T) { - logs := []string{ - `Bridge network: state=Running tailnet="example.com" peers=598`, - `Bridge target is visible: requested="aperture.example.ts.net"`, + logs := []bridgeLine{ + {event: connection.Note(`Bridge network: state=Running tailnet="example.com" peers=598`)}, + {event: connection.Note(`Bridge target is visible: requested="aperture.example.ts.net"`)}, + {event: connection.Entered(connection.AwaitingLoginLink)}, } for i := range bridgeLogLimit + 10 { - logs = appendBridgeLog(logs, fmt.Sprintf("magicsock: noisy line %d", i)) + logs = appendBridgeLog(logs, bridgeLine{event: connection.Notef("magicsock: noisy line %d", i)}) } - logs = appendBridgeLog(logs, "Bridge dial failed: lookup failed") + logs = appendBridgeLog(logs, bridgeLine{event: connection.Note("Bridge dial failed: lookup failed")}) if len(logs) != bridgeLogLimit { t.Fatalf("len(logs) = %d, want %d", len(logs), bridgeLogLimit) } - got := strings.Join(logs, "\n") - for _, want := range []string{"Bridge network:", "Bridge target is visible:", "Bridge dial failed:"} { + var got string + for _, line := range logs { + got += line.event.String() + "\n" + } + for _, want := range []string{"Bridge network:", "Bridge target is visible:", "Bridge dial failed:", connection.AwaitingLoginLink.String()} { if !strings.Contains(got, want) { t.Errorf("logs lost %q:\n%s", want, got) } } } -func TestFetchProvidersIncludesErrorResponseBody(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - http.Error(w, "bridge proxy error: lookup aperture", http.StatusBadGateway) - })) - defer srv.Close() - - _, err := fetchProviders(srv.URL) - if err == nil || !strings.Contains(err.Error(), "lookup aperture") { - t.Fatalf("fetchProviders error = %v, want response detail", err) +// runCmd executes cmd and everything it batched, discarding the messages. The +// side effects are the point: which of the batched commands actually ran. +func runCmd(t *testing.T, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + return + } + if batch, ok := cmd().(tea.BatchMsg); ok { + for _, c := range batch { + runCmd(t, c) + } } } -func TestFetchProvidersUsesModelsEndpoint(t *testing.T) { - srv := modelsServerWithHandler(t, func(r *http.Request) { - if r.Method != http.MethodGet { - t.Errorf("method = %q, want GET", r.Method) - } - if r.URL.Path != "/v1/models" { - t.Errorf("path = %q, want /v1/models", r.URL.Path) - } - if got := r.Header.Get("User-Agent"); got != "aperture-cli" { - t.Errorf("User-Agent = %q, want aperture-cli", got) - } - }) - defer srv.Close() +const testAuthURL = "https://login.tailscale.com/a/17bceb7b0129ba" - got, err := fetchProviders(srv.URL + "/") +func TestBridgeAuthURLIsShownOnceAndOpened(t *testing.T) { + link, err := connection.ParseLoginLink(testAuthURL) if err != nil { t.Fatal(err) } - if len(got) != 1 || got[0].ID != "anthropic" || !got[0].SupportsEndpoint(config.EndpointAnthropicMessages) { - t.Fatalf("fetchProviders() = %#v, want Anthropic Messages provider", got) - } -} -func TestFetchProvidersContextHonorsCancellation(t *testing.T) { - requestStarted := make(chan struct{}) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(requestStarted) - <-r.Context().Done() - })) - defer srv.Close() + var opened []string + orig := openURL + openURL = func(url string) error { + opened = append(opened, url) + return nil + } + t.Cleanup(func() { openURL = orig }) + ch := make(chan bridgeLine, 1) + // Cancelled: waitBridgeLog then answers immediately, so running the batch + // does not block on a log line that will never come. ctx, cancel := context.WithCancel(context.Background()) - result := make(chan error, 1) - go func() { - _, err := fetchProvidersContext(ctx, srv.URL, time.Minute) - result <- err - }() - <-requestStarted cancel() + m := &model{ + g: &config.Global{}, + width: 100, + act: &activation{id: 7, logCh: ch, logCtx: ctx}, + } + + _, cmd := m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{event: connection.LoginRequired(link)}}) + runCmd(t, cmd) + if len(opened) != 1 || opened[0] != testAuthURL { + t.Fatalf("browser opens = %q, want one at %q", opened, testAuthURL) + } + _, cmd = m.Update(bridgeLogMsg{ch: ch, line: bridgeLine{event: connection.LoginRequired(link)}}) + runCmd(t, cmd) + if len(opened) != 1 { + t.Errorf("repeated auth URL opened the browser again: %q", opened) + } + + // The footer owns the link; a copy in the log tail would be the same 60 + // characters twice on one screen. + if len(m.bridgeLogs) != 0 { + t.Errorf("bridge logs = %q, want the link only in the footer", m.bridgeLogs) + } + footer := ansi.Strip(m.authFooter()) + if !strings.Contains(footer, authProse) { + t.Errorf("footer = %q, want it to say what the link is for", footer) + } + if !strings.Contains(footer, "\n"+testAuthURL+"\n") { + t.Errorf("footer = %q, want %q alone on its line", footer, testAuthURL) + } + + m.Update(browserOpenMsg{id: 7, err: errors.New("exec: \"xdg-open\": not found")}) + if len(m.bridgeLogs) != 1 || !strings.Contains(m.bridgeLogs[0].event.Note, "Use the link below") { + t.Errorf("failed open did not tell the user to use the link: %q", m.bridgeLogs) + } + m.Update(browserOpenMsg{id: 6, err: errors.New("stale")}) + if len(m.bridgeLogs) != 1 { + t.Errorf("a stale attempt's open failure was shown: %q", m.bridgeLogs) + } +} - if err := <-result; !errors.Is(err, context.Canceled) { - t.Fatalf("fetchProvidersContext error = %v, want context canceled", err) +// TestAuthFooterCopyKey covers the SSH case: no browser opens there, so the +// only way to the link is the terminal's own clipboard, over OSC 52. The key +// is a chord because the override editor shares this screen and takes every +// printable one. +func TestAuthFooterCopyKey(t *testing.T) { + var copies []string + orig := copyToClipboard + copyToClipboard = func(s string) error { + copies = append(copies, s) + return nil + } + t.Cleanup(func() { copyToClipboard = orig }) + + m := &model{ + g: &config.Global{}, + width: 100, + step: stepPreflight, + act: &activation{ + id: 3, + authURL: testAuthURL, + attempt: &bridges.Attempt{Endpoint: config.Bridged("", "b1")}, + cancel: func() {}, + }, + } + if !m.act.canOverride() { + t.Fatal("the override editor is inert here, so this does not test the collision it is about") + } + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}) + runCmd(t, cmd) + if len(copies) != 0 { + t.Errorf("a printable key copied: %q", copies) + } + if m.act.override.value != "c" { + t.Errorf("override = %q, want the printable key to reach the editor", m.act.override.value) + } + + _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlY}) + runCmd(t, cmd) + if len(copies) != 1 || copies[0] != testAuthURL { + t.Fatalf("clipboard = %q, want one copy of %q", copies, testAuthURL) + } + if m.act.override.value != "c" { + t.Errorf("override = %q, want ctrl+y to leave the editor alone", m.act.override.value) + } + + m.Update(clipboardMsg{id: 3}) + if footer := ansi.Strip(m.authFooter()); !strings.Contains(footer, authCopiedHint) { + t.Errorf("footer = %q, want it to confirm the copy", footer) + } +} + +// TestAuthFooterKeepsAWrappedLinkSelectable is the narrow terminal case. Bubble +// Tea's renderer cuts any line past the width, so the link has to wrap, and a +// wrapped link that shares its lines with prose or an indent is one nobody can +// drag out of the terminal. +func TestAuthFooterKeepsAWrappedLinkSelectable(t *testing.T) { + m := &model{ + g: &config.Global{}, + width: 30, + act: &activation{id: 3, authURL: testAuthURL}, + } + lines := strings.Split(ansi.Strip(m.authFooter()), "\n") + prose := strings.Count(m.wrapText("", authProse), "\n") + 1 + hint := strings.Count(m.wrapText("", authCopyHint), "\n") + 1 + link := lines[prose : len(lines)-hint] + if len(link) < 2 { + t.Fatalf("footer = %q, want a link too long for %d columns to have wrapped", lines, m.width) + } + if joined := strings.Join(link, ""); joined != testAuthURL { + t.Errorf("link lines joined = %q, want %q: a paste of the selection would not resolve", joined, testAuthURL) + } + for _, line := range lines { + if ansi.StringWidth(line) > m.width { + t.Errorf("line %q is wider than the %d column terminal, so the renderer will cut it", line, m.width) + } + if strings.TrimSpace(line) != line { + t.Errorf("line %q carries padding the selection would pick up", line) + } + } +} + +// TestAuthFooterLinksEveryWrappedLine checks the OSC 8 hyperlink that makes +// ctrl-click work on a link the screen had to break in two: each piece carries +// the whole URL, under one id so the terminal treats them as one target. +func TestAuthFooterLinksEveryWrappedLine(t *testing.T) { + m := &model{ + g: &config.Global{}, + width: 30, + act: &activation{id: 3, authURL: testAuthURL}, + } + footer := m.authFooter() + open := ansi.SetHyperlink(testAuthURL, "id=aperture-auth") + if got := strings.Count(footer, open); got != 2 { + t.Errorf("footer opens the hyperlink %d times, want one per wrapped line: %q", got, footer) + } + if got := strings.Count(footer, ansi.ResetHyperlink()); got != 2 { + t.Errorf("footer closes the hyperlink %d times, want one per wrapped line: %q", got, footer) } } @@ -828,14 +1573,14 @@ func TestFailureViewWrapsDiagnostics(t *testing.T) { Debug: true, Settings: config.Settings{ Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work Bridge"}}, - Endpoints: []config.Endpoint{{URL: "http://aperture.example.ts.net", BridgeID: "bridge-abcdef"}}, + Endpoints: []config.Endpoint{config.Bridged("http://aperture.example.ts.net", "bridge-abcdef")}, }, }, width: 50, forcedToEndpoint: true, preflightErr: "bridge Work Bridge could not reach endpoint: lookup aperture.example.ts.net on 127.0.0.53:53: no such host", - bridgeLogs: []string{ - `Bridge network: state=Running tailnet="example.com" dns_suffix="example.ts.net" peers=597`, + bridgeLogs: []bridgeLine{ + {event: connection.Note(`Bridge network: state=Running tailnet="example.com" dns_suffix="example.ts.net" peers=597`)}, }, } m.resetStack(m.setupGuideMenu()) @@ -853,7 +1598,7 @@ func TestEndpointLabel_ShowsBridge(t *testing.T) { Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work"}}, }, }} - got := m.endpointLabel(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}) + got := m.endpointLabel(config.Bridged("http://ai", "bridge-abcdef")) if got != "http://ai via Work" { t.Errorf("endpointLabel = %q", got) } @@ -865,7 +1610,7 @@ func TestRootHeaderShowsLogicalBridgeEndpoint(t *testing.T) { ApertureHost: "http://127.0.0.1:41234", Settings: config.Settings{ Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work"}}, - Endpoints: []config.Endpoint{{URL: "http://ai", BridgeID: "bridge-abcdef"}}, + Endpoints: []config.Endpoint{config.Bridged("http://ai", "bridge-abcdef")}, }, }, step: stepMenu, @@ -877,3 +1622,122 @@ func TestRootHeaderShowsLogicalBridgeEndpoint(t *testing.T) { t.Fatalf("root header = %q", header) } } + +// TestBridgeLogSinkNeverDropsTheLoginLink covers the failure that left a slow +// bridge unrecoverable: the sink discarded whatever arrived while its buffer +// was full, and under -debug the tsnet backend logger shares that buffer, so a +// burst of chatter could take the one line the user cannot proceed without. +func TestBridgeLogSinkNeverDropsTheLoginLink(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ch := make(chan bridgeLine, 4) + emit := bridgeLogSink(ctx, ch, time.Now()) + + for i := range cap(ch) + 20 { + emit(connection.Notef("magicsock: noisy line %d", i)) + } + if len(ch) != cap(ch) { + t.Fatalf("buffer holds %d lines, want it full at %d", len(ch), cap(ch)) + } + + link, err := connection.ParseLoginLink(testAuthURL) + if err != nil { + t.Fatal(err) + } + sent := make(chan struct{}) + go func() { + emit(connection.LoginRequired(link)) + close(sent) + }() + + // The update loop draining is what makes room. Without it the send above + // waits, which is the point: it waits rather than vanishing. + deadline := time.After(5 * time.Second) + for { + select { + case line := <-ch: + if line.event.Link != nil { + <-sent + return + } + case <-deadline: + t.Fatal("the login link never arrived; a full buffer swallowed it") + } + } +} + +// TestRemoveConnectionRowTakesTheBridgeWithIt covers what one press of "d" is +// supposed to mean. One picker row is two objects in settings, and removing +// only the endpoint left the bridge re-listed as a bare "Connect via" row: the +// row read as having moved rather than gone. +func TestRemoveConnectionRowTakesTheBridgeWithIt(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := &model{g: &config.Global{Settings: config.Settings{ + Endpoints: []config.Endpoint{ + config.Direct("http://active"), + config.Bridged("http://ai", "b1"), + }, + Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, + }}} + + rows := m.connectionRows() + if len(rows) != 2 { + t.Fatalf("connectionRows() = %d rows, want 2", len(rows)) + } + m.removeRow(rows[1]) + + after := m.connectionRows() + if len(after) != 1 { + t.Fatalf("after one remove: %d rows, want 1", len(after)) + } + if after[0].ep.URL() != "http://active" { + t.Errorf("surviving row = %q, want the untouched endpoint", after[0].ep.URL()) + } + if len(m.g.Settings.Bridges) != 0 { + t.Errorf("bridges = %+v, want the orphan gone with its endpoint", m.g.Settings.Bridges) + } +} + +// TestRemoveConnectionRowKeepsASharedBridge is the other half: the cascade may +// only take a bridge nothing else points at. +func TestRemoveConnectionRowKeepsASharedBridge(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + m := &model{g: &config.Global{Settings: config.Settings{ + Endpoints: []config.Endpoint{ + config.Direct("http://active"), + config.Bridged("http://ai", "b1"), + config.Bridged("http://other", "b1"), + }, + Bridges: []config.Bridge{{ID: "b1", Name: "work"}}, + }}} + + m.removeRow(m.connectionRows()[1]) + + if len(m.g.Settings.Bridges) != 1 { + t.Fatalf("bridges = %+v, want the bridge kept for the other endpoint", m.g.Settings.Bridges) + } + rows := m.connectionRows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2 with no bare bridge row", len(rows)) + } + for _, r := range rows { + if !r.saved { + t.Errorf("unexpected bare bridge row: %+v", r) + } + } +} + +// A shutdown error is memoized by Machines.Close, so a second Close from the +// error screen's q returns the same error and the user can never leave. The +// TUI quits regardless; main calls Close again and reports the error on +// stderr once the terminal is back. +func TestQuitMsgWithAnErrorStillQuits(t *testing.T) { + m := pickerModel(t) + _, cmd := m.Update(quitMsg{Err: errors.New("route close failed")}) + if cmd == nil { + t.Fatal("no command after a failed shutdown") + } + if _, quitting := cmd().(tea.QuitMsg); !quitting { + t.Errorf("cmd() = %T, want tea.QuitMsg", cmd()) + } +}